This RPubs article is written for students who may know physics or astronomy, but may not yet know operations research or game theory. The aim is to build the method slowly:
The code is intentionally small and transparent. It is not meant to replace professional astronomical schedulers; it is a conceptual bridge from mathematical theory to multimessenger-astronomy decision making.
When a gravitational-wave alert arrives, the source position is uncertain. The event is not given as one exact point in the sky. Instead, the gravitational-wave pipelines release a sky-localization map: a grid of sky pixels, each with posterior probability.
A telescope network must answer:
Which telescope should observe which sky tile, at which time, and how should we avoid wasting scarce telescope time on redundant observations?
This is a resource-allocation problem under uncertainty. That is exactly the language of operations research.
If each telescope is autonomous, then another question appears:
How can local telescope decisions align with global scientific value?
That is the language of game theory.
Let
\[ \mathcal H = \{1,\ldots,n\} \]
be a set of sky pixels. Pixel \(h\) has posterior probability
\[ \pi_h \geq 0, \qquad \sum_{h=1}^n \pi_h = 1. \]
Interpretation: before observing, \(\pi_h\) is the probability that the source lies in pixel \(h\).
An action \(a\) means:
\[ a = \text{``telescope } k \text{ observes tile } j \text{ at time slot } \tau \text{''}. \]
Let
\[ q_{ah} \in [0,1] \]
be the conditional probability that action \(a\) detects the counterpart if the source is in pixel \(h\). A simple model is
\[ q_{ah} = \mathbf 1\{h \in C_a\} \times \text{visibility quality} \times \text{depth quality} \times \text{time-decay factor}. \]
Here \(C_a\) is the footprint of the tile observed by action \(a\).
For a schedule \(S\), meaning a set of actions, the probability of at least one detection if the true source is in pixel \(h\) is
\[ 1 - \prod_{a\in S}(1-q_{ah}). \]
Averaging this over the sky posterior gives
\[ F(S) = \sum_{h\in\mathcal H} \pi_h \left[ 1-\prod_{a\in S}(1-q_{ah}) \right]. \]
This is the core objective.
If action \(a\) costs \(c_a\), define
\[ C(S)=\sum_{a\in S}c_a, \qquad \Phi(S)=F(S)-C(S). \]
Here \(F(S)\) is expected detection value, while \(\Phi(S)\) is net welfare.
We now make a small two-dimensional toy sky. This is not a true HEALPix sphere. It is a teaching analogue: a sky patch with posterior probability concentrated in two regions.
n_pix <- 450
sky <- data.frame(
pixel = seq_len(n_pix),
x = runif(n_pix, -1, 1),
y = runif(n_pix, -1, 1)
)
gaussian_bump <- function(x, y, mx, my, sx, sy, weight = 1) {
weight * exp(-0.5 * (((x - mx) / sx)^2 + ((y - my) / sy)^2))
}
raw_prob <-
gaussian_bump(sky$x, sky$y, -0.35, 0.25, 0.22, 0.16, 1.00) +
gaussian_bump(sky$x, sky$y, 0.42, -0.18, 0.30, 0.20, 0.70) +
0.02
sky$pi <- raw_prob / sum(raw_prob)
ggplot(sky, aes(x, y)) +
geom_point(aes(size = pi, colour = pi), alpha = 0.75) +
coord_equal() +
labs(
title = "Toy gravitational-wave sky map",
subtitle = "Each point is a sky pixel; point size and colour represent posterior probability.",
x = "Toy sky coordinate x",
y = "Toy sky coordinate y",
colour = "Posterior",
size = "Posterior"
)The brightest pixels represent the most probable sky regions. A good schedule should cover these regions quickly, but it should not blindly duplicate coverage if another telescope is already observing the same high-probability part.
We create 12 possible sky tiles. Each tile has a centre and a circular footprint in this toy coordinate system.
tile_grid <- expand.grid(
tx = seq(-0.75, 0.75, length.out = 4),
ty = seq(-0.60, 0.60, length.out = 3)
)
tiles <- data.frame(
tile = seq_len(nrow(tile_grid)),
tx = tile_grid$tx,
ty = tile_grid$ty,
radius = 0.38
)
telescopes <- data.frame(
telescope = c(
"ZTF-like",
"DECam-like",
"MeerLICHT-like",
"SkyMapper-like",
"BlackGEM-like"
),
hemisphere = c("north", "south", "south", "south", "south"),
fov_factor = c(1.15, 1.00, 0.72, 0.85, 0.78),
depth_factor = c(0.58, 0.92, 0.82, 0.68, 0.80),
cost_per_action = c(0.0019, 0.0022, 0.0018, 0.0017, 0.0019),
budget = c(3, 3, 3, 2, 3)
)
slots <- c(1, 2, 3, 4)
kable(telescopes, caption = "Toy heterogeneous telescope network.")| telescope | hemisphere | fov_factor | depth_factor | cost_per_action | budget |
|---|---|---|---|---|---|
| ZTF-like | north | 1.15 | 0.58 | 0.0019 | 3 |
| DECam-like | south | 1.00 | 0.92 | 0.0022 | 3 |
| MeerLICHT-like | south | 0.72 | 0.82 | 0.0018 | 3 |
| SkyMapper-like | south | 0.85 | 0.68 | 0.0017 | 2 |
| BlackGEM-like | south | 0.78 | 0.80 | 0.0019 | 3 |
The names are chosen to be astronomy-flavoured, not exact engineering models. The important feature is heterogeneity: different facilities have different depth, cost, and visibility.
ggplot() +
geom_point(data = sky, aes(x, y, size = pi), alpha = 0.25) +
geom_point(data = tiles, aes(tx, ty), size = 3, shape = 4, stroke = 1.2) +
geom_text(data = tiles, aes(tx, ty, label = tile), nudge_y = 0.06) +
coord_equal() +
labs(
title = "Candidate observation tiles",
subtitle = "Crosses indicate tile centres. Each tile covers a circular neighbourhood.",
x = "Toy sky coordinate x",
y = "Toy sky coordinate y",
size = "Posterior"
)An action combines telescope, tile, and time slot. We also build a matrix \(Q\), where row \(a\) and column \(h\) stores \(q_{ah}\).
make_actions <- function(sky, tiles, telescopes, slots) {
action_list <- list()
Q_list <- list()
id <- 1
for (k in seq_len(nrow(telescopes))) {
tel <- telescopes[k, ]
for (j in seq_len(nrow(tiles))) {
tile <- tiles[j, ]
distance <- sqrt((sky$x - tile$tx)^2 + (sky$y - tile$ty)^2)
in_footprint <- as.numeric(distance <= tile$radius * tel$fov_factor)
# Toy visibility:
# northern facility prefers positive y; southern facilities prefer negative y,
# but neither is completely blind in this simplified example.
if (tel$hemisphere == "north") {
visibility <- 0.45 + 0.55 * pmin(pmax((sky$y + 1) / 2, 0), 1)
} else {
visibility <- 0.45 + 0.55 * pmin(pmax((1 - sky$y) / 2, 0), 1)
}
for (s in slots) {
# Counterpart fading: later slots have smaller detection probability.
time_decay <- exp(-0.09 * (s - 1))
q <- in_footprint * visibility * tel$depth_factor * time_decay
# Keep q in [0, 1].
q <- pmin(pmax(q, 0), 1)
action_id <- paste0("a", id)
action_list[[id]] <- data.frame(
action = action_id,
telescope = tel$telescope,
tile = tile$tile,
slot = s,
cost = tel$cost_per_action,
local_score = sum(sky$pi * q),
stringsAsFactors = FALSE
)
Q_list[[id]] <- q
id <- id + 1
}
}
}
actions <- do.call(rbind, action_list)
Q <- do.call(rbind, Q_list)
rownames(Q) <- actions$action
list(actions = actions, Q = Q)
}
built <- make_actions(sky, tiles, telescopes, slots)
actions <- built$actions
Q <- built$Q
actions %>%
arrange(desc(local_score)) %>%
head(12) %>%
mutate(across(where(is.numeric), ~ round(.x, 4))) %>%
kable(caption = "Top 12 individual actions by posterior-weighted local detection score.")| action | telescope | tile | slot | cost | local_score |
|---|---|---|---|---|---|
| a69 | DECam-like | 6 | 1 | 0.0022 | 0.2151 |
| a73 | DECam-like | 7 | 1 | 0.0022 | 0.2077 |
| a70 | DECam-like | 6 | 2 | 0.0022 | 0.1966 |
| a21 | ZTF-like | 6 | 1 | 0.0019 | 0.1959 |
| a74 | DECam-like | 7 | 2 | 0.0022 | 0.1898 |
| a71 | DECam-like | 6 | 3 | 0.0022 | 0.1797 |
| a22 | ZTF-like | 6 | 2 | 0.0019 | 0.1790 |
| a75 | DECam-like | 7 | 3 | 0.0022 | 0.1735 |
| a72 | DECam-like | 6 | 4 | 0.0022 | 0.1642 |
| a23 | ZTF-like | 6 | 3 | 0.0019 | 0.1636 |
| a76 | DECam-like | 7 | 4 | 0.0022 | 0.1586 |
| a25 | ZTF-like | 7 | 1 | 0.0019 | 0.1508 |
We now implement
\[ F(S) = \sum_h \pi_h \left[ 1-\prod_{a\in S}(1-q_{ah}) \right] \]
and
\[ \Phi(S)=F(S)-C(S). \]
F_value <- function(action_set, Q, pi_vec) {
action_set <- unique(action_set)
if (length(action_set) == 0) return(0)
Qs <- Q[action_set, , drop = FALSE]
detect_by_pixel <- 1 - apply(1 - Qs, 2, prod)
sum(pi_vec * detect_by_pixel)
}
cost_value <- function(action_set, actions) {
action_set <- unique(action_set)
if (length(action_set) == 0) return(0)
sum(actions$cost[match(action_set, actions$action)])
}
Phi_value <- function(action_set, Q, pi_vec, actions) {
F_value(action_set, Q, pi_vec) - cost_value(action_set, actions)
}
example_set <- actions %>%
arrange(desc(local_score)) %>%
head(5) %>%
pull(action)
data.frame(
schedule_size = length(example_set),
expected_detection = F_value(example_set, Q, sky$pi),
cost = cost_value(example_set, actions),
welfare = Phi_value(example_set, Q, sky$pi, actions)
) %>%
mutate(across(where(is.numeric), ~ round(.x, 5))) %>%
kable(caption = "Detection value and welfare for a simple top-five schedule.")| schedule_size | expected_detection | cost | welfare |
|---|---|---|---|
| 5 | 0.55353 | 0.0107 | 0.54283 |
A central planner can greedily add actions that increase welfare the most, subject to each telescope’s budget and the restriction that a telescope cannot observe two tiles in the same slot.
This is an operations-research heuristic. It is simple, interpretable, and connected to the theory of submodular maximization.
is_feasible_global <- function(candidate_set, actions, telescopes) {
if (length(candidate_set) == 0) return(TRUE)
sel <- actions[match(candidate_set, actions$action), ]
# No telescope uses the same time slot twice.
duplicated_pair <- any(duplicated(paste(sel$telescope, sel$slot)))
if (duplicated_pair) return(FALSE)
# Telescope budget.
tab <- table(sel$telescope)
for (nm in names(tab)) {
b <- telescopes$budget[match(nm, telescopes$telescope)]
if (tab[[nm]] > b) return(FALSE)
}
TRUE
}
centralized_greedy <- function(actions, Q, pi_vec, telescopes, max_total = 12) {
chosen <- character(0)
history <- data.frame(step = integer(), added = character(), welfare = numeric())
for (step in seq_len(max_total)) {
base <- Phi_value(chosen, Q, pi_vec, actions)
best_gain <- 0
best_action <- NA_character_
for (a in actions$action) {
cand <- unique(c(chosen, a))
if (length(cand) == length(chosen)) next
if (!is_feasible_global(cand, actions, telescopes)) next
gain <- Phi_value(cand, Q, pi_vec, actions) - base
if (gain > best_gain) {
best_gain <- gain
best_action <- a
}
}
if (is.na(best_action) || best_gain <= 1e-12) break
chosen <- unique(c(chosen, best_action))
history <- rbind(
history,
data.frame(
step = step,
added = best_action,
welfare = Phi_value(chosen, Q, pi_vec, actions)
)
)
}
list(schedule = chosen, history = history)
}
greedy_out <- centralized_greedy(actions, Q, sky$pi, telescopes, max_total = 12)
greedy_table <- actions[match(greedy_out$schedule, actions$action), ] %>%
mutate(across(where(is.numeric), ~ round(.x, 4)))
kable(greedy_table, caption = "Centralized greedy toy schedule.")| action | telescope | tile | slot | cost | local_score | |
|---|---|---|---|---|---|---|
| 69 | a69 | DECam-like | 6 | 1 | 0.0022 | 0.2151 |
| 74 | a74 | DECam-like | 7 | 2 | 0.0022 | 0.1898 |
| 21 | a21 | ZTF-like | 6 | 1 | 0.0019 | 0.1959 |
| 59 | a59 | DECam-like | 3 | 3 | 0.0022 | 0.0815 |
| 38 | a38 | ZTF-like | 10 | 2 | 0.0019 | 0.1050 |
| 173 | a173 | SkyMapper-like | 8 | 1 | 0.0017 | 0.0682 |
| 19 | a19 | ZTF-like | 5 | 3 | 0.0019 | 0.0808 |
| 217 | a217 | BlackGEM-like | 7 | 1 | 0.0019 | 0.1147 |
| 158 | a158 | SkyMapper-like | 4 | 2 | 0.0017 | 0.0280 |
| 133 | a133 | MeerLICHT-like | 10 | 1 | 0.0018 | 0.0419 |
| 222 | a222 | BlackGEM-like | 8 | 2 | 0.0019 | 0.0497 |
| 118 | a118 | MeerLICHT-like | 6 | 2 | 0.0018 | 0.1056 |
ggplot(greedy_out$history, aes(step, welfare)) +
geom_line(linewidth = 1) +
geom_point(size = 2.5) +
labs(
title = "Centralized greedy improvement path",
subtitle = "The planner repeatedly adds the feasible action with the largest welfare gain.",
x = "Greedy step",
y = "Net welfare"
)The function \(F(S)\) is submodular. Intuitively:
If a sky region has already been covered by several observations, one more observation of the same region adds less new information than it would have added at the beginning.
Formally, for \(S \subseteq T\) and \(b \notin T\),
\[ F(S\cup\{b\})-F(S) \geq F(T\cup\{b\})-F(T). \]
The next small experiment demonstrates this effect.
# Choose one candidate action b and compare its gain when added to a small
# schedule versus a larger schedule.
b <- actions %>%
arrange(desc(local_score)) %>%
slice(8) %>%
pull(action)
S_small <- greedy_out$schedule[1:2]
T_large <- greedy_out$schedule[1:8]
gain_small <- F_value(c(S_small, b), Q, sky$pi) - F_value(S_small, Q, sky$pi)
gain_large <- F_value(c(T_large, b), Q, sky$pi) - F_value(T_large, Q, sky$pi)
data.frame(
candidate_action = b,
marginal_gain_after_small_schedule = gain_small,
marginal_gain_after_large_schedule = gain_large
) %>%
mutate(across(where(is.numeric), ~ round(.x, 6))) %>%
kable(caption = "A numerical illustration of diminishing returns.")| candidate_action | marginal_gain_after_small_schedule | marginal_gain_after_large_schedule |
|---|---|---|
| a75 | 0.053571 | 0.018928 |
Now suppose each telescope is a player.
\[ S=(S_1,\ldots,S_K). \]
\[ \bar S = \bigcup_{k=1}^K S_k. \]
If every telescope greedily maximizes only its own local posterior coverage, it may duplicate other facilities. A better idea is to reward a telescope for its marginal contribution to the global welfare.
Define
\[ u_k(S_k,S_{-k}) = \Phi\left(S_k\cup \bar S_{-k}\right) - \Phi\left(\bar S_{-k}\right). \]
This means:
Telescope \(k\)’s utility is exactly the extra welfare added by its chosen actions beyond what the other telescopes already provide.
This discourages redundant observations.
Define the potential function
\[ \Psi(S_1,\ldots,S_K)=\Phi\left(\bigcup_{k=1}^K S_k\right). \]
If telescope \(k\) changes from \(S_k\) to \(S'_k\), then
\[ u_k(S'_k,S_{-k})-u_k(S_k,S_{-k}) = \Psi(S'_k,S_{-k})-\Psi(S_k,S_{-k}). \]
Therefore, the game is an exact potential game.
This is powerful because in a finite game, repeated strict better responses must eventually stop. The stopping point is a pure-strategy Nash equilibrium.
The exact local best-response problem can become combinatorial. In the manuscript-level problem, the best-response subproblem is an operations-research problem that may be solved by local search, dynamic programming, integer programming, or greedy approximation.
For RPubs teaching, we use a fast approximation:
This preserves the concept while avoiding long knitting times.
all_local_strategies <- function(actions, telescope_name, budget, top_per_slot = 3) {
tel_actions <- actions[actions$telescope == telescope_name, ]
tel_actions$net_score <- tel_actions$local_score - tel_actions$cost
# Candidate pruning: keep only the top few actions in each time slot.
pruned <- do.call(
rbind,
lapply(split(tel_actions, tel_actions$slot), function(d) {
d <- d[order(-d$net_score), ]
d[seq_len(min(nrow(d), top_per_slot)), ]
})
)
ids <- pruned$action
max_b <- min(budget, length(ids), length(unique(pruned$slot)))
strategies <- list(character(0))
if (max_b == 0) return(strategies)
for (b in seq_len(max_b)) {
cmb <- combn(ids, b, simplify = FALSE)
for (s in cmb) {
sel <- actions[match(s, actions$action), ]
if (!any(duplicated(sel$slot))) {
strategies[[length(strategies) + 1]] <- s
}
}
}
strategies
}
strategy_library <- list()
for (i in seq_len(nrow(telescopes))) {
nm <- telescopes$telescope[i]
strategy_library[[nm]] <- all_local_strategies(
actions = actions,
telescope_name = nm,
budget = telescopes$budget[i],
top_per_slot = 3
)
}
data.frame(
telescope = names(strategy_library),
number_of_candidate_strategies = vapply(strategy_library, length, integer(1))
) %>%
kable(caption = "Pruned local strategy-library sizes used for fast RPubs knitting.")| telescope | number_of_candidate_strategies | |
|---|---|---|
| ZTF-like | ZTF-like | 175 |
| DECam-like | DECam-like | 175 |
| MeerLICHT-like | MeerLICHT-like | 175 |
| SkyMapper-like | SkyMapper-like | 67 |
| BlackGEM-like | BlackGEM-like | 175 |
A best-response update means that one telescope changes its schedule to maximize its marginal contribution, keeping all other telescopes fixed.
union_profile <- function(profile) {
unique(unlist(profile, use.names = FALSE))
}
best_response_for_tel <- function(tel_name, profile, strategy_library, Q, pi_vec, actions) {
others <- profile
others[[tel_name]] <- character(0)
other_actions <- union_profile(others)
best_strategy <- profile[[tel_name]]
best_utility <- -Inf
for (candidate in strategy_library[[tel_name]]) {
utility <- Phi_value(c(other_actions, candidate), Q, pi_vec, actions) -
Phi_value(other_actions, Q, pi_vec, actions)
if (utility > best_utility + 1e-12) {
best_utility <- utility
best_strategy <- candidate
}
}
list(strategy = best_strategy, utility = best_utility)
}
potential_game_scheduler <- function(telescopes, strategy_library, Q, pi_vec, actions,
max_rounds = 8, tol = 1e-10) {
tel_names <- telescopes$telescope
profile <- setNames(vector("list", length(tel_names)), tel_names)
for (nm in tel_names) profile[[nm]] <- character(0)
history <- data.frame(
round = integer(),
telescope = character(),
welfare = numeric(),
changed = logical(),
stringsAsFactors = FALSE
)
for (r in seq_len(max_rounds)) {
any_changed <- FALSE
for (nm in tel_names) {
old <- profile[[nm]]
br <- best_response_for_tel(nm, profile, strategy_library, Q, pi_vec, actions)
new <- br$strategy
changed <- !setequal(old, new)
if (changed) any_changed <- TRUE
profile[[nm]] <- new
history <- rbind(
history,
data.frame(
round = r,
telescope = nm,
welfare = Phi_value(union_profile(profile), Q, pi_vec, actions),
changed = changed,
stringsAsFactors = FALSE
)
)
}
if (!any_changed) break
}
list(profile = profile, schedule = union_profile(profile), history = history)
}
pg_out <- potential_game_scheduler(
telescopes = telescopes,
strategy_library = strategy_library,
Q = Q,
pi_vec = sky$pi,
actions = actions,
max_rounds = 8
)
pg_schedule_table <- actions[match(pg_out$schedule, actions$action), ] %>%
arrange(telescope, slot) %>%
mutate(across(where(is.numeric), ~ round(.x, 4)))
kable(pg_schedule_table, caption = "Potential-game toy schedule after best-response convergence.")| action | telescope | tile | slot | cost | local_score |
|---|---|---|---|---|---|
| a221 | BlackGEM-like | 8 | 1 | 0.0019 | 0.0544 |
| a214 | BlackGEM-like | 6 | 2 | 0.0019 | 0.1166 |
| a223 | BlackGEM-like | 8 | 3 | 0.0019 | 0.0454 |
| a73 | DECam-like | 7 | 1 | 0.0022 | 0.2077 |
| a70 | DECam-like | 6 | 2 | 0.0022 | 0.1966 |
| a79 | DECam-like | 8 | 3 | 0.0022 | 0.0881 |
| a121 | MeerLICHT-like | 7 | 1 | 0.0018 | 0.1049 |
| a118 | MeerLICHT-like | 6 | 2 | 0.0018 | 0.1056 |
| a119 | MeerLICHT-like | 6 | 3 | 0.0018 | 0.0965 |
| a169 | SkyMapper-like | 7 | 1 | 0.0017 | 0.1212 |
| a170 | SkyMapper-like | 7 | 2 | 0.0017 | 0.1108 |
| a21 | ZTF-like | 6 | 1 | 0.0019 | 0.1959 |
| a38 | ZTF-like | 10 | 2 | 0.0019 | 0.1050 |
| a39 | ZTF-like | 10 | 3 | 0.0019 | 0.0959 |
ggplot(pg_out$history, aes(seq_along(welfare), welfare)) +
geom_line(linewidth = 1) +
geom_point(aes(shape = changed), size = 2.4) +
labs(
title = "Best-response dynamics in the toy potential game",
subtitle = "Each point is one telescope update. The potential is the net welfare of the aggregate schedule.",
x = "Update index",
y = "Potential / net welfare",
shape = "Changed?"
)We now compare four methods:
independent_greedy <- function(actions, Q, pi_vec, telescopes) {
chosen <- character(0)
for (i in seq_len(nrow(telescopes))) {
nm <- telescopes$telescope[i]
b <- telescopes$budget[i]
tel_actions <- actions[actions$telescope == nm, ]
tel_actions$net <- tel_actions$local_score - tel_actions$cost
tel_actions <- tel_actions[order(-tel_actions$net), ]
local <- character(0)
used_slots <- integer(0)
for (ii in seq_len(nrow(tel_actions))) {
if (length(local) >= b) break
if (!(tel_actions$slot[ii] %in% used_slots)) {
local <- c(local, tel_actions$action[ii])
used_slots <- c(used_slots, tel_actions$slot[ii])
}
}
chosen <- c(chosen, local)
}
unique(chosen)
}
random_feasible <- function(actions, telescopes) {
chosen <- character(0)
for (i in seq_len(nrow(telescopes))) {
nm <- telescopes$telescope[i]
b <- telescopes$budget[i]
tel_actions <- actions[actions$telescope == nm, ]
tel_actions <- tel_actions[sample(seq_len(nrow(tel_actions))), ]
local <- character(0)
used_slots <- integer(0)
for (ii in seq_len(nrow(tel_actions))) {
if (length(local) >= b) break
if (!(tel_actions$slot[ii] %in% used_slots)) {
local <- c(local, tel_actions$action[ii])
used_slots <- c(used_slots, tel_actions$slot[ii])
}
}
chosen <- c(chosen, local)
}
unique(chosen)
}
schedules <- list(
"centralized_greedy" = greedy_out$schedule,
"potential_game" = pg_out$schedule,
"independent_greedy" = independent_greedy(actions, Q, sky$pi, telescopes),
"random_feasible" = random_feasible(actions, telescopes)
)
method_summary <- data.frame(
method = names(schedules),
n_actions = vapply(schedules, length, integer(1)),
expected_detection = vapply(schedules, F_value, numeric(1), Q = Q, pi_vec = sky$pi),
cost = vapply(schedules, cost_value, numeric(1), actions = actions),
welfare = vapply(schedules, Phi_value, numeric(1), Q = Q, pi_vec = sky$pi, actions = actions),
unique_tiles = vapply(schedules, function(s) length(unique(actions$tile[match(s, actions$action)])), integer(1)),
telescopes_used = vapply(schedules, function(s) length(unique(actions$telescope[match(s, actions$action)])), integer(1))
) %>%
arrange(desc(welfare))
method_summary %>%
mutate(across(where(is.numeric), ~ round(.x, 5))) %>%
kable(caption = "Toy benchmark comparison.")| method | n_actions | expected_detection | cost | welfare | unique_tiles | telescopes_used | |
|---|---|---|---|---|---|---|---|
| centralized_greedy | centralized_greedy | 12 | 0.75324 | 0.0231 | 0.73014 | 7 | 5 |
| potential_game | potential_game | 14 | 0.69443 | 0.0268 | 0.66763 | 4 | 5 |
| independent_greedy | independent_greedy | 14 | 0.54888 | 0.0268 | 0.52208 | 2 | 5 |
| random_feasible | random_feasible | 14 | 0.48288 | 0.0268 | 0.45608 | 9 | 5 |
method_long <- method_summary %>%
select(method, expected_detection, welfare) %>%
pivot_longer(cols = c(expected_detection, welfare), names_to = "metric", values_to = "value")
ggplot(method_long, aes(reorder(method, value), value, fill = metric)) +
geom_col(position = "dodge") +
coord_flip() +
labs(
title = "Toy scheduler comparison",
subtitle = "The potential-game schedule is a decentralized alternative to a centralized planner.",
x = "Method",
y = "Score",
fill = "Metric"
)The schedule tells us what was observed. But in multi-observatory campaigns, we also want to know:
Which telescope made how much marginal contribution to the final scientific opportunity?
The Shapley value answers this by averaging marginal contribution over all possible arrival orders of the players.
For the toy teaching example, we compute a realized Shapley value: the action set selected by each telescope is fixed by the potential-game schedule, and we allocate the final welfare among telescopes.
all_permutations <- function(x) {
if (length(x) == 1) return(list(x))
out <- list()
for (i in seq_along(x)) {
rest <- x[-i]
sub <- all_permutations(rest)
for (s in sub) out[[length(out) + 1]] <- c(x[i], s)
}
out
}
realized_shapley <- function(profile, Q, pi_vec, actions) {
players <- names(profile)
perms <- all_permutations(players)
credit <- setNames(rep(0, length(players)), players)
for (perm in perms) {
coalition_actions <- character(0)
current_value <- Phi_value(coalition_actions, Q, pi_vec, actions)
for (p in perm) {
new_actions <- unique(c(coalition_actions, profile[[p]]))
new_value <- Phi_value(new_actions, Q, pi_vec, actions)
credit[p] <- credit[p] + (new_value - current_value)
coalition_actions <- new_actions
current_value <- new_value
}
}
credit / length(perms)
}
toy_shapley <- realized_shapley(pg_out$profile, Q, sky$pi, actions)
shapley_table <- data.frame(
telescope = names(toy_shapley),
realized_shapley_value = as.numeric(toy_shapley),
n_actions = vapply(pg_out$profile, length, integer(1))
) %>%
arrange(desc(realized_shapley_value))
shapley_table %>%
mutate(realized_shapley_value = round(realized_shapley_value, 5)) %>%
kable(caption = "Realized Shapley credit for the toy potential-game schedule.")| telescope | realized_shapley_value | n_actions | |
|---|---|---|---|
| DECam-like | DECam-like | 0.21409 | 3 |
| ZTF-like | ZTF-like | 0.19720 | 3 |
| MeerLICHT-like | MeerLICHT-like | 0.09935 | 3 |
| BlackGEM-like | BlackGEM-like | 0.08066 | 3 |
| SkyMapper-like | SkyMapper-like | 0.07632 | 2 |
ggplot(shapley_table, aes(reorder(telescope, realized_shapley_value), realized_shapley_value)) +
geom_col() +
coord_flip() +
labs(
title = "Realized Shapley credit in the toy telescope network",
subtitle = "Credit is assigned by average marginal contribution to final welfare.",
x = "Telescope",
y = "Realized Shapley value"
)The toy model can be read physically as follows:
This conceptual structure remains the same when the toy sky is replaced by a public gravitational-wave HEALPix sky map.
The following table reproduces the manuscript-style summary for two public gravitational-wave events. Here the data are embedded directly in the R Markdown so that the document is standalone.
real_metrics <- data.frame(
event = c(
"GW170817", "GW170817", "GW170817", "GW170817",
"GW190814", "GW190814", "GW190814", "GW190814"
),
method = c(
"proposed_potential_game", "centralized_greedy", "independent_greedy", "random_feasible",
"proposed_potential_game", "centralized_greedy", "independent_greedy", "random_feasible"
),
n_actions = c(9, 9, 9, 9, 12, 12, 12, 12),
expected_detection = c(0.121277, 0.120291, 0.120580, 0.094848,
0.654073, 0.662937, 0.641847, 0.624945),
unique_covered_mass = c(0.708058, 0.722862, 0.722862, 0.620815,
0.934254, 0.921806, 0.874235, 0.935519),
overlap_mass = c(0.448968, 0.278122, 0.340782, 0.215602,
0.861279, 0.859900, 0.861279, 0.864565),
total_cost = c(0.01925, 0.01925, 0.01925, 0.01925,
0.02500, 0.02500, 0.02500, 0.02500),
welfare = c(0.102027, 0.101041, 0.101330, 0.075598,
0.629073, 0.637937, 0.616847, 0.599945),
telescopes_used = c(3, 3, 3, 3, 4, 4, 4, 4),
unique_tiles = c(5, 6, 6, 5, 7, 6, 6, 8)
)
real_metrics %>%
mutate(across(where(is.numeric), ~ round(.x, 6))) %>%
kable(caption = "Public-event-style method comparison used in the manuscript.")| event | method | n_actions | expected_detection | unique_covered_mass | overlap_mass | total_cost | welfare | telescopes_used | unique_tiles |
|---|---|---|---|---|---|---|---|---|---|
| GW170817 | proposed_potential_game | 9 | 0.121277 | 0.708058 | 0.448968 | 0.01925 | 0.102027 | 3 | 5 |
| GW170817 | centralized_greedy | 9 | 0.120291 | 0.722862 | 0.278122 | 0.01925 | 0.101041 | 3 | 6 |
| GW170817 | independent_greedy | 9 | 0.120580 | 0.722862 | 0.340782 | 0.01925 | 0.101330 | 3 | 6 |
| GW170817 | random_feasible | 9 | 0.094848 | 0.620815 | 0.215602 | 0.01925 | 0.075598 | 3 | 5 |
| GW190814 | proposed_potential_game | 12 | 0.654073 | 0.934254 | 0.861279 | 0.02500 | 0.629073 | 4 | 7 |
| GW190814 | centralized_greedy | 12 | 0.662937 | 0.921806 | 0.859900 | 0.02500 | 0.637937 | 4 | 6 |
| GW190814 | independent_greedy | 12 | 0.641847 | 0.874235 | 0.861279 | 0.02500 | 0.616847 | 4 | 6 |
| GW190814 | random_feasible | 12 | 0.624945 | 0.935519 | 0.864565 | 0.02500 | 0.599945 | 4 | 8 |
ggplot(real_metrics, aes(method, welfare, fill = method)) +
geom_col(show.legend = FALSE) +
facet_wrap(~ event, scales = "free_y") +
coord_flip() +
labs(
title = "Welfare comparison for public-event-style results",
subtitle = "The proposed potential-game scheduler is decentralized and remains competitive with centralized greedy.",
x = "Method",
y = "Net welfare"
)ggplot(real_metrics, aes(method, expected_detection, fill = method)) +
geom_col(show.legend = FALSE) +
facet_wrap(~ event, scales = "free_y") +
coord_flip() +
labs(
title = "Expected detection comparison for public-event-style results",
subtitle = "Expected detection is posterior-weighted and accounts for repeated probabilistic coverage.",
x = "Method",
y = "Expected detection"
)proposed_schedule <- data.frame(
event = c(
"GW170817", "GW170817", "GW170817",
"GW190814", "GW190814", "GW190814", "GW190814"
),
telescope = c(
"Network_B_DECamLike",
"Network_C_MeerLICHTLike",
"Network_E_BlackGEMLike",
"Network_A_ZTFLike",
"Network_B_DECamLike",
"Network_C_MeerLICHTLike",
"Network_E_BlackGEMLike"
),
selected_slot_hours = c(
"10.5, 11.0, 11.5",
"4.5, 5.0, 5.5",
"10.5, 11.0, 11.5",
"13.0, 13.5, 14.0",
"10.5, 11.0, 11.5",
"4.0, 4.5, 5.0",
"10.0, 10.5, 11.0"
),
selected_tiles = c(
"3, 0, 2",
"1, 0, 4",
"3, 0, 2",
"8, 1, 7",
"0, 1, 3",
"1, 0, 2",
"4, 0, 1"
),
mean_q = c(
0.075302, 0.123426, 0.071225,
0.034507, 0.380429, 0.556140, 0.364986
)
)
proposed_schedule %>%
mutate(mean_q = round(mean_q, 6)) %>%
kable(caption = "Condensed proposed potential-game schedule for the two public-event-style examples.")| event | telescope | selected_slot_hours | selected_tiles | mean_q |
|---|---|---|---|---|
| GW170817 | Network_B_DECamLike | 10.5, 11.0, 11.5 | 3, 0, 2 | 0.075302 |
| GW170817 | Network_C_MeerLICHTLike | 4.5, 5.0, 5.5 | 1, 0, 4 | 0.123426 |
| GW170817 | Network_E_BlackGEMLike | 10.5, 11.0, 11.5 | 3, 0, 2 | 0.071225 |
| GW190814 | Network_A_ZTFLike | 13.0, 13.5, 14.0 | 8, 1, 7 | 0.034507 |
| GW190814 | Network_B_DECamLike | 10.5, 11.0, 11.5 | 0, 1, 3 | 0.380429 |
| GW190814 | Network_C_MeerLICHTLike | 4.0, 4.5, 5.0 | 1, 0, 2 | 0.556140 |
| GW190814 | Network_E_BlackGEMLike | 10.0, 10.5, 11.0 | 4, 0, 1 | 0.364986 |
ggplot(proposed_schedule, aes(telescope, mean_q, fill = event)) +
geom_col(position = "dodge") +
coord_flip() +
labs(
title = "Mean action quality in proposed schedules",
subtitle = "The schedule structure changes from event to event because visibility and posterior geometry change.",
x = "Telescope",
y = "Mean q",
fill = "Event"
)real_shapley <- data.frame(
event = c(
"GW170817", "GW170817", "GW170817", "GW170817", "GW170817",
"GW190814", "GW190814", "GW190814", "GW190814", "GW190814"
),
telescope = c(
"Network_C_MeerLICHTLike",
"Network_E_BlackGEMLike",
"Network_B_DECamLike",
"Network_A_ZTFLike",
"Network_D_SkyMapperLike",
"Network_C_MeerLICHTLike",
"Network_B_DECamLike",
"Network_E_BlackGEMLike",
"Network_A_ZTFLike",
"Network_D_SkyMapperLike"
),
realized_shapley_value = c(
0.050335, 0.026128, 0.025564, 0, 0,
0.270491, 0.160863, 0.157275, 0.040445, 0
),
n_actions = c(3, 3, 3, 0, 0, 3, 3, 3, 3, 0)
)
real_shapley %>%
mutate(realized_shapley_value = round(realized_shapley_value, 6)) %>%
kable(caption = "Realized Shapley-value decomposition for the public-event-style examples.")| event | telescope | realized_shapley_value | n_actions |
|---|---|---|---|
| GW170817 | Network_C_MeerLICHTLike | 0.050335 | 3 |
| GW170817 | Network_E_BlackGEMLike | 0.026128 | 3 |
| GW170817 | Network_B_DECamLike | 0.025564 | 3 |
| GW170817 | Network_A_ZTFLike | 0.000000 | 0 |
| GW170817 | Network_D_SkyMapperLike | 0.000000 | 0 |
| GW190814 | Network_C_MeerLICHTLike | 0.270491 | 3 |
| GW190814 | Network_B_DECamLike | 0.160863 | 3 |
| GW190814 | Network_E_BlackGEMLike | 0.157275 | 3 |
| GW190814 | Network_A_ZTFLike | 0.040445 | 3 |
| GW190814 | Network_D_SkyMapperLike | 0.000000 | 0 |
ggplot(real_shapley, aes(reorder(telescope, realized_shapley_value), realized_shapley_value, fill = event)) +
geom_col(position = "dodge") +
coord_flip() +
labs(
title = "Realized Shapley credit for public-event-style schedules",
subtitle = "Credit reflects marginal scientific contribution, not merely nominal participation.",
x = "Telescope",
y = "Realized Shapley value",
fill = "Event"
)The main conceptual movement is:
\[ \text{sky map} \longrightarrow \text{probabilistic objective} \longrightarrow \text{constrained scheduling} \longrightarrow \text{decentralized game} \longrightarrow \text{credit allocation}. \]
This is not merely a computational pipeline. It is a modelling philosophy.
The objective
\[ F(S) = \sum_h \pi_h \left[ 1-\prod_{a\in S}(1-q_{ah}) \right] \]
has diminishing returns. That is why greedy operations-research methods are sensible.
The utility
\[ u_k(S_k,S_{-k}) = \Phi(S_k\cup \bar S_{-k})-\Phi(\bar S_{-k}) \]
turns local telescope updates into an exact potential game. That is why decentralized best-response dynamics have a clean equilibrium interpretation.
The Shapley value then supplies a principled contribution score.
The public-event-style results should be interpreted carefully:
top_per_slot from 3 to 5 in the local strategy
library. How does speed change?expected_detection and
unique_covered_mass.Action: a telescope-tile-time choice.
Budget: a constraint on the number of observations, exposure time, or cost.
Posterior sky probability: the probability assigned to each sky pixel after gravitational-wave localization.
Coverage: the amount of posterior probability mass reached by a schedule.
Submodularity: a diminishing-returns property of set functions.
Operations research: mathematical optimization of decisions under constraints.
Game theory: mathematical study of interacting decision makers.
Potential game: a game where unilateral utility improvements match improvements in a global potential function.
Nash equilibrium: a profile where no single player can improve by changing alone.
Shapley value: a cooperative-game-theoretic allocation of total value based on average marginal contribution.
This standalone tutorial has built the paper’s theory from the ground up. We began with a probability map on the sky, defined telescope actions, constructed a posterior-weighted detection objective, introduced cost-adjusted welfare, and showed how scheduling becomes an operations-research problem. We then moved from centralized greedy scheduling to decentralized telescope decision making through an exact potential game. Finally, we used the Shapley value to turn the final schedule into a scientifically interpretable credit allocation.
The same backbone scales conceptually from the toy examples shown here to real public gravitational-wave sky maps and multi-observatory follow-up campaigns.
For students, the following topics are natural next steps: