coconet-python 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
coconet/model.py ADDED
@@ -0,0 +1,2255 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import math
5
+ import multiprocessing as mp
6
+ import shutil
7
+ import tempfile
8
+ import time
9
+ from collections.abc import Iterable
10
+ from concurrent.futures import Future, ProcessPoolExecutor
11
+ from dataclasses import dataclass, replace
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import threadpoolctl
17
+
18
+ from coconet.config import (
19
+ CoconetConfig,
20
+ effective_ensemble_workers,
21
+ use_parallel_ensemble_run,
22
+ )
23
+ from coconet.logging_utils import configure_logging
24
+ from coconet.netlogo import NetLogoRng, heading_from_dx_dy, nl_ceiling, nl_median, nl_round
25
+
26
+ CORAL_GROUPS = ("sa", "ta", "mo", "po", "fa", "tt")
27
+ # Coral larval kernel inner loop order in spawn (legacy NetLogo). RNG must draw
28
+ # uniforms in this order; do not reorder or merge with CORAL_GROUPS.
29
+ CORAL_SPAWN_KERNEL_ORDER = ("sa", "tt", "ta", "mo", "po", "fa")
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class ReefKernel:
35
+ con1: float
36
+ dir1: float
37
+ ang1: float
38
+ dis1: float
39
+ con2: float
40
+ dir2: float
41
+ ang2: float
42
+ dis2: float
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class SpinupCheckpoint:
47
+ """Immutable snapshot of site/reef initial conditions after ensemble-0 spinup."""
48
+
49
+ E_i: dict[str, np.ndarray]
50
+ G_i: dict[str, np.ndarray]
51
+ S_i: dict[str, np.ndarray]
52
+ B_i: np.ndarray
53
+ T_i: np.ndarray
54
+ C_i: dict[str, np.ndarray]
55
+ C_site_i: np.ndarray
56
+ R_site_i: np.ndarray
57
+
58
+
59
+ class CoconetModel:
60
+ """Python port of legacy CoCoNet NetLogo model."""
61
+
62
+ def __init__(self, config: CoconetConfig) -> None:
63
+ self.cfg = config
64
+ self.rng = NetLogoRng(1)
65
+
66
+ # Globals
67
+ self.small = 0.000001
68
+ self.year = config.start_year
69
+ self.ensemble = 0
70
+ self.per_km = (2 * 500) / ((25.5 - 10.5) * 111)
71
+ self.ha_per_site = 8
72
+ self.draw_year = 15
73
+ self.draw_month = 10
74
+ self.draw_fortnight = 1
75
+ self.search_mode = int(config.search_mode)
76
+ self.control_region = "GBR"
77
+ self.S_vessels = 0
78
+ self.catchment_condition = 0.0
79
+ self.flood_load = 0.2
80
+ self.cyclone_centre = -1
81
+ self.cyclone_radius = 0.0
82
+ self.cyclone_category = 0.0
83
+ self.number_of_reefs = 0
84
+ self.number_of_sites = 0
85
+ self.output_file = Path(config.output_file)
86
+
87
+ # Fixed parameter defaults from setup
88
+ self.adaptability = 1.0
89
+ self.adapt_decay_time = 10.0
90
+ self.adapt_penalty = 0.05
91
+ self.adapt_plasticity = 8.0
92
+ self.rubble_decay_time = 6.0
93
+ self.flood_scale = 50.0
94
+ self.k_sa = 0.0025
95
+ self.k_ta = 0.0020
96
+ self.k_mo = 0.0005
97
+ self.k_po = 0.0010
98
+ self.k_fa = 0.0020
99
+ self.k_tt = 0.0010
100
+ self.pH_scale = 100.0
101
+ self.dhw_scale = 30.0
102
+
103
+ self.B_max = 10000.0
104
+ self.B_recruit = 0.5
105
+ self.B_pred_S1 = 500.0
106
+ self.T_max = 200.0
107
+ self.T_recruit = 0.8
108
+ self.T_pred_B = 120.0
109
+ self.E_recruit = 0.0028
110
+ self.E_mort = 0.026
111
+ self.E_natal = 0.1
112
+ self.E_pred_S1 = 500.0
113
+ self.E_pred_S = 50.0
114
+ self.G_recruit = 4.0
115
+ self.G_mort = 0.01
116
+ self.G_pred_T = 70.0
117
+ self.reporting_ratio = 0.5
118
+
119
+ self.rate_i = {
120
+ "sa": 0.50,
121
+ "ta": 0.40,
122
+ "mo": 0.30,
123
+ "po": 0.15,
124
+ "fa": 0.10,
125
+ "tt": 0.40,
126
+ }
127
+ self.thermal_i = {
128
+ "sa": 1.5,
129
+ "ta": 2.0,
130
+ "mo": 3.0,
131
+ "po": 3.5,
132
+ "fa": 3.5,
133
+ "tt": 7.5,
134
+ }
135
+
136
+ self.C_recruit = 0.05
137
+ self.S_spawning_threshold = 3.0
138
+ self.S_spawning_failure = 0.7
139
+ self.S_phase = 1.0
140
+ self.S_recruit = 300000.0
141
+ self.S_pred_C = 0.0003
142
+ self.S_prefer = 0.55
143
+ self.S_mort = 0.8
144
+ self.S1_mort = self.S_mort
145
+ self.S2_mort = 0.0
146
+ self.S3_mort = 0.0
147
+ self.S4_mort = 0.0
148
+ self.S5_mort = 0.0
149
+ self.S6_mort = self.S_mort
150
+
151
+ self.monitor_total_outbreaks = 0
152
+ self.monitor_active_outbreaks = 0
153
+
154
+ # Loaded data placeholders
155
+ self.reef_df: pd.DataFrame | None = None
156
+ self.reef_numeric: np.ndarray | None = None
157
+ self.coast_x: np.ndarray | None = None
158
+ self.coast_y: np.ndarray | None = None
159
+
160
+ self.reef_who: np.ndarray | None = None
161
+ self.who_to_reef: dict[int, int] = {}
162
+ self.site_who: np.ndarray | None = None
163
+ self.site_reef: np.ndarray | None = None
164
+ self.site_offsets: np.ndarray | None = None
165
+
166
+ self.dist_matrix: np.ndarray | None = None
167
+ self.heading_matrix: np.ndarray | None = None
168
+
169
+ # Reef properties / states
170
+ self.reef_id: np.ndarray | None = None
171
+ self.region_name: np.ndarray | None = None
172
+ self.shelf_position: np.ndarray | None = None
173
+ self.sector_number: np.ndarray | None = None
174
+ self.rezone_year: np.ndarray | None = None
175
+ self.future_rezone_year: np.ndarray | None = None
176
+ self.priority_category: np.ndarray | None = None
177
+ self.priority: np.ndarray | None = None
178
+ self.reef_sites: np.ndarray | None = None
179
+ self.benefit: np.ndarray | None = None
180
+ self.x: np.ndarray | None = None
181
+ self.y: np.ndarray | None = None
182
+ self.xcor: np.ndarray | None = None
183
+ self.ycor: np.ndarray | None = None
184
+ self.km_offshore: np.ndarray | None = None
185
+
186
+ self.E = {k: None for k in ("0", "1", "2", "3", "4", "5")}
187
+ self.E_i = {k: None for k in ("0", "1", "2", "3", "4", "5")}
188
+ self.G = {k: None for k in ("0", "1", "2", "3", "4", "5")}
189
+ self.G_i = {k: None for k in ("0", "1", "2", "3", "4", "5")}
190
+ self.E_catch_kg: np.ndarray | None = None
191
+ self.G_catch_kg: np.ndarray | None = None
192
+
193
+ self.B_r: np.ndarray | None = None
194
+ self.T_r: np.ndarray | None = None
195
+ self.S_r = {k: None for k in ("1", "2", "3", "4", "5", "6")}
196
+ self.S_manta_r: np.ndarray | None = None
197
+ self.C_r = {g: None for g in CORAL_GROUPS}
198
+ self.C_reef: np.ndarray | None = None
199
+ self.R_reef: np.ndarray | None = None
200
+ self.C_out_degree: np.ndarray | None = None
201
+ self.dhw: np.ndarray | None = None
202
+ self.reef_shading: np.ndarray | None = None
203
+ self.regional_shading: np.ndarray | None = None
204
+ self.pH_protect: np.ndarray | None = None
205
+ self.dives_reef: np.ndarray | None = None
206
+
207
+ self.bleach_mort_r = {g: None for g in CORAL_GROUPS}
208
+ self.cyclone_mort_r = {g: None for g in CORAL_GROUPS}
209
+ self.predate_mort_r = {g: None for g in CORAL_GROUPS}
210
+
211
+ # Site properties / states
212
+ self.B: np.ndarray | None = None
213
+ self.B_i: np.ndarray | None = None
214
+ self.T: np.ndarray | None = None
215
+ self.T_i: np.ndarray | None = None
216
+
217
+ self.S = {k: None for k in ("0", "1", "2", "3", "4", "5", "6")}
218
+ self.S_i = {k: None for k in ("0", "1", "2", "3", "4", "5", "6")}
219
+ self.S_manta: np.ndarray | None = None
220
+
221
+ self.C = {g: None for g in CORAL_GROUPS}
222
+ self.C_i = {g: None for g in CORAL_GROUPS}
223
+ self.C_site: np.ndarray | None = None
224
+ self.C_site_i: np.ndarray | None = None
225
+
226
+ self.rate = {g: None for g in CORAL_GROUPS}
227
+ self.thermal = {g: None for g in CORAL_GROUPS}
228
+ self.bleach_mort = {g: None for g in CORAL_GROUPS}
229
+ self.cyclone_mort = {g: None for g in CORAL_GROUPS}
230
+ self.predate_mort = {g: None for g in CORAL_GROUPS}
231
+ self.R_site: np.ndarray | None = None
232
+ self.R_site_i: np.ndarray | None = None
233
+
234
+ # ---- lifecycle ----
235
+ def setup(self, *, init_output: bool = True) -> None:
236
+ setup_start = time.perf_counter()
237
+ logger.info(
238
+ "Model setup started (reefs_file=%s coastline_file=%s output_file=%s init_output=%s)",
239
+ self.cfg.reefs_file,
240
+ self.cfg.coastline_file,
241
+ self.cfg.output_file,
242
+ init_output,
243
+ )
244
+ self.rng.seed(1)
245
+ self._load_coastline()
246
+ self._load_reefs()
247
+ self._allocate_states()
248
+ if init_output:
249
+ self._set_up_output_files()
250
+ self.ensemble = 0
251
+ logger.info(
252
+ "Model setup completed in %.2fs (reefs=%s sites=%s output_file=%s)",
253
+ time.perf_counter() - setup_start,
254
+ self.number_of_reefs,
255
+ self.number_of_sites,
256
+ self.output_file,
257
+ )
258
+
259
+ def run(self) -> None:
260
+ run_start = time.perf_counter()
261
+ workers = effective_ensemble_workers(self.cfg.ensemble_threads, self.cfg.ensemble_runs)
262
+ logger.info(
263
+ "Run started (ensemble_runs=%s ensemble_threads=%s effective_worker_cap=%s "
264
+ "start_year=%s spinup_backtrack_years=%s end_year=%s save_year=%s "
265
+ "projection_year=%s search_year=%s)",
266
+ self.cfg.ensemble_runs,
267
+ self.cfg.ensemble_threads,
268
+ workers,
269
+ self.cfg.start_year,
270
+ self.cfg.spinup_backtrack_years,
271
+ self.cfg.end_year,
272
+ self.cfg.save_year,
273
+ self.cfg.projection_year,
274
+ self.cfg.search_year,
275
+ )
276
+ self.setup(init_output=True)
277
+ if use_parallel_ensemble_run(self.cfg.ensemble_threads, self.cfg.ensemble_runs):
278
+ logger.info(
279
+ "Parallel simulation phase: process_pool_size=%s "
280
+ "(spawn workers after ensemble-0 spinup on main process; "
281
+ "BLAS limited to 1 thread per worker via threadpoolctl).",
282
+ workers,
283
+ )
284
+ self._run_with_parallel_simulation_ensembles(run_start, workers)
285
+ else:
286
+ if self.cfg.ensemble_runs > 1 and workers == 1:
287
+ logger.info(
288
+ "Simulation ensembles run serially on the main thread "
289
+ "(ensemble_threads=%s; use --ensemble-threads > 1 or 0/auto to run multiple worker processes).",
290
+ self.cfg.ensemble_threads,
291
+ )
292
+ self._run_sequential_ensemble_loop(run_start)
293
+
294
+ def _run_sequential_ensemble_loop(self, run_start: float) -> None:
295
+ while self.ensemble <= self.cfg.ensemble_runs:
296
+ ensemble_start = time.perf_counter()
297
+ ensemble_kind = "spinup" if self.ensemble == 0 else "simulation"
298
+ logger.info(
299
+ "Ensemble %s/%s started (%s).",
300
+ self.ensemble,
301
+ self.cfg.ensemble_runs,
302
+ ensemble_kind,
303
+ )
304
+ self.initialise_run()
305
+ self._run_ensemble_year_steps()
306
+ self._maybe_write_priority_benefit()
307
+ logger.info(
308
+ "Ensemble %s/%s completed in %.2fs.",
309
+ self.ensemble,
310
+ self.cfg.ensemble_runs,
311
+ time.perf_counter() - ensemble_start,
312
+ )
313
+ self.ensemble += 1
314
+ logger.info(
315
+ "Run completed in %.2fs (output_file=%s final_ensemble=%s).",
316
+ time.perf_counter() - run_start,
317
+ self.output_file,
318
+ self.ensemble - 1,
319
+ )
320
+
321
+ def _run_with_parallel_simulation_ensembles(self, run_start: float, max_workers: int) -> None:
322
+ self.ensemble = 0
323
+ ensemble_start = time.perf_counter()
324
+ logger.info(
325
+ "Ensemble %s/%s started (spinup).",
326
+ self.ensemble,
327
+ self.cfg.ensemble_runs,
328
+ )
329
+ self.initialise_run()
330
+ self._run_ensemble_year_steps()
331
+ self._maybe_write_priority_benefit()
332
+ logger.info(
333
+ "Ensemble %s/%s completed in %.2fs.",
334
+ self.ensemble,
335
+ self.cfg.ensemble_runs,
336
+ time.perf_counter() - ensemble_start,
337
+ )
338
+
339
+ checkpoint = self.export_spinup_checkpoint()
340
+ tmpdir = Path(tempfile.mkdtemp(prefix="coconet-ensemble-"))
341
+ try:
342
+ futures: dict[int, Future[None]] = {}
343
+ # spawn: fresh interpreters so we do not fork a huge post-spinup parent;
344
+ # threads were wrong here — Python bytecode in the year loop holds the GIL.
345
+ ctx = mp.get_context("spawn")
346
+ with ProcessPoolExecutor(max_workers=max_workers, mp_context=ctx) as executor:
347
+ for e in range(1, self.cfg.ensemble_runs + 1):
348
+ out_part = tmpdir / f"output_{e}.csv"
349
+ pri_part = tmpdir / f"priority_{e}.csv" if self.search_mode == 1 else None
350
+ futures[e] = executor.submit(
351
+ _run_simulation_ensemble_worker,
352
+ replace(self.cfg),
353
+ checkpoint,
354
+ e,
355
+ out_part,
356
+ pri_part,
357
+ )
358
+ for e in range(1, self.cfg.ensemble_runs + 1):
359
+ futures[e].result()
360
+
361
+ with self.output_file.open("ab") as out_f:
362
+ for e in range(1, self.cfg.ensemble_runs + 1):
363
+ part = tmpdir / f"output_{e}.csv"
364
+ if part.is_file():
365
+ with part.open("rb") as in_f:
366
+ shutil.copyfileobj(in_f, out_f)
367
+
368
+ if self.search_mode == 1:
369
+ pri_main = Path("priority_reef_benefit.csv")
370
+ with pri_main.open("ab") as out_f:
371
+ for e in range(1, self.cfg.ensemble_runs + 1):
372
+ part = tmpdir / f"priority_{e}.csv"
373
+ if part.is_file():
374
+ with part.open("rb") as in_f:
375
+ shutil.copyfileobj(in_f, out_f)
376
+ finally:
377
+ shutil.rmtree(tmpdir, ignore_errors=True)
378
+
379
+ self.ensemble = self.cfg.ensemble_runs + 1
380
+ logger.info(
381
+ "Run completed in %.2fs (output_file=%s final_ensemble=%s).",
382
+ time.perf_counter() - run_start,
383
+ self.output_file,
384
+ self.ensemble - 1,
385
+ )
386
+
387
+ def export_spinup_checkpoint(self) -> SpinupCheckpoint:
388
+ return SpinupCheckpoint(
389
+ E_i={k: np.copy(v) for k, v in self.E_i.items()},
390
+ G_i={k: np.copy(v) for k, v in self.G_i.items()},
391
+ S_i={k: np.copy(v) for k, v in self.S_i.items()},
392
+ B_i=np.copy(self.B_i),
393
+ T_i=np.copy(self.T_i),
394
+ C_i={k: np.copy(v) for k, v in self.C_i.items()},
395
+ C_site_i=np.copy(self.C_site_i),
396
+ R_site_i=np.copy(self.R_site_i),
397
+ )
398
+
399
+ def import_spinup_checkpoint(self, checkpoint: SpinupCheckpoint) -> None:
400
+ for k, v in checkpoint.E_i.items():
401
+ self.E_i[k][:] = v
402
+ for k, v in checkpoint.G_i.items():
403
+ self.G_i[k][:] = v
404
+ for k, v in checkpoint.S_i.items():
405
+ self.S_i[k][:] = v
406
+ self.B_i[:] = checkpoint.B_i
407
+ self.T_i[:] = checkpoint.T_i
408
+ for k, v in checkpoint.C_i.items():
409
+ self.C_i[k][:] = v
410
+ self.C_site_i[:] = checkpoint.C_site_i
411
+ self.R_site_i[:] = checkpoint.R_site_i
412
+
413
+ def _maybe_write_priority_benefit(self, path: Path | None = None) -> None:
414
+ if self.search_mode == 1 and self.year >= self.cfg.search_year:
415
+ logger.debug(
416
+ "Writing priority benefit output (ensemble=%s year=%s).",
417
+ self.ensemble,
418
+ self.year,
419
+ )
420
+ self._write_priority_benefit(path)
421
+
422
+ def _run_ensemble_year_steps(self) -> None:
423
+ while self.year <= self.cfg.end_year:
424
+ logger.info(
425
+ "Progress: ensemble=%s year=%s",
426
+ self.ensemble,
427
+ self.year,
428
+ )
429
+ self._seed_year()
430
+ self.draw_year = 15 + self.rng.random_int(7)
431
+ if self.year in (2015, 2016, 2017, 2018, 2019, 2020, 2021):
432
+ self.draw_year = self.year - 2000
433
+ self.draw_month = 10 + self.rng.random_int(3)
434
+ self.draw_fortnight = 1 + self.rng.random_int(4)
435
+
436
+ self.cyclone()
437
+ self._seed_year()
438
+ if self.year < self.cfg.projection_year:
439
+ self.bleaching()
440
+ else:
441
+ if self.year == self.cfg.projection_year:
442
+ logger.debug(
443
+ "Entering projection bleaching mode at year=%s.",
444
+ self.year,
445
+ )
446
+ prob = self._projection_bleach_probability()
447
+ reduction = 0.2 * (self.cyclone_category - 0.5) / 4.5
448
+ if (prob - reduction) > self.rng.random_float(1.0):
449
+ self.bleaching()
450
+
451
+ for reef_idx in range(self.number_of_reefs):
452
+ self.grow_corals(reef_idx)
453
+ self.grow_cots(reef_idx)
454
+ self.grow_fish(reef_idx)
455
+ self.consume_corals(reef_idx)
456
+ self.consume_cots(reef_idx)
457
+ self.dives_reef[reef_idx] = 0
458
+
459
+ self._apply_interventions()
460
+
461
+ for reef_idx in range(self.number_of_reefs):
462
+ self.spawn_corals(reef_idx)
463
+
464
+ if 0.5 * (
465
+ 1
466
+ + math.sin(
467
+ 2 * 3.1416 * (2010 + self.S_phase + self.rng.random_float(4) - self.year) / 16
468
+ )
469
+ ) > self.rng.random_float(2 * self.S_spawning_failure):
470
+ for reef_idx in range(self.number_of_reefs):
471
+ self.spawn_cots(reef_idx)
472
+
473
+ for reef_idx in range(self.number_of_reefs):
474
+ self.spawn_fish(reef_idx)
475
+
476
+ self.reef_populations()
477
+ if self.ensemble == 0 and self.year == self.cfg.start_year:
478
+ logger.info(
479
+ "Captured start conditions at year=%s during spinup.",
480
+ self.year,
481
+ )
482
+ self.save_start_conditions()
483
+ self.year = self.cfg.end_year - 1
484
+
485
+ if self.ensemble > 0 and self.year >= self.cfg.save_year and self.search_mode == 0:
486
+ logger.debug(
487
+ "Writing annual output (ensemble=%s year=%s).",
488
+ self.ensemble,
489
+ self.year,
490
+ )
491
+ self.write_output()
492
+
493
+ self.year += 1
494
+
495
+ # ---- setup helpers ----
496
+ def _load_coastline(self) -> None:
497
+ path = Path(self.cfg.coastline_file)
498
+ df = pd.read_csv(path)
499
+ self.coast_x = 67.0 * (pd.to_numeric(df.iloc[:, 0], errors="coerce").to_numpy() - 147.6)
500
+ self.coast_y = 67.0 * (pd.to_numeric(df.iloc[:, 1], errors="coerce").to_numpy() + 18.0)
501
+
502
+ def _load_reefs(self) -> None:
503
+ path = Path(self.cfg.reefs_file)
504
+ self.reef_df = pd.read_csv(path, low_memory=False)
505
+ self.reef_numeric = self.reef_df.apply(pd.to_numeric, errors="coerce").to_numpy(
506
+ dtype=np.float64
507
+ )
508
+
509
+ self.reef_id = self.reef_df.iloc[:, 1].astype(str).to_numpy()
510
+ self.y = self.reef_numeric[:, 2]
511
+ self.x = self.reef_numeric[:, 3]
512
+ self.region_name = self.reef_df.iloc[:, 6].astype(str).to_numpy()
513
+ self.shelf_position = self.reef_df.iloc[:, 8].astype(str).to_numpy()
514
+ self.sector_number = self.reef_numeric[:, 9].astype(np.int32)
515
+ self.rezone_year = self.reef_numeric[:, 10].astype(np.int32)
516
+ self.priority_category = self.reef_df.iloc[:, 11].astype(str).to_numpy()
517
+ self.reef_sites = self.reef_numeric[:, 18].astype(np.int32)
518
+ self.number_of_reefs = self.reef_df.shape[0]
519
+ self.number_of_sites = int(self.reef_sites.sum())
520
+ self.xcor = 67.0 * (self.x - 147.6)
521
+ self.ycor = 67.0 * (self.y + 18.0)
522
+
523
+ # Reproduce NetLogo who numbering with coastline created first.
524
+ coast_count = len(self.coast_x) + 1
525
+ self.reef_who = np.zeros(self.number_of_reefs, dtype=np.int32)
526
+ self.site_reef = np.zeros(self.number_of_sites, dtype=np.int32)
527
+ self.site_who = np.zeros(self.number_of_sites, dtype=np.int32)
528
+ self.site_offsets = np.zeros(self.number_of_reefs + 1, dtype=np.int32)
529
+
530
+ who = coast_count
531
+ s = 0
532
+ for i in range(self.number_of_reefs):
533
+ self.reef_who[i] = who
534
+ self.who_to_reef[int(who)] = i
535
+ who += 1
536
+ n = int(self.reef_sites[i])
537
+ self.site_offsets[i] = s
538
+ self.site_reef[s : s + n] = i
539
+ self.site_who[s : s + n] = np.arange(who, who + n, dtype=np.int32)
540
+ s += n
541
+ who += n
542
+ self.site_offsets[self.number_of_reefs] = s
543
+
544
+ # km offshore from nearest coast.
545
+ dx = self.xcor[:, None] - self.coast_x[None, :]
546
+ dy = self.ycor[:, None] - self.coast_y[None, :]
547
+ self.km_offshore = np.sqrt(dx * dx + dy * dy).min(axis=1) / self.per_km
548
+
549
+ # Pairwise distance/heading among reefs for dispersal.
550
+ rdx = self.xcor[None, :] - self.xcor[:, None]
551
+ rdy = self.ycor[None, :] - self.ycor[:, None]
552
+ self.dist_matrix = np.sqrt(rdx * rdx + rdy * rdy)
553
+ self.heading_matrix = heading_from_dx_dy(rdx, rdy)
554
+
555
+ def _allocate_states(self) -> None:
556
+ n_reefs = self.number_of_reefs
557
+ n_sites = self.number_of_sites
558
+
559
+ self.future_rezone_year = np.full(n_reefs, 9999, dtype=np.int32)
560
+ self.priority = np.zeros(n_reefs, dtype=np.int32)
561
+ self.benefit = np.zeros(n_reefs, dtype=np.float64)
562
+ self.E_catch_kg = np.zeros(n_reefs, dtype=np.float64)
563
+ self.G_catch_kg = np.zeros(n_reefs, dtype=np.float64)
564
+ self.reef_shading = np.zeros(n_reefs, dtype=np.float64)
565
+ self.regional_shading = np.zeros(n_reefs, dtype=np.float64)
566
+ self.pH_protect = np.zeros(n_reefs, dtype=np.float64)
567
+ self.dives_reef = np.zeros(n_reefs, dtype=np.float64)
568
+ self.dhw = np.zeros(n_reefs, dtype=np.float64)
569
+ self.C_out_degree = np.zeros(n_reefs, dtype=np.float64)
570
+
571
+ self.B_r = np.zeros(n_reefs, dtype=np.float64)
572
+ self.T_r = np.zeros(n_reefs, dtype=np.float64)
573
+ self.S_manta_r = np.zeros(n_reefs, dtype=np.float64)
574
+ self.C_reef = np.zeros(n_reefs, dtype=np.float64)
575
+ self.R_reef = np.zeros(n_reefs, dtype=np.float64)
576
+
577
+ for age in ("0", "1", "2", "3", "4", "5"):
578
+ self.E[age] = np.zeros(n_reefs, dtype=np.float64)
579
+ self.E_i[age] = np.zeros(n_reefs, dtype=np.float64)
580
+ self.G[age] = np.zeros(n_reefs, dtype=np.float64)
581
+ self.G_i[age] = np.zeros(n_reefs, dtype=np.float64)
582
+
583
+ for age in ("1", "2", "3", "4", "5", "6"):
584
+ self.S_r[age] = np.zeros(n_reefs, dtype=np.float64)
585
+
586
+ for g in CORAL_GROUPS:
587
+ self.C_r[g] = np.zeros(n_reefs, dtype=np.float64)
588
+ self.bleach_mort_r[g] = np.zeros(n_reefs, dtype=np.float64)
589
+ self.cyclone_mort_r[g] = np.zeros(n_reefs, dtype=np.float64)
590
+ self.predate_mort_r[g] = np.zeros(n_reefs, dtype=np.float64)
591
+
592
+ self.B = np.zeros(n_sites, dtype=np.float64)
593
+ self.B_i = np.zeros(n_sites, dtype=np.float64)
594
+ self.T = np.zeros(n_sites, dtype=np.float64)
595
+ self.T_i = np.zeros(n_sites, dtype=np.float64)
596
+ self.S_manta = np.zeros(n_sites, dtype=np.float64)
597
+
598
+ for age in ("0", "1", "2", "3", "4", "5", "6"):
599
+ self.S[age] = np.zeros(n_sites, dtype=np.float64)
600
+ self.S_i[age] = np.zeros(n_sites, dtype=np.float64)
601
+
602
+ for g in CORAL_GROUPS:
603
+ self.C[g] = np.zeros(n_sites, dtype=np.float64)
604
+ self.C_i[g] = np.zeros(n_sites, dtype=np.float64)
605
+ self.rate[g] = np.zeros(n_sites, dtype=np.float64)
606
+ self.thermal[g] = np.zeros(n_sites, dtype=np.float64)
607
+ self.bleach_mort[g] = np.zeros(n_sites, dtype=np.float64)
608
+ self.cyclone_mort[g] = np.zeros(n_sites, dtype=np.float64)
609
+ self.predate_mort[g] = np.zeros(n_sites, dtype=np.float64)
610
+
611
+ self.C_site = np.zeros(n_sites, dtype=np.float64)
612
+ self.C_site_i = np.zeros(n_sites, dtype=np.float64)
613
+ self.R_site = np.zeros(n_sites, dtype=np.float64)
614
+ self.R_site_i = np.zeros(n_sites, dtype=np.float64)
615
+
616
+ # ---- utility helpers ----
617
+ def _seed_year(self) -> None:
618
+ self.rng.seed((self.ensemble + 1) * self.year)
619
+
620
+ def _sites_for_reef(self, reef_idx: int) -> slice:
621
+ s0 = int(self.site_offsets[reef_idx])
622
+ s1 = int(self.site_offsets[reef_idx + 1])
623
+ return slice(s0, s1)
624
+
625
+ def _one_site_for_reef(self, reef_idx: int) -> int | None:
626
+ sl = self._sites_for_reef(reef_idx)
627
+ if sl.start == sl.stop:
628
+ return None
629
+ return self.rng.random_int(sl.stop - sl.start) + sl.start
630
+
631
+ def _one_global_site(self) -> int | None:
632
+ if self.number_of_sites == 0:
633
+ return None
634
+ return self.rng.random_int(self.number_of_sites)
635
+
636
+ def _kernel_base_coral(self, draw_year: int, draw_month: int) -> int:
637
+ return 20 + (((draw_year - 15) * 3 + (draw_month - 10)) * 9)
638
+
639
+ def _kernel_base_cots(self, draw_year: int, draw_fortnight: int) -> int:
640
+ return 209 + (((draw_year - 15) * 4 + (draw_fortnight - 1)) * 9)
641
+
642
+ def _kernel_base_grouper(self, draw_year: int, draw_fortnight: int) -> int:
643
+ return 461 + (((draw_year - 15) * 4 + (draw_fortnight - 1)) * 9)
644
+
645
+ def _reef_kernel(self, reef_idx: int, base_col: int) -> ReefKernel:
646
+ row = self.reef_numeric[reef_idx]
647
+ return ReefKernel(
648
+ con1=float(row[base_col]),
649
+ dir1=float(row[base_col + 1]),
650
+ ang1=float(row[base_col + 2]),
651
+ dis1=float(row[base_col + 3]),
652
+ con2=float(row[base_col + 4]),
653
+ dir2=float(row[base_col + 5]),
654
+ ang2=float(row[base_col + 6]),
655
+ dis2=float(row[base_col + 7]),
656
+ )
657
+
658
+ def _targets_in_cone(
659
+ self, source: int, distance: float, direction: float, angle: float
660
+ ) -> tuple[np.ndarray, np.ndarray]:
661
+ if distance <= 0:
662
+ return np.array([], dtype=np.int32), np.array([], dtype=np.float64)
663
+ d = self.dist_matrix[source]
664
+ h = self.heading_matrix[source]
665
+ delta = np.abs((h - direction + 180.0) % 360.0 - 180.0)
666
+ mask = (d <= distance) & (delta <= angle / 2.0)
667
+ mask[source] = False
668
+ targets = np.flatnonzero(mask)
669
+ return targets.astype(np.int32), d[targets]
670
+
671
+ def _targets_in_radius(self, source: int, distance: float) -> np.ndarray:
672
+ d = self.dist_matrix[source]
673
+ mask = d <= distance
674
+ mask[source] = False
675
+ return np.flatnonzero(mask).astype(np.int32)
676
+
677
+ # ---- core procedures ----
678
+ def initialise_run(self) -> None:
679
+ self._seed_year()
680
+ self.catchment_condition = 0.0
681
+
682
+ for reef_idx in range(self.number_of_reefs):
683
+ self.future_rezone_year[reef_idx] = 9999
684
+ temp_y = max(0.0, 1.1 - (((self.y[reef_idx] + 15.0) / 15.0) ** 2))
685
+ pH_x = 1.0 - 0.20 * math.exp(-1.0 * self.km_offshore[reef_idx] / self.pH_scale)
686
+ self.pH_protect[reef_idx] = 0.0
687
+ self.reef_shading[reef_idx] = 0.0
688
+ self.regional_shading[reef_idx] = 0.0
689
+
690
+ if self.search_mode == 1:
691
+ self.cfg.consolidation_reefs = min(self.cfg.consolidation_reefs, 1)
692
+ self.cfg.shading_reefs = min(self.cfg.shading_reefs, 1)
693
+ self.cfg.seed_reefs = min(self.cfg.seed_reefs, 1)
694
+ self.cfg.slick_reefs = min(self.cfg.slick_reefs, 1)
695
+ self.cfg.pH_reefs = min(self.cfg.pH_reefs, 1)
696
+
697
+ sl = self._sites_for_reef(reef_idx)
698
+ self.rate["sa"][sl] = self.rate_i["sa"] * temp_y * pH_x * 1.0
699
+ self.rate["ta"][sl] = (
700
+ self.rate_i["ta"]
701
+ * temp_y
702
+ * pH_x
703
+ * (1.0 * ((self.rate_i["sa"] / self.rate_i["ta"]) ** 0.073))
704
+ )
705
+ self.rate["mo"][sl] = (
706
+ self.rate_i["mo"]
707
+ * temp_y
708
+ * pH_x
709
+ * (1.0 * ((self.rate_i["sa"] / self.rate_i["mo"]) ** 0.073))
710
+ )
711
+ self.rate["po"][sl] = (
712
+ self.rate_i["po"]
713
+ * temp_y
714
+ * pH_x
715
+ * (1.0 * ((self.rate_i["sa"] / self.rate_i["po"]) ** 0.073))
716
+ )
717
+ self.rate["fa"][sl] = (
718
+ self.rate_i["fa"]
719
+ * temp_y
720
+ * pH_x
721
+ * (1.0 * ((self.rate_i["sa"] / self.rate_i["fa"]) ** 0.073))
722
+ )
723
+ self.rate["tt"][sl] = (
724
+ self.rate_i["tt"]
725
+ * temp_y
726
+ * pH_x
727
+ * (1.0 * ((self.rate_i["sa"] / self.rate_i["tt"]) ** 0.073))
728
+ )
729
+
730
+ for g in CORAL_GROUPS:
731
+ self.thermal[g][sl] = self.thermal_i[g]
732
+
733
+ if self.ensemble == 0:
734
+ back = max(0, self.cfg.spinup_backtrack_years)
735
+ self.year = max(1, self.cfg.start_year - back)
736
+ for reef_idx in range(self.number_of_reefs):
737
+ self.E["5"][reef_idx] = 0
738
+ self.E["4"][reef_idx] = 1
739
+ self.E["3"][reef_idx] = 2
740
+ self.E["2"][reef_idx] = 4
741
+ self.E["1"][reef_idx] = 8
742
+ self.E["0"][reef_idx] = 16
743
+
744
+ self.G["5"][reef_idx] = 1
745
+ self.G["4"][reef_idx] = 2
746
+ self.G["3"][reef_idx] = 4
747
+ self.G["2"][reef_idx] = 8
748
+ self.G["1"][reef_idx] = 16
749
+ self.G["0"][reef_idx] = 32
750
+
751
+ sl = self._sites_for_reef(reef_idx)
752
+ n = sl.stop - sl.start
753
+ self.B[sl] = self.rng._rs.randint(0, int(0.2 * self.B_max), size=n)
754
+ self.T[sl] = self.rng._rs.randint(0, int(0.2 * self.T_max), size=n)
755
+ self.S["6"][sl] = 0
756
+ self.S["5"][sl] = 0
757
+ self.S["4"][sl] = 1
758
+ self.S["3"][sl] = 5
759
+ self.S["2"][sl] = 20
760
+ self.S["1"][sl] = 100
761
+ self.S["0"][sl] = 500
762
+
763
+ self.C["sa"][sl] = 0.1
764
+ self.C["ta"][sl] = 0.1
765
+ self.C["mo"][sl] = 0.1
766
+ self.C["po"][sl] = 0.1
767
+ self.C["fa"][sl] = 0.1
768
+ self.C["tt"][sl] = 0.0
769
+ self.C_site[sl] = (
770
+ self.C["sa"][sl]
771
+ + self.C["ta"][sl]
772
+ + self.C["mo"][sl]
773
+ + self.C["po"][sl]
774
+ + self.C["fa"][sl]
775
+ + self.C["tt"][sl]
776
+ )
777
+ self.R_site[sl] = 0.2
778
+ else:
779
+ self.year = self.cfg.start_year
780
+ for age in ("0", "1", "2", "3", "4", "5"):
781
+ self.E[age][:] = self.E_i[age]
782
+ self.G[age][:] = self.G_i[age]
783
+ for age in ("0", "1", "2", "3", "4", "5", "6"):
784
+ self.S[age][:] = self.S_i[age]
785
+ self.B[:] = self.B_i
786
+ self.T[:] = self.T_i
787
+ for g in CORAL_GROUPS:
788
+ self.C[g][:] = self.C_i[g]
789
+ self.C_site[:] = self.C_site_i
790
+ self.R_site[:] = self.R_site_i
791
+
792
+ self.reef_populations()
793
+ self._assign_priority()
794
+ if self.cfg.unregulated_fishing:
795
+ self.rezone_year[:] = 9999
796
+ self.future_rezone_year[:] = 9999
797
+
798
+ def _assign_priority(self) -> None:
799
+ p = 1
800
+ for category in ("T", "P", "N"):
801
+ idxs = np.flatnonzero(self.priority_category == category)
802
+ for reef_idx in idxs:
803
+ self.priority[reef_idx] = p
804
+ if p < self.cfg.rezoned_reefs and self.rezone_year[reef_idx] == 9999:
805
+ self.future_rezone_year[reef_idx] = self.cfg.start_modified_zoning
806
+ p += 1
807
+
808
+ def save_start_conditions(self) -> None:
809
+ for age in ("0", "1", "2", "3", "4", "5"):
810
+ self.E_i[age][:] = self.E[age]
811
+ self.G_i[age][:] = self.G[age]
812
+ self.B_i[:] = self.B
813
+ self.T_i[:] = self.T
814
+ for age in ("0", "1", "2", "3", "4", "5", "6"):
815
+ self.S_i[age][:] = self.S[age]
816
+ for g in CORAL_GROUPS:
817
+ self.C_i[g][:] = self.C[g]
818
+ self.C_site_i[:] = self.C_site
819
+ self.R_site_i[:] = self.R_site
820
+
821
+ def grow_fish(self, reef_idx: int) -> None:
822
+ self._seed_year()
823
+ temp_depend = 1.0 + 0.01 * (self.y[reef_idx] + 25.0) ** 2
824
+ g_weighted = (
825
+ self.G["1"][reef_idx]
826
+ + 2 * self.G["2"][reef_idx]
827
+ + 3 * self.G["3"][reef_idx]
828
+ + 4 * self.G["4"][reef_idx]
829
+ + 5 * self.G["5"][reef_idx]
830
+ ) / 15.0
831
+
832
+ sl = self._sites_for_reef(reef_idx)
833
+ c_site = self.C_site[sl]
834
+ r_site = self.R_site[sl]
835
+ b = self.B[sl]
836
+ t = self.T[sl]
837
+
838
+ predation = (
839
+ self.T_pred_B
840
+ * t
841
+ * b
842
+ / (b + self.T_pred_B * t + self.small)
843
+ * np.exp(-1.0 * (c_site + r_site))
844
+ )
845
+ b = b * (1 + self.B_recruit) - predation - self.B_recruit * b * b / self.B_max
846
+ b = np.clip(b, 100.0, self.B_max * (c_site + r_site))
847
+ self.B[sl] = b
848
+
849
+ predation = (
850
+ self.G_pred_T
851
+ * g_weighted
852
+ * t
853
+ / (t + self.G_pred_T * g_weighted + self.small)
854
+ * np.exp(-1.0 * c_site)
855
+ )
856
+ t = t * (1 + self.T_recruit) - predation - self.T_recruit * t * t / self.T_max
857
+ t = np.clip(t, 1.0, self.T_max * c_site)
858
+ self.T[sl] = t
859
+
860
+ mortality = self.E_mort * temp_depend / (1.0 + self.C_reef[reef_idx])
861
+ self.E["5"][reef_idx] = nl_round(
862
+ (self.E["4"][reef_idx] + self.E["5"][reef_idx])
863
+ * max(0.0, (1 - 0.25 * mortality * (self.E["4"][reef_idx] + self.E["5"][reef_idx])))
864
+ )
865
+ self.E["4"][reef_idx] = nl_round(
866
+ self.E["3"][reef_idx] * max(0.0, (1 - 0.33 * mortality * self.E["3"][reef_idx]))
867
+ )
868
+ self.E["3"][reef_idx] = nl_round(
869
+ self.E["2"][reef_idx] * max(0.0, (1 - 0.5 * mortality * self.E["2"][reef_idx]))
870
+ )
871
+ self.E["2"][reef_idx] = nl_round(
872
+ self.E["1"][reef_idx] * max(0.0, (1 - 1.0 * mortality * self.E["1"][reef_idx]))
873
+ )
874
+ self.E["1"][reef_idx] = nl_round(self.E["0"][reef_idx] * self.C_reef[reef_idx] ** 0.2)
875
+
876
+ mortality = self.G_mort * temp_depend / (1.0 + self.C_reef[reef_idx])
877
+ self.G["5"][reef_idx] = nl_round(
878
+ (self.G["4"][reef_idx] + self.G["5"][reef_idx])
879
+ * max(0.0, (1 - 0.25 * mortality * (self.G["4"][reef_idx] + self.G["5"][reef_idx])))
880
+ )
881
+ self.G["4"][reef_idx] = nl_round(
882
+ self.G["3"][reef_idx] * max(0.0, (1 - 0.33 * mortality * self.G["3"][reef_idx]))
883
+ )
884
+ self.G["3"][reef_idx] = nl_round(
885
+ self.G["2"][reef_idx] * max(0.0, (1 - 0.5 * mortality * self.G["2"][reef_idx]))
886
+ )
887
+ self.G["2"][reef_idx] = nl_round(
888
+ self.G["1"][reef_idx] * max(0.0, (1 - 1.0 * mortality * self.G["1"][reef_idx]))
889
+ )
890
+ self.G["1"][reef_idx] = nl_round(self.G["0"][reef_idx] * self.C_reef[reef_idx] ** 0.2)
891
+
892
+ def apply_fishing(self) -> None:
893
+ self._seed_year()
894
+ self.E_catch_kg[:] = 0.0
895
+ self.G_catch_kg[:] = 0.0
896
+
897
+ kg_per_E_3, kg_per_E_4, kg_per_E_5 = 1.0, 1.5, 2.5
898
+ kg_per_G_3, kg_per_G_4, kg_per_G_5 = 1.0, 1.5, 3.0
899
+
900
+ reporting_rate = 0.8 + self.rng.random_float(0.2)
901
+ if self.year < 2004 or self.cfg.unregulated_fishing:
902
+ e_annual = max(
903
+ 600000.0,
904
+ 1500000.0 * (1 - math.exp(-0.01 * math.exp(0.08 * (self.year - 1940)))),
905
+ ) / (self.reporting_ratio * reporting_rate)
906
+ g_annual = max(
907
+ 1160000.0,
908
+ 2900000.0 * (1 - math.exp(-0.01 * math.exp(0.08 * (self.year - 1940)))),
909
+ ) / (self.reporting_ratio * reporting_rate)
910
+ else:
911
+ e_annual = 400000.0 / reporting_rate
912
+ g_annual = 900000.0 / reporting_rate
913
+ if self.year >= self.cfg.start_modified_fishing:
914
+ e_annual *= 1 - self.cfg.catch_reduction
915
+ g_annual *= 1 - self.cfg.catch_reduction
916
+
917
+ e_cumulative = 0.0
918
+ visits = 0
919
+ max_visits = 3000
920
+ while e_cumulative < e_annual and visits < max_visits:
921
+ eligible = np.flatnonzero(
922
+ (self.year < self.rezone_year) & (self.year < self.future_rezone_year)
923
+ )
924
+ if eligible.size == 0:
925
+ break
926
+ reef_idx = int(eligible[self.rng.random_int(eligible.size)])
927
+ yy = 0.12 * (27 + self.y[reef_idx])
928
+ prob_fish = 0.104 / yy * math.exp(-2 * (math.log(yy) ** 2))
929
+ if self.rng.random_float(1.0) < prob_fish:
930
+ visits += 1
931
+ effort = math.exp(-1 * self.rng.random_float(1.0))
932
+ e3 = effort * self.E["3"][reef_idx]
933
+ e4 = effort * self.E["4"][reef_idx]
934
+ e5 = effort * self.E["5"][reef_idx]
935
+ if self.year >= self.cfg.start_lower_sizelimit:
936
+ e3 = 0.0
937
+ if self.year >= self.cfg.start_upper_sizelimit:
938
+ e5 = 0.0
939
+ if (
940
+ self.year >= self.cfg.start_CoTSlimit
941
+ and (
942
+ 0.55 * self.S_r["2"][reef_idx]
943
+ + 0.70 * self.S_r["3"][reef_idx]
944
+ + 0.85 * self.S_r["4"][reef_idx]
945
+ + 0.95 * self.S_r["5"][reef_idx]
946
+ + 0.99 * self.S_r["6"][reef_idx]
947
+ )
948
+ > 68
949
+ ):
950
+ e3 = e4 = e5 = 0.0
951
+ self.E["3"][reef_idx] = max(0, nl_round(self.E["3"][reef_idx] - e3))
952
+ self.E["4"][reef_idx] = max(0, nl_round(self.E["4"][reef_idx] - e4))
953
+ self.E["5"][reef_idx] = max(0, nl_round(self.E["5"][reef_idx] - e5))
954
+ catch = (
955
+ (e3 * kg_per_E_3 + e4 * kg_per_E_4 + e5 * kg_per_E_5)
956
+ * self.reef_sites[reef_idx]
957
+ * self.ha_per_site
958
+ )
959
+ self.E_catch_kg[reef_idx] += catch
960
+ e_cumulative += catch
961
+
962
+ g_cumulative = 0.0
963
+ visits = 0
964
+ while g_cumulative < g_annual and visits < max_visits:
965
+ eligible = np.flatnonzero(
966
+ (self.year < self.rezone_year) & (self.year < self.future_rezone_year)
967
+ )
968
+ if eligible.size == 0:
969
+ break
970
+ reef_idx = int(eligible[self.rng.random_int(eligible.size)])
971
+ yy = 0.17 * (25 + self.y[reef_idx])
972
+ prob_fish = 0.18 / yy * math.exp(-2 * (math.log(yy) ** 2))
973
+ if self.rng.random_float(1.0) < prob_fish:
974
+ visits += 1
975
+ effort = math.exp(-1 * self.rng.random_float(1.0))
976
+ g3 = effort * self.G["3"][reef_idx]
977
+ g4 = effort * self.G["4"][reef_idx]
978
+ g5 = effort * self.G["5"][reef_idx]
979
+ if self.year >= self.cfg.start_lower_sizelimit:
980
+ g3 = 0.0
981
+ if self.year >= self.cfg.start_upper_sizelimit:
982
+ g5 = 0.0
983
+ if (
984
+ self.year >= self.cfg.start_CoTSlimit
985
+ and (
986
+ 0.55 * self.S_r["2"][reef_idx]
987
+ + 0.70 * self.S_r["3"][reef_idx]
988
+ + 0.85 * self.S_r["4"][reef_idx]
989
+ + 0.95 * self.S_r["5"][reef_idx]
990
+ + 0.99 * self.S_r["6"][reef_idx]
991
+ )
992
+ > 68
993
+ ):
994
+ g3 = g4 = g5 = 0.0
995
+ self.G["3"][reef_idx] = max(0, nl_round(self.G["3"][reef_idx] - g3))
996
+ self.G["4"][reef_idx] = max(0, nl_round(self.G["4"][reef_idx] - g4))
997
+ self.G["5"][reef_idx] = max(0, nl_round(self.G["5"][reef_idx] - g5))
998
+ catch = (
999
+ (g3 * kg_per_G_3 + g4 * kg_per_G_4 + g5 * kg_per_G_5)
1000
+ * self.reef_sites[reef_idx]
1001
+ * self.ha_per_site
1002
+ )
1003
+ self.G_catch_kg[reef_idx] += catch
1004
+ g_cumulative += catch
1005
+
1006
+ def grow_corals(self, reef_idx: int) -> None:
1007
+ self._seed_year()
1008
+ flood_denom = max(self.flood_scale * self.flood_load, self.small)
1009
+ flood_effect = 0.1 + 0.9 * math.exp(-1 * self.km_offshore[reef_idx] / flood_denom)
1010
+ pH_effect_t = 0.0
1011
+ if self.year >= self.cfg.projection_year:
1012
+ pH_effect_t = (1.0 - self.pH_protect[reef_idx]) * math.sqrt(self.cfg.SSP)
1013
+
1014
+ sl = self._sites_for_reef(reef_idx)
1015
+ who = self.site_who[sl]
1016
+ rubble_retention = (who % 11) / 20.0
1017
+
1018
+ mask = (self.C_site[sl] + self.R_site[sl]) < 0.8
1019
+ if np.any(mask):
1020
+ for g, k in (
1021
+ ("sa", self.k_sa),
1022
+ ("ta", self.k_ta),
1023
+ ("mo", self.k_mo),
1024
+ ("po", self.k_po),
1025
+ ("fa", self.k_fa),
1026
+ ("tt", self.k_tt),
1027
+ ):
1028
+ self.rate[g][sl][mask] = self.rate[g][sl][mask] ** (1 + pH_effect_t * k)
1029
+
1030
+ denom = 1 + np.sqrt(self.C_site[sl][mask] + self.R_site[sl][mask])
1031
+ self.C["fa"][sl][mask] *= 1 + self.rate["fa"][sl][mask] * (1 - flood_effect) / denom
1032
+ self.C["po"][sl][mask] *= 1 + self.rate["po"][sl][mask] * (1 - flood_effect) / denom
1033
+ self.C["mo"][sl][mask] *= 1 + self.rate["mo"][sl][mask] * (1 - flood_effect) / denom
1034
+ self.C["ta"][sl][mask] *= 1 + self.rate["ta"][sl][mask] * (1 - flood_effect) / denom
1035
+ self.C["tt"][sl][mask] *= 1 + self.rate["tt"][sl][mask] * (1 - flood_effect) / denom
1036
+ self.C["sa"][sl][mask] *= 1 + self.rate["sa"][sl][mask] * (1 - flood_effect) / denom
1037
+
1038
+ self.C_site[sl] = (
1039
+ self.C["sa"][sl]
1040
+ + self.C["ta"][sl]
1041
+ + self.C["mo"][sl]
1042
+ + self.C["po"][sl]
1043
+ + self.C["fa"][sl]
1044
+ + self.C["tt"][sl]
1045
+ )
1046
+
1047
+ self.R_site[sl] = np.minimum(
1048
+ self.R_site[sl] * (1 - 1 / self.rubble_decay_time),
1049
+ rubble_retention,
1050
+ )
1051
+
1052
+ over = (self.C_site[sl] + self.R_site[sl]) > 0.7
1053
+ if np.any(over):
1054
+ scale = self.C_site[sl][over] + self.R_site[sl][over] + 0.3
1055
+ for g in CORAL_GROUPS:
1056
+ self.C[g][sl][over] = self.C[g][sl][over] / scale
1057
+ self.R_site[sl][over] = self.R_site[sl][over] / scale
1058
+
1059
+ self.C_site[sl] = (
1060
+ self.C["sa"][sl]
1061
+ + self.C["ta"][sl]
1062
+ + self.C["mo"][sl]
1063
+ + self.C["po"][sl]
1064
+ + self.C["fa"][sl]
1065
+ + self.C["tt"][sl]
1066
+ )
1067
+
1068
+ over1 = (self.C_site[sl] + self.R_site[sl]) > 1.0
1069
+ if np.any(over1):
1070
+ self.C["fa"][sl][over1] = 0.1
1071
+ self.C["po"][sl][over1] = 0.1
1072
+ self.C["mo"][sl][over1] = 0.1
1073
+ self.C["ta"][sl][over1] = 0.1
1074
+ self.C["tt"][sl][over1] = 0.0
1075
+ self.C["sa"][sl][over1] = 0.1
1076
+ self.C_site[sl][over1] = 0.5
1077
+ self.R_site[sl][over1] = 0.1
1078
+
1079
+ def grow_cots(self, reef_idx: int) -> None:
1080
+ self._seed_year()
1081
+ sl = self._sites_for_reef(reef_idx)
1082
+ for s in range(sl.start, sl.stop):
1083
+ c_f = nl_median(
1084
+ 0, self.C["sa"][s] + self.C["ta"][s] + self.C["mo"][s] + self.C["tt"][s], 1
1085
+ )
1086
+ site_cap = math.sqrt(c_f)
1087
+ old = {a: self.S[a][s] for a in ("0", "1", "2", "3", "4", "5", "6")}
1088
+ self.S["6"][s] = nl_round(
1089
+ (old["5"] + old["6"]) * math.exp(-1 * self.S6_mort / (c_f + self.small))
1090
+ )
1091
+ self.S["5"][s] = nl_round(old["4"] * math.exp(-1 * self.S5_mort / (c_f + self.small)))
1092
+ self.S["4"][s] = nl_round(old["3"] * math.exp(-1 * self.S4_mort / (c_f + self.small)))
1093
+ self.S["3"][s] = nl_round(old["2"] * math.exp(-1 * self.S3_mort / (c_f + self.small)))
1094
+ self.S["2"][s] = nl_round(
1095
+ old["1"] * site_cap * math.exp(-1 * self.S2_mort / (c_f + self.small))
1096
+ )
1097
+ self.S["1"][s] = nl_round(
1098
+ (old["0"] + old["1"] * (1 - site_cap))
1099
+ * math.exp(-1 * self.S1_mort / (self.R_site[s] + self.small))
1100
+ )
1101
+ self.S["0"][s] = 0
1102
+ self.S_manta[s] = (
1103
+ 0.50 * self.S["2"][s]
1104
+ + 0.70 * self.S["3"][s]
1105
+ + 0.85 * self.S["4"][s]
1106
+ + 0.95 * self.S["5"][s]
1107
+ + 0.99 * self.S["6"][s]
1108
+ )
1109
+
1110
+ def consume_corals(self, reef_idx: int) -> None:
1111
+ self._seed_year()
1112
+ sl = self._sites_for_reef(reef_idx)
1113
+ for s in range(sl.start, sl.stop):
1114
+ pred = self.S_pred_C * (
1115
+ 0.1 * self.S["1"][s]
1116
+ + 1 * self.S["2"][s]
1117
+ + 2 * self.S["3"][s]
1118
+ + 3 * self.S["4"][s]
1119
+ + 4 * self.S["5"][s]
1120
+ + 5 * self.S["6"][s]
1121
+ )
1122
+ for g in ("sa", "tt", "ta", "mo", "po", "fa"):
1123
+ consume = min(self.S_prefer * pred, 0.9 * self.C[g][s])
1124
+ self.C[g][s] -= consume
1125
+ self.R_site[s] = nl_median(0.0, 1.0, self.R_site[s] + 2 * consume)
1126
+ pred -= consume
1127
+
1128
+ def consume_cots(self, reef_idx: int) -> None:
1129
+ self._seed_year()
1130
+ e_weighted = (
1131
+ self.E["1"][reef_idx]
1132
+ + 2 * self.E["2"][reef_idx]
1133
+ + 3 * self.E["3"][reef_idx]
1134
+ + 4 * self.E["4"][reef_idx]
1135
+ + 5 * self.E["5"][reef_idx]
1136
+ ) / 15.0
1137
+ sl = self._sites_for_reef(reef_idx)
1138
+ for s in range(sl.start, sl.stop):
1139
+ e_site = self.rng.random_int(2 * e_weighted)
1140
+ pred = (
1141
+ self.B_pred_S1
1142
+ * self.B[s]
1143
+ * self.S["1"][s]
1144
+ / (self.S["1"][s] + self.B_pred_S1 * self.B[s] + self.small)
1145
+ )
1146
+ self.S["1"][s] = nl_round(max(10.0, self.S["1"][s] - pred))
1147
+ pred = (
1148
+ self.E_pred_S1
1149
+ * e_site
1150
+ * self.S["1"][s]
1151
+ / (self.S["1"][s] + self.E_pred_S1 * e_site + self.small)
1152
+ * math.exp(-1 * self.R_site[s])
1153
+ )
1154
+ self.S["1"][s] = nl_ceiling(max(10.0, self.S["1"][s] - pred))
1155
+ for a in ("2", "3", "4", "5", "6"):
1156
+ pred = (
1157
+ self.E_pred_S
1158
+ * e_site
1159
+ * self.S[a][s]
1160
+ / (self.S[a][s] + self.E_pred_S * e_site + self.small)
1161
+ )
1162
+ self.S[a][s] = nl_ceiling(max(1.0, self.S[a][s] - pred))
1163
+
1164
+ def spawn_fish(self, reef_idx: int) -> None:
1165
+ self._seed_year()
1166
+ kernel = self._reef_kernel(
1167
+ reef_idx, self._kernel_base_grouper(self.draw_year, self.draw_fortnight)
1168
+ )
1169
+ g_natal = self.rng.random_float(math.exp(-50 / math.sqrt(self.reef_sites[reef_idx])))
1170
+ g_source = (
1171
+ self.G["2"][reef_idx]
1172
+ + 2 * self.G["3"][reef_idx]
1173
+ + 4 * self.G["4"][reef_idx]
1174
+ + 8 * self.G["5"][reef_idx]
1175
+ ) * (1 - 0.004 * (self.y[reef_idx] + 25) ** 2)
1176
+ self.G["0"][reef_idx] = self.rng.random_int(
1177
+ (kernel.con1 + kernel.con2) * self.G_recruit * g_source * g_natal
1178
+ )
1179
+
1180
+ self._spawn_grouper_kernel(
1181
+ reef_idx, g_source, g_natal, kernel.con1, kernel.dir1, kernel.ang1, kernel.dis1
1182
+ )
1183
+ self._spawn_grouper_kernel(
1184
+ reef_idx, g_source, g_natal, kernel.con2, kernel.dir2, kernel.ang2, kernel.dis2
1185
+ )
1186
+
1187
+ e_source = (self.E["4"][reef_idx] + 2 * self.E["5"][reef_idx]) * (
1188
+ 1 - 0.004 * (self.y[reef_idx] + 25) ** 2
1189
+ )
1190
+ self.E["0"][reef_idx] = self.rng.random_int(
1191
+ self.E_recruit * e_source * self.E_natal * self.reef_sites[reef_idx]
1192
+ )
1193
+ targets = self._targets_in_radius(reef_idx, 100 * self.per_km)
1194
+ for t in targets:
1195
+ self.E["0"][t] += self.rng.random_int(
1196
+ self.E_recruit * e_source * (1 - self.E_natal) * self.reef_sites[t]
1197
+ )
1198
+
1199
+ def _spawn_grouper_kernel(
1200
+ self,
1201
+ source: int,
1202
+ g_source: float,
1203
+ g_natal: float,
1204
+ con: float,
1205
+ direction: float,
1206
+ angle: float,
1207
+ distance: float,
1208
+ ) -> None:
1209
+ if distance <= 0 or con <= 0:
1210
+ return
1211
+ radius = distance * self.per_km
1212
+ targets, dists = self._targets_in_cone(source, radius, direction, angle)
1213
+ if targets.size == 0:
1214
+ return
1215
+ include = self.rng._rs.random_sample(size=targets.size) > (dists / radius)
1216
+ for t in targets[include]:
1217
+ self.G["0"][t] += self.rng.random_int(
1218
+ con * self.G_recruit * g_source * (1 - g_natal) * self.reef_sites[t]
1219
+ )
1220
+
1221
+ def spawn_corals(self, reef_idx: int) -> None:
1222
+ self._seed_year()
1223
+ src_site = self._one_site_for_reef(reef_idx)
1224
+ if src_site is None:
1225
+ return
1226
+
1227
+ hybrid = self.cfg.hybrid_fraction * self.C["sa"][src_site]
1228
+ c_sa_source = (self.C["sa"][src_site] - hybrid) + hybrid * (
1229
+ (hybrid * hybrid + 2 * (1 - self.cfg.dominance) * hybrid * self.C["tt"][src_site])
1230
+ / ((hybrid + self.C["tt"][src_site] + self.small) ** 2)
1231
+ )
1232
+ c_ta_source = self.C["ta"][src_site]
1233
+ c_mo_source = self.C["mo"][src_site]
1234
+ c_po_source = self.C["po"][src_site]
1235
+ c_fa_source = self.C["fa"][src_site]
1236
+ c_tt_source = self.C["tt"][src_site] * (
1237
+ self.C["tt"][src_site]
1238
+ * ((2 * self.cfg.dominance * hybrid) + self.C["tt"][src_site])
1239
+ / ((hybrid + self.C["tt"][src_site] + self.small) ** 2)
1240
+ )
1241
+
1242
+ thermal_source = {g: self.thermal[g][src_site] for g in CORAL_GROUPS}
1243
+ kernel = self._reef_kernel(
1244
+ reef_idx, self._kernel_base_coral(self.draw_year, self.draw_month)
1245
+ )
1246
+ recruits_total = 0.0
1247
+
1248
+ recruits_total += self._spawn_coral_kernel(
1249
+ reef_idx,
1250
+ kernel.con1,
1251
+ kernel.dir1,
1252
+ kernel.ang1,
1253
+ kernel.dis1,
1254
+ c_sa_source,
1255
+ c_ta_source,
1256
+ c_mo_source,
1257
+ c_po_source,
1258
+ c_fa_source,
1259
+ c_tt_source,
1260
+ thermal_source,
1261
+ )
1262
+ recruits_total += self._spawn_coral_kernel(
1263
+ reef_idx,
1264
+ kernel.con2,
1265
+ kernel.dir2,
1266
+ kernel.ang2,
1267
+ kernel.dis2,
1268
+ c_sa_source,
1269
+ c_ta_source,
1270
+ c_mo_source,
1271
+ c_po_source,
1272
+ c_fa_source,
1273
+ c_tt_source,
1274
+ thermal_source,
1275
+ )
1276
+
1277
+ sl = self._sites_for_reef(reef_idx)
1278
+ self.C_site[sl] = (
1279
+ self.C["sa"][sl]
1280
+ + self.C["ta"][sl]
1281
+ + self.C["mo"][sl]
1282
+ + self.C["po"][sl]
1283
+ + self.C["fa"][sl]
1284
+ + self.C["tt"][sl]
1285
+ )
1286
+ over = (self.C_site[sl] + self.R_site[sl]) > 0.7
1287
+ if np.any(over):
1288
+ cr = self.C_site[sl][over] + self.R_site[sl][over] + 0.3
1289
+ for g in CORAL_GROUPS:
1290
+ self.C[g][sl][over] = self.C[g][sl][over] / cr
1291
+ self.C_site[sl] = (
1292
+ self.C["sa"][sl]
1293
+ + self.C["ta"][sl]
1294
+ + self.C["mo"][sl]
1295
+ + self.C["po"][sl]
1296
+ + self.C["fa"][sl]
1297
+ + self.C["tt"][sl]
1298
+ )
1299
+ over1 = (self.C_site[sl] + self.R_site[sl]) > 1.0
1300
+ if np.any(over1):
1301
+ self.C["fa"][sl][over1] = 0.1
1302
+ self.C["po"][sl][over1] = 0.1
1303
+ self.C["mo"][sl][over1] = 0.1
1304
+ self.C["ta"][sl][over1] = 0.1
1305
+ self.C["tt"][sl][over1] = 0.0
1306
+ self.C["sa"][sl][over1] = 0.1
1307
+ self.R_site[sl][over1] = 0.1
1308
+ self.C_out_degree[reef_idx] = recruits_total
1309
+
1310
+ def _spawn_coral_kernel(
1311
+ self,
1312
+ source: int,
1313
+ con: float,
1314
+ direction: float,
1315
+ angle: float,
1316
+ distance: float,
1317
+ c_sa_source: float,
1318
+ c_ta_source: float,
1319
+ c_mo_source: float,
1320
+ c_po_source: float,
1321
+ c_fa_source: float,
1322
+ c_tt_source: float,
1323
+ thermal_source: dict[str, float],
1324
+ ) -> float:
1325
+ """Dispersal cone for one connectivity kernel; updates C and thermal per site.
1326
+
1327
+ Recruitment matches the former six-call ``_calculate_recruitment`` loop per
1328
+ site. **Faithful vs legacy Python port:**
1329
+
1330
+ - **RNG:** One ``RandomState.random_sample(6)`` per site replaces six
1331
+ ``NetLogoRng.random_float`` calls; MT19937 consumption order is unchanged
1332
+ (groups in ``CORAL_SPAWN_KERNEL_ORDER``).
1333
+ - **Recruits:** Still ``float(U * (c_source * limits)) * (rate / rate_i)``
1334
+ per group (same as ``random_float(c_source * limits) * (rate / rate_i)``).
1335
+ - **Thermal:** ``numpy.maximum(1.0, …)`` is element-wise equivalent to
1336
+ scalar ``max(1.0, …)``.
1337
+
1338
+ **No intentional behavioural change:** reads use pre-update cover/thermal;
1339
+ writes match the old loop order (sa, tt, ta, mo, po, fa).
1340
+ """
1341
+ if distance <= 0 or con <= 0:
1342
+ return 0.0
1343
+ radius = distance * self.per_km
1344
+ targets, dists = self._targets_in_cone(source, radius, direction, angle)
1345
+ if targets.size == 0:
1346
+ return 0.0
1347
+ include = self.rng._rs.random_sample(size=targets.size) > (dists / radius)
1348
+ # Batched recruitment (see module doc note on _spawn_coral_kernel).
1349
+ src_arr = np.array(
1350
+ (c_sa_source, c_tt_source, c_ta_source, c_mo_source, c_po_source, c_fa_source),
1351
+ dtype=np.float64,
1352
+ )
1353
+ ri = np.array([self.rate_i[g] for g in CORAL_SPAWN_KERNEL_ORDER], dtype=np.float64)
1354
+ ts_arr = np.array([thermal_source[g] for g in CORAL_SPAWN_KERNEL_ORDER], dtype=np.float64)
1355
+ recruits_total = 0.0
1356
+ u_buf = np.empty(6, dtype=np.float64)
1357
+ rates = np.empty(6, dtype=np.float64)
1358
+ c_ex = np.empty(6, dtype=np.float64)
1359
+ th_ex = np.empty(6, dtype=np.float64)
1360
+ for reef_idx in targets[include]:
1361
+ sl = self._sites_for_reef(int(reef_idx))
1362
+ for s in range(sl.start, sl.stop):
1363
+ limits = self.C_recruit * con * nl_median(0, 1 - self.C_site[s] - self.R_site[s], 1)
1364
+ for i, g in enumerate(CORAL_SPAWN_KERNEL_ORDER):
1365
+ rates[i] = self.rate[g][s]
1366
+ c_ex[i] = self.C[g][s]
1367
+ th_ex[i] = self.thermal[g][s]
1368
+ u_buf[:] = self.rng._rs.random_sample(6)
1369
+ # Per-group: float(U * (c_source * limits)) * (rate / rate_i) — matches
1370
+ # NetLogoRng.random_float(c_source * limits) * (rate / rate_i).
1371
+ for i in range(6):
1372
+ u_buf[i] = float(u_buf[i] * (src_arr[i] * limits)) * (rates[i] / ri[i])
1373
+ recruits = u_buf # reuse buffer as recruit vector
1374
+ denom = recruits + c_ex + self.small
1375
+ thermal_new = np.maximum(1.0, (recruits * ts_arr + c_ex * th_ex) / denom)
1376
+ for i, g in enumerate(CORAL_SPAWN_KERNEL_ORDER):
1377
+ self.C[g][s] += recruits[i]
1378
+ self.thermal[g][s] = thermal_new[i]
1379
+ recruits_total += recruits[i]
1380
+ return recruits_total
1381
+
1382
+ def spawn_cots(self, reef_idx: int) -> None:
1383
+ self._seed_year()
1384
+ kernel = self._reef_kernel(
1385
+ reef_idx, self._kernel_base_cots(self.draw_year, self.draw_fortnight)
1386
+ )
1387
+ s_natal = self.rng.random_float(math.exp(-50 / math.sqrt(self.reef_sites[reef_idx])))
1388
+
1389
+ sl = self._sites_for_reef(reef_idx)
1390
+ s_source = 0.0
1391
+ for s in range(sl.start, sl.stop):
1392
+ adults = (
1393
+ self.S["2"][s] + self.S["3"][s] + self.S["4"][s] + self.S["5"][s] + self.S["6"][s]
1394
+ )
1395
+ if adults > self.S_spawning_threshold:
1396
+ s_source += (
1397
+ self.S["2"][s]
1398
+ + 2 * self.S["3"][s]
1399
+ + 4 * self.S["4"][s]
1400
+ + 8 * self.S["5"][s]
1401
+ + 8 * self.S["6"][s]
1402
+ )
1403
+ for s in range(sl.start, sl.stop):
1404
+ self.S["0"][s] += self.rng.random_int(
1405
+ (kernel.con1 + kernel.con2) * self.S_recruit * s_source * s_natal * self.R_site[s]
1406
+ )
1407
+
1408
+ self._spawn_cots_kernel(
1409
+ reef_idx, s_source, s_natal, kernel.con1, kernel.dir1, kernel.ang1, kernel.dis1
1410
+ )
1411
+ self._spawn_cots_kernel(
1412
+ reef_idx, s_source, s_natal, kernel.con2, kernel.dir2, kernel.ang2, kernel.dis2
1413
+ )
1414
+
1415
+ def _spawn_cots_kernel(
1416
+ self,
1417
+ source: int,
1418
+ s_source: float,
1419
+ s_natal: float,
1420
+ con: float,
1421
+ direction: float,
1422
+ angle: float,
1423
+ distance: float,
1424
+ ) -> None:
1425
+ if distance <= 0 or con <= 0:
1426
+ return
1427
+ radius = distance * self.per_km
1428
+ targets, dists = self._targets_in_cone(source, radius, direction, angle)
1429
+ if targets.size == 0:
1430
+ return
1431
+ include = self.rng._rs.random_sample(size=targets.size) > (dists / radius)
1432
+ for reef_idx in targets[include]:
1433
+ sl = self._sites_for_reef(int(reef_idx))
1434
+ for s in range(sl.start, sl.stop):
1435
+ self.S["0"][s] = min(
1436
+ self.S["0"][s]
1437
+ + self.rng.random_int(
1438
+ con * self.S_recruit * s_source * (1 - s_natal) * self.R_site[s]
1439
+ ),
1440
+ 1000000,
1441
+ )
1442
+
1443
+ def release_fish(self) -> None:
1444
+ self._seed_year()
1445
+ p = 0
1446
+ treatments = 0
1447
+ while treatments < self.cfg.release_reefs and p <= self.number_of_reefs:
1448
+ p += 1
1449
+ reef_idx = self._reef_by_priority(p)
1450
+ if reef_idx is None:
1451
+ continue
1452
+ if not self._reef_in_intervention_bounds(reef_idx):
1453
+ continue
1454
+ adults = (
1455
+ self.E["2"][reef_idx]
1456
+ + self.E["3"][reef_idx]
1457
+ + self.E["4"][reef_idx]
1458
+ + self.E["5"][reef_idx]
1459
+ )
1460
+ if adults >= self.cfg.release_threshold:
1461
+ continue
1462
+ self.E["1"][reef_idx] = nl_round(
1463
+ self.E["1"][reef_idx]
1464
+ + self.cfg.release_number
1465
+ / max(self.cfg.release_reefs, 1)
1466
+ / (self.reef_sites[reef_idx] * self.ha_per_site)
1467
+ )
1468
+ treatments += 1
1469
+
1470
+ def control_cots(self) -> None:
1471
+ self._seed_year()
1472
+ dives_remaining = 0.9 * 20 * 36 * 8 * self.S_vessels
1473
+ p = 0
1474
+ while dives_remaining > 0 and p <= self.number_of_reefs:
1475
+ p += 1
1476
+ dives_remaining -= 8
1477
+ reef_idx = self._reef_by_priority(p)
1478
+ if reef_idx is None:
1479
+ continue
1480
+ if not (
1481
+ self.region_name[reef_idx] == self.control_region or self.control_region == "GBR"
1482
+ ):
1483
+ continue
1484
+ if not (
1485
+ self.S_manta_r[reef_idx] < self.cfg.CoTS_threshold
1486
+ or self.C_reef[reef_idx] > self.cfg.coral_threshold
1487
+ ):
1488
+ continue
1489
+ dives_total = 0.0
1490
+ sl = self._sites_for_reef(reef_idx)
1491
+ idxs = np.flatnonzero(self.S_manta[sl] > self.cfg.eco_threshold) + sl.start
1492
+ for s in idxs:
1493
+ dives_site = nl_round((167 / 40) * self.S_manta[s] ** 0.667)
1494
+ dives_remaining -= dives_site
1495
+ dives_total += dives_site
1496
+ diver_detect = 1.5 + self.rng.random_float(0.5)
1497
+ self.S["2"][s] = nl_round(0.50 * self.cfg.eco_threshold / diver_detect)
1498
+ self.S["3"][s] = nl_round(0.30 * self.cfg.eco_threshold / diver_detect)
1499
+ self.S["4"][s] = nl_round(0.15 * self.cfg.eco_threshold / diver_detect)
1500
+ self.S["5"][s] = nl_round(0.05 * self.cfg.eco_threshold / diver_detect)
1501
+ self.S["6"][s] = nl_round(0.01 * self.cfg.eco_threshold / diver_detect)
1502
+ self.S["1"][s] = nl_round(
1503
+ min(
1504
+ self.S["1"][s],
1505
+ 2.77
1506
+ * (
1507
+ self.S["2"][s]
1508
+ + self.S["3"][s]
1509
+ + self.S["4"][s]
1510
+ + self.S["5"][s]
1511
+ + self.S["6"][s]
1512
+ ),
1513
+ )
1514
+ )
1515
+ self.dives_reef[reef_idx] += dives_total
1516
+
1517
+ def control_cots_by_sector(self) -> None:
1518
+ self._seed_year()
1519
+ dives_remaining = 0.9 * 20 * 36 * 8 * self.S_vessels
1520
+ sector = 1
1521
+ s_max = -1.0
1522
+ for candidate in range(1, 12):
1523
+ idxs = np.flatnonzero(self.sector_number == candidate)
1524
+ if idxs.size == 0:
1525
+ continue
1526
+ value = float(np.mean(self.S_manta_r[idxs]))
1527
+ if value > s_max:
1528
+ s_max = value
1529
+ sector = candidate
1530
+
1531
+ p = 0
1532
+ while dives_remaining > 0 and p <= self.number_of_reefs:
1533
+ p += 1
1534
+ dives_remaining -= 8
1535
+ reef_idx = self._reef_by_priority(p)
1536
+ if reef_idx is None or self.sector_number[reef_idx] != sector:
1537
+ continue
1538
+ dives_total = 0.0
1539
+ sl = self._sites_for_reef(reef_idx)
1540
+ idxs = np.flatnonzero(self.S_manta[sl] > self.cfg.eco_threshold) + sl.start
1541
+ for s in idxs:
1542
+ dives_site = nl_round(0.7 * (167 / 40) * self.S_manta[s] ** 0.667)
1543
+ dives_remaining -= dives_site
1544
+ dives_total += dives_site
1545
+ diver_detect = 1.5 + self.rng.random_float(0.5)
1546
+ self.S["2"][s] = nl_round(0.50 * self.cfg.eco_threshold / diver_detect)
1547
+ self.S["3"][s] = nl_round(0.30 * self.cfg.eco_threshold / diver_detect)
1548
+ self.S["4"][s] = nl_round(0.15 * self.cfg.eco_threshold / diver_detect)
1549
+ self.S["5"][s] = nl_round(0.05 * self.cfg.eco_threshold / diver_detect)
1550
+ self.S["6"][s] = nl_round(0.01 * self.cfg.eco_threshold / diver_detect)
1551
+ self.S["1"][s] = nl_round(
1552
+ min(
1553
+ self.S["1"][s],
1554
+ 2.77
1555
+ * (
1556
+ self.S["2"][s]
1557
+ + self.S["3"][s]
1558
+ + self.S["4"][s]
1559
+ + self.S["5"][s]
1560
+ + self.S["6"][s]
1561
+ ),
1562
+ )
1563
+ )
1564
+ self.dives_reef[reef_idx] += dives_total
1565
+
1566
+ def consolidate_rubble(self) -> None:
1567
+ self._seed_year()
1568
+ p = 0
1569
+ treatments = 0
1570
+ threshold = self.cfg.consolidation_hectares / self.ha_per_site
1571
+ while treatments < self.cfg.consolidation_reefs and p <= self.number_of_reefs:
1572
+ p += 1
1573
+ reef_idx = self._reef_by_priority(p)
1574
+ if reef_idx is None or not self._reef_in_intervention_bounds(reef_idx):
1575
+ continue
1576
+ if self.R_reef[reef_idx] <= self.cfg.consolidation_threshold:
1577
+ continue
1578
+ sl = self._sites_for_reef(reef_idx)
1579
+ candidates = np.flatnonzero(self.R_site[sl] > threshold)
1580
+ if candidates.size == 0:
1581
+ continue
1582
+ chosen = int(candidates[self.rng.random_int(candidates.size)] + sl.start)
1583
+ self.R_site[chosen] -= threshold
1584
+ treatments += 1
1585
+
1586
+ def seed_tt_coral(self) -> None:
1587
+ self._seed_year()
1588
+ p = 0
1589
+ treatments = 0
1590
+ seed_fraction = self.cfg.seed_hectares / max(self.cfg.seed_reefs, 1) / self.ha_per_site
1591
+ while treatments < self.cfg.seed_reefs and p <= self.number_of_reefs:
1592
+ p += 1
1593
+ reef_idx = self._reef_by_priority(p)
1594
+ if reef_idx is None or not self._reef_in_intervention_bounds(reef_idx):
1595
+ continue
1596
+ if self.C_reef[reef_idx] >= self.cfg.seed_threshold:
1597
+ continue
1598
+ sl = self._sites_for_reef(reef_idx)
1599
+ candidates = np.flatnonzero(
1600
+ (self.C["tt"][sl] < seed_fraction) & (self.C_site[sl] < (1 - seed_fraction))
1601
+ )
1602
+ if candidates.size == 0:
1603
+ continue
1604
+ s = int(candidates[self.rng.random_int(candidates.size)] + sl.start)
1605
+ self.thermal["tt"][s] = (
1606
+ seed_fraction * self.thermal_i["tt"] + self.C["tt"][s] * self.thermal["tt"][s]
1607
+ ) / (seed_fraction + self.C["tt"][s] + self.small)
1608
+ self.C["tt"][s] += seed_fraction
1609
+ treatments += 1
1610
+
1611
+ def seed_coral_slick(self) -> None:
1612
+ self._seed_year()
1613
+ slick_fraction = self.cfg.slick_hectares / max(self.cfg.slick_reefs, 1) / self.ha_per_site
1614
+ source = self._one_global_site()
1615
+ if source is None:
1616
+ return
1617
+ denom = max(self.C_site[source], self.small)
1618
+ frac = {g: self.C[g][source] / denom for g in CORAL_GROUPS}
1619
+ thermal_slick = {g: self.thermal[g][source] for g in CORAL_GROUPS}
1620
+
1621
+ p = 0
1622
+ treatments = 0
1623
+ while treatments < self.cfg.slick_reefs and p <= self.number_of_reefs:
1624
+ p += 1
1625
+ reef_idx = self._reef_by_priority(p)
1626
+ if reef_idx is None or not self._reef_in_intervention_bounds(reef_idx):
1627
+ continue
1628
+ if self.C_r["tt"][reef_idx] >= self.cfg.slick_threshold:
1629
+ continue
1630
+ sl = self._sites_for_reef(reef_idx)
1631
+ candidates = np.flatnonzero(self.C_site[sl] < (1 - slick_fraction))
1632
+ if candidates.size == 0:
1633
+ continue
1634
+ s = int(candidates[self.rng.random_int(candidates.size)] + sl.start)
1635
+ for g in CORAL_GROUPS:
1636
+ self.thermal[g][s] = (
1637
+ slick_fraction * thermal_slick[g] + self.C[g][s] * self.thermal[g][s]
1638
+ ) / (slick_fraction + self.C[g][s] + self.small)
1639
+ for g in CORAL_GROUPS:
1640
+ self.C[g][s] += frac[g] * slick_fraction
1641
+ treatments += 1
1642
+
1643
+ def shade_local_reef(self) -> None:
1644
+ self._seed_year()
1645
+ self.reef_shading[:] = 0.0
1646
+ p = 0
1647
+ treatments = 0
1648
+ while treatments < self.cfg.shading_reefs and p <= self.number_of_reefs:
1649
+ p += 1
1650
+ reef_idx = self._reef_by_priority(p)
1651
+ if reef_idx is None or not self._reef_in_intervention_bounds(reef_idx):
1652
+ continue
1653
+ self.reef_shading[reef_idx] = self.cfg.reef_shading_reduction
1654
+ treatments += 1
1655
+
1656
+ def shade_regional_coral(self) -> None:
1657
+ self._seed_year()
1658
+ self.regional_shading[:] = 0.0
1659
+ mask = (
1660
+ (self.x > self.cfg.intervene_lon_min)
1661
+ & (self.x < self.cfg.intervene_lon_max)
1662
+ & (self.y > self.cfg.intervene_lat_min)
1663
+ & (self.y < self.cfg.intervene_lat_max)
1664
+ )
1665
+ self.regional_shading[mask] = self.cfg.regional_shading_reduction
1666
+
1667
+ def increase_pH(self) -> None:
1668
+ self._seed_year()
1669
+ p = 0
1670
+ treatments = 0
1671
+ while treatments < self.cfg.pH_reefs and p <= self.number_of_reefs:
1672
+ p += 1
1673
+ reef_idx = self._reef_by_priority(p)
1674
+ if reef_idx is None or not self._reef_in_intervention_bounds(reef_idx):
1675
+ continue
1676
+ self.pH_protect[reef_idx] = self.cfg.pH_protection
1677
+ treatments += 1
1678
+
1679
+ def bleaching(self) -> None:
1680
+ self._seed_year()
1681
+ self.dhw[:] = 0.0
1682
+ for g in CORAL_GROUPS:
1683
+ self.bleach_mort[g][:] = 0.0
1684
+
1685
+ dhw_max = 0.0
1686
+ bleaching_centre: int | None = None
1687
+ if self.year > 1997 and self.year < self.cfg.projection_year:
1688
+ historical = {
1689
+ 1998: (8, (-21, -20)),
1690
+ 2002: (10, (-21, -20)),
1691
+ 2016: (9, (-12, -11)),
1692
+ 2017: (8, (-17, -16)),
1693
+ 2020: (6, (-20, -19)),
1694
+ 2022: (6, (-18, -17)),
1695
+ }
1696
+ if self.year in historical:
1697
+ dhw_max, lat_band = historical[self.year]
1698
+ candidates = np.flatnonzero((self.y > lat_band[0]) & (self.y < lat_band[1]))
1699
+ if candidates.size > 0:
1700
+ bleaching_centre = int(candidates[self.rng.random_int(candidates.size)])
1701
+ elif self.year >= self.cfg.projection_year:
1702
+ self._seed_year()
1703
+ if self.cfg.SSP == 1.9:
1704
+ dhw_max = (8 - self.rng.random_float(16)) - 0.0028 * (self.year - 2060) ** 2 + 8
1705
+ if self.cfg.SSP == 2.6:
1706
+ dhw_max = (8 - self.rng.random_float(16)) - 0.0026 * (self.year - 2070) ** 2 + 10
1707
+ if self.cfg.SSP == 4.5:
1708
+ dhw_max = (8 - self.rng.random_float(16)) + 0.221 * self.year - 444
1709
+ if self.cfg.SSP == 7.0:
1710
+ dhw_max = (8 - self.rng.random_float(16)) + 0.0039 * (self.year - 2010) ** 2 + 2
1711
+ if self.cfg.SSP == 8.5:
1712
+ dhw_max = (8 - self.rng.random_float(16)) + 0.0047 * (self.year - 2000) ** 2 + 1
1713
+ bleaching_centre = self.rng.random_int(self.number_of_reefs)
1714
+
1715
+ if dhw_max <= 0 or bleaching_centre is None:
1716
+ return
1717
+
1718
+ bleaching_radius = self.dhw_scale * dhw_max * (0.5 + self.rng.random_float(1.0))
1719
+ centre_dist = self.dist_matrix[bleaching_centre]
1720
+ affected = np.flatnonzero(centre_dist <= bleaching_radius)
1721
+ for reef_idx in affected:
1722
+ radial = 1 - nl_median(
1723
+ 0,
1724
+ ((centre_dist[reef_idx] / self.per_km / bleaching_radius) ** 2),
1725
+ 1,
1726
+ )
1727
+ self.dhw[reef_idx] = (
1728
+ dhw_max
1729
+ * radial
1730
+ * (1 - self.reef_shading[reef_idx])
1731
+ * (1 - self.regional_shading[reef_idx])
1732
+ )
1733
+ dhw_reef = self.dhw[reef_idx]
1734
+ sl = self._sites_for_reef(int(reef_idx))
1735
+ for s in range(sl.start, sl.stop):
1736
+ rand = self.rng.random_float(1.0)
1737
+ new_rubble = 0.0
1738
+ for g in CORAL_GROUPS:
1739
+ mort = max(0.0, rand * (1 - math.exp(-0.12 * (dhw_reef - self.thermal[g][s]))))
1740
+ self.bleach_mort[g][s] = mort
1741
+ new_rubble += 2 * self.C[g][s] * mort
1742
+ self.C[g][s] *= 1 - mort
1743
+ self.thermal[g][s] *= (1 + self.adaptability) ** mort
1744
+ self.thermal[g][s] = min(
1745
+ self.thermal[g][s], self.thermal_i[g] + self.adapt_plasticity
1746
+ )
1747
+
1748
+ self.R_site[s] = min(1.0, self.R_site[s] + new_rubble)
1749
+
1750
+ self.thermal["sa"][s] -= (
1751
+ self.thermal["sa"][s] - self.thermal_i["sa"]
1752
+ ) / self.adapt_decay_time
1753
+ self.rate["sa"][s] = self.rate_i["sa"] * (
1754
+ 1 - self.adapt_penalty * (self.thermal["sa"][s] - self.thermal_i["sa"])
1755
+ )
1756
+ for g in ("ta", "mo", "po", "fa", "tt"):
1757
+ decay = self.adapt_decay_time / (
1758
+ (self.rate[g][s] + self.small) / (self.rate["sa"][s] + self.small)
1759
+ )
1760
+ self.thermal[g][s] -= (self.thermal[g][s] - self.thermal_i[g]) / decay
1761
+ self.rate[g][s] = self.rate_i[g] * (
1762
+ 1 - self.adapt_penalty * (self.thermal[g][s] - self.thermal_i[g])
1763
+ )
1764
+
1765
+ def cyclone(self) -> None:
1766
+ self._seed_year()
1767
+ smaller = (200 + self.rng.random_int(300)) * self.per_km
1768
+ medium = (400 + self.rng.random_int(300)) * self.per_km
1769
+ larger = (600 + self.rng.random_int(300)) * self.per_km
1770
+ for g in CORAL_GROUPS:
1771
+ self.cyclone_mort[g][:] = 0.0
1772
+
1773
+ if self.rng.random_int(100) < 35:
1774
+ self.cyclone_category = 2
1775
+ self.cyclone_radius = smaller
1776
+ self.cyclone_centre = self.rng.random_int(self.number_of_reefs)
1777
+ self.cyclone_mortality()
1778
+
1779
+ if self.year > 1975 and self.year < self.cfg.projection_year:
1780
+ historical: dict[int, tuple[float, int, tuple[float, float]]] = {
1781
+ 1976: (smaller, 3, (-23, -22)),
1782
+ 1980: (smaller, 4, (-23, -22)),
1783
+ 1986: (medium, 3, (-18, -17)),
1784
+ 1989: (larger, 3, (-20, -19)),
1785
+ 1990: (medium, 3, (-15, -14)),
1786
+ 1991: (larger, 4, (-18, -17)),
1787
+ 1997: (larger, 3, (-19, -18)),
1788
+ 1998: (larger, 3, (-16, -15)),
1789
+ 2005: (smaller, 5, (-14, -13)),
1790
+ 2006: (medium, 3, (-18, -17)),
1791
+ 2007: (smaller, 3, (-14, -13)),
1792
+ 2009: (larger, 4, (-21, -20)),
1793
+ 2010: (smaller, 3, (-20, -19)),
1794
+ 2011: (larger, 5, (-18, -17)),
1795
+ 2014: (medium, 3, (-15, -14)),
1796
+ 2015: (smaller, 3, (-22, -20)),
1797
+ 2017: (smaller, 3, (-20, -19)),
1798
+ 2019: (smaller, 3, (-16, -15)),
1799
+ }
1800
+ if self.year in historical:
1801
+ radius, cat, lat_band = historical[self.year]
1802
+ candidates = np.flatnonzero((self.y > lat_band[0]) & (self.y < lat_band[1]))
1803
+ if candidates.size > 0:
1804
+ self.cyclone_radius = radius
1805
+ self.cyclone_category = cat
1806
+ self.cyclone_centre = int(candidates[self.rng.random_int(candidates.size)])
1807
+ self.cyclone_mortality()
1808
+ else:
1809
+ if self.rng.random_int(100) < 21:
1810
+ self.cyclone_radius = (200 + self.rng.random_int(500)) * self.per_km
1811
+ self.cyclone_category = 3
1812
+ self.cyclone_centre = self.rng.random_int(self.number_of_reefs)
1813
+ self.cyclone_mortality()
1814
+ if self.rng.random_int(100) < 4:
1815
+ self.cyclone_radius = (200 + self.rng.random_int(600)) * self.per_km
1816
+ self.cyclone_category = 4
1817
+ self.cyclone_centre = self.rng.random_int(self.number_of_reefs)
1818
+ self.cyclone_mortality()
1819
+ if self.rng.random_int(100) < 2:
1820
+ self.cyclone_radius = (200 + self.rng.random_int(700)) * self.per_km
1821
+ self.cyclone_category = 5
1822
+ self.cyclone_centre = self.rng.random_int(self.number_of_reefs)
1823
+ self.cyclone_mortality()
1824
+
1825
+ self.flood_load = 0.2 + 0.4 * (1 + 0.2 * self.cyclone_category - self.catchment_condition)
1826
+
1827
+ def cyclone_mortality(self) -> None:
1828
+ self._seed_year()
1829
+ if self.cyclone_centre < 0:
1830
+ return
1831
+ d = self.dist_matrix[self.cyclone_centre]
1832
+ affected = np.flatnonzero(d <= self.cyclone_radius)
1833
+ for reef_idx in affected:
1834
+ radial = 1 - nl_median(0, ((d[reef_idx] / self.per_km / self.cyclone_radius) ** 2), 1)
1835
+ shelter = math.sqrt(
1836
+ self.reef_sites[reef_idx] * self.number_of_reefs / self.number_of_sites
1837
+ )
1838
+ sl = self._sites_for_reef(int(reef_idx))
1839
+ for s in range(sl.start, sl.stop):
1840
+ rand = (0.7 + self.rng.random_float(0.3)) * radial / shelter
1841
+ mort_max = rand * (0.25 * self.cyclone_category - 0.30)
1842
+ mort_min = rand * max(0.0, 0.3 * self.cyclone_category - 0.9)
1843
+ self.cyclone_mort["sa"][s] = (1.0 * mort_max + 0.0 * mort_min) * (
1844
+ self.rate_i["sa"] / self.rate["sa"][s]
1845
+ )
1846
+ self.cyclone_mort["ta"][s] = (0.9 * mort_max + 0.1 * mort_min) * (
1847
+ self.rate_i["ta"] / self.rate["ta"][s]
1848
+ )
1849
+ self.cyclone_mort["mo"][s] = (0.7 * mort_max + 0.3 * mort_min) * (
1850
+ self.rate_i["mo"] / self.rate["mo"][s]
1851
+ )
1852
+ self.cyclone_mort["po"][s] = (0.1 * mort_max + 0.9 * mort_min) * (
1853
+ self.rate_i["po"] / self.rate["po"][s]
1854
+ )
1855
+ self.cyclone_mort["fa"][s] = (0.0 * mort_max + 1.0 * mort_min) * (
1856
+ self.rate_i["fa"] / self.rate["fa"][s]
1857
+ )
1858
+ self.cyclone_mort["tt"][s] = (1.0 * mort_max + 0.0 * mort_min) * (
1859
+ self.rate_i["tt"] / self.rate["tt"][s]
1860
+ )
1861
+ self.R_site[s] = nl_median(
1862
+ 0.0,
1863
+ 1.0,
1864
+ self.R_site[s]
1865
+ + 2
1866
+ * (
1867
+ self.cyclone_mort["sa"][s] * self.C["sa"][s]
1868
+ + self.cyclone_mort["ta"][s] * self.C["ta"][s]
1869
+ + self.cyclone_mort["mo"][s] * self.C["mo"][s]
1870
+ + self.cyclone_mort["po"][s] * self.C["po"][s]
1871
+ + self.cyclone_mort["fa"][s] * self.C["fa"][s]
1872
+ + self.cyclone_mort["tt"][s] * self.C["tt"][s]
1873
+ ),
1874
+ )
1875
+ for g in CORAL_GROUPS:
1876
+ self.C[g][s] *= max(self.small, 1 - self.cyclone_mort[g][s])
1877
+
1878
+ def reef_populations(self) -> None:
1879
+ for reef_idx in range(self.number_of_reefs):
1880
+ sl = self._sites_for_reef(reef_idx)
1881
+ self.B_r[reef_idx] = nl_round(float(np.mean(self.B[sl])))
1882
+ self.T_r[reef_idx] = nl_round(float(np.mean(self.T[sl])))
1883
+
1884
+ for age in ("2", "3", "4", "5", "6"):
1885
+ self.S_r[age][reef_idx] = nl_round(float(np.mean(self.S[age][sl])))
1886
+ self.S_r["1"][reef_idx] = nl_round(
1887
+ min(
1888
+ self.S_r["1"][reef_idx],
1889
+ 2.77
1890
+ * (
1891
+ self.S_r["2"][reef_idx]
1892
+ + self.S_r["3"][reef_idx]
1893
+ + self.S_r["4"][reef_idx]
1894
+ + self.S_r["5"][reef_idx]
1895
+ + self.S_r["6"][reef_idx]
1896
+ ),
1897
+ )
1898
+ )
1899
+ self.S_manta_r[reef_idx] = nl_round(
1900
+ 0.50 * self.S_r["2"][reef_idx]
1901
+ + 0.70 * self.S_r["3"][reef_idx]
1902
+ + 0.85 * self.S_r["4"][reef_idx]
1903
+ + 0.95 * self.S_r["5"][reef_idx]
1904
+ + 0.99 * self.S_r["6"][reef_idx]
1905
+ )
1906
+
1907
+ for g in CORAL_GROUPS:
1908
+ self.C_r[g][reef_idx] = float(np.mean(self.C[g][sl]))
1909
+ self.bleach_mort_r[g][reef_idx] = float(np.mean(self.bleach_mort[g][sl]))
1910
+ self.cyclone_mort_r[g][reef_idx] = float(np.mean(self.cyclone_mort[g][sl]))
1911
+ self.predate_mort_r[g][reef_idx] = float(np.mean(self.predate_mort[g][sl]))
1912
+ self.C_reef[reef_idx] = float(np.mean(self.C_site[sl]))
1913
+ self.R_reef[reef_idx] = float(np.mean(self.R_site[sl]))
1914
+
1915
+ if self.search_mode == 1 and self.year >= self.cfg.search_year:
1916
+ idx = self._reef_by_priority(1)
1917
+ if idx is not None:
1918
+ self.benefit[idx] += float(np.mean(self.C_site))
1919
+
1920
+ # ---- intervention and loop helpers ----
1921
+ def _apply_interventions(self) -> None:
1922
+ if self.year >= self.cfg.start_CoTS_control:
1923
+ if self.cfg.CoTS_vessels_GBR > 0:
1924
+ self.control_region = "GBR"
1925
+ self.S_vessels = self.cfg.CoTS_vessels_GBR
1926
+ self.control_cots()
1927
+ if self.cfg.CoTS_vessels_FN > 0:
1928
+ self.control_region = "FN"
1929
+ self.S_vessels = self.cfg.CoTS_vessels_FN
1930
+ self.control_cots()
1931
+ if self.cfg.CoTS_vessels_N > 0:
1932
+ self.control_region = "N"
1933
+ self.S_vessels = self.cfg.CoTS_vessels_N
1934
+ self.control_cots()
1935
+ if self.cfg.CoTS_vessels_C > 0:
1936
+ self.control_region = "C"
1937
+ self.S_vessels = self.cfg.CoTS_vessels_C
1938
+ self.control_cots()
1939
+ if self.cfg.CoTS_vessels_S > 0:
1940
+ self.control_region = "S"
1941
+ self.S_vessels = self.cfg.CoTS_vessels_S
1942
+ self.control_cots()
1943
+ if self.cfg.CoTS_vessels_sector > 0:
1944
+ self.S_vessels = self.cfg.CoTS_vessels_sector
1945
+ self.control_cots_by_sector()
1946
+
1947
+ if (
1948
+ np.count_nonzero((self.year < self.rezone_year) & (self.year < self.future_rezone_year))
1949
+ > 0
1950
+ ):
1951
+ self.apply_fishing()
1952
+
1953
+ if self.year >= self.cfg.start_catchment_restore and self.cfg.restore_timeframe > 0:
1954
+ self.catchment_condition += (1 - self.catchment_condition) / self.cfg.restore_timeframe
1955
+ if self.year >= self.cfg.start_rubble_consolidation:
1956
+ self.consolidate_rubble()
1957
+ if self.year >= self.cfg.start_coral_seeding:
1958
+ self.seed_tt_coral()
1959
+ if self.year >= self.cfg.start_coral_slick:
1960
+ self.seed_coral_slick()
1961
+ if self.year >= self.cfg.start_emperor_release:
1962
+ self.release_fish()
1963
+ if self.year >= self.cfg.start_reef_shading:
1964
+ self.shade_local_reef()
1965
+ if self.year >= self.cfg.start_regional_shading:
1966
+ self.shade_regional_coral()
1967
+ if self.year >= self.cfg.start_pH_protection:
1968
+ self.increase_pH()
1969
+
1970
+ if self.year >= 2026:
1971
+ self._apply_perfect_intervention()
1972
+
1973
+ def _apply_perfect_intervention(self) -> None:
1974
+ mode = self.cfg.perfect_intervention
1975
+ if mode == "Starfish-control":
1976
+ for reef_idx in np.flatnonzero(self.priority <= 100 * self.ensemble):
1977
+ sl = self._sites_for_reef(int(reef_idx))
1978
+ for age in ("2", "3", "4", "5", "6"):
1979
+ self.S[age][sl] = 0
1980
+ if mode == "Coral-replenishment":
1981
+ for reef_idx in np.flatnonzero(
1982
+ (self.priority <= 100 * self.ensemble) & (self.C_reef < 0.2)
1983
+ ):
1984
+ sl = self._sites_for_reef(int(reef_idx))
1985
+ idxs = np.flatnonzero(self.C_site[sl] < 0.2) + sl.start
1986
+ for s in idxs:
1987
+ self.C["sa"][s] += 0.01
1988
+ self.C["ta"][s] += 0.01
1989
+ self.C["mo"][s] += 0.01
1990
+ self.C["po"][s] += 0.01
1991
+ self.C["fa"][s] += 0.01
1992
+ if mode == "Coral-enhancement":
1993
+ for reef_idx in np.flatnonzero(
1994
+ (self.priority <= 100 * self.ensemble) & (self.C_reef < 0.2)
1995
+ ):
1996
+ sl = self._sites_for_reef(int(reef_idx))
1997
+ idxs = np.flatnonzero(self.C_site[sl] < 0.2) + sl.start
1998
+ self.C["tt"][idxs] += 0.01
1999
+ if mode == "Rubble-stabilisation":
2000
+ for reef_idx in np.flatnonzero(self.priority <= 100 * self.ensemble):
2001
+ sl = self._sites_for_reef(int(reef_idx))
2002
+ self.R_site[sl] = 0
2003
+ if mode == "Coral-shading":
2004
+ self.reef_shading[self.priority <= 100 * self.ensemble] = 1.0
2005
+ if mode == "Fish-protection":
2006
+ self.future_rezone_year[self.priority <= 100 * self.ensemble] = 2026
2007
+ if mode == "ShadingPlusControl":
2008
+ for reef_idx in np.flatnonzero(self.priority <= 100 * self.ensemble):
2009
+ self.reef_shading[reef_idx] = 1.0
2010
+ sl = self._sites_for_reef(int(reef_idx))
2011
+ for age in ("2", "3", "4", "5", "6"):
2012
+ self.S[age][sl] = 0
2013
+
2014
+ def _reef_by_priority(self, p: int) -> int | None:
2015
+ idxs = np.flatnonzero(self.priority == p)
2016
+ if idxs.size == 0:
2017
+ return None
2018
+ return int(idxs[0])
2019
+
2020
+ def _reef_in_intervention_bounds(self, reef_idx: int) -> bool:
2021
+ return (
2022
+ self.x[reef_idx] > self.cfg.intervene_lon_min
2023
+ and self.x[reef_idx] < self.cfg.intervene_lon_max
2024
+ and self.y[reef_idx] > self.cfg.intervene_lat_min
2025
+ and self.y[reef_idx] < self.cfg.intervene_lat_max
2026
+ )
2027
+
2028
+ def _projection_bleach_probability(self) -> float:
2029
+ if self.cfg.SSP == 1.9:
2030
+ return 0.39 - 0.00007 * (self.year - 2070) ** 2
2031
+ if self.cfg.SSP == 2.6:
2032
+ return 0.47 - 0.00011 * (self.year - 2070) ** 2
2033
+ if self.cfg.SSP == 4.5:
2034
+ return 0.70 - 0.00011 * (self.year - 2090) ** 2
2035
+ if self.cfg.SSP == 7.0:
2036
+ return 0.98 - 0.00013 * (self.year - 2100) ** 2
2037
+ if self.cfg.SSP == 8.5:
2038
+ return 0.98 - 0.00017 * (self.year - 2090) ** 2
2039
+ return 0.0
2040
+
2041
+ # ---- output ----
2042
+ def _set_up_output_files(self) -> None:
2043
+ self.rng.seed(1)
2044
+ if self.output_file.exists():
2045
+ self.output_file.unlink()
2046
+ lines: list[str] = []
2047
+ lines.append(f"Climate scenario, {self.cfg.SSP}")
2048
+ lines.append("")
2049
+ lines.append(f"Ensemble runs, {self.cfg.ensemble_runs}")
2050
+ lines.append(f"Start year, {self.cfg.start_year}")
2051
+ lines.append(f"Spinup backtrack (years), {self.cfg.spinup_backtrack_years}")
2052
+ lines.append(f"Save year, {self.cfg.save_year}")
2053
+ lines.append(f"Projection year, {self.cfg.projection_year}")
2054
+ lines.append(f"End year, {self.cfg.end_year}")
2055
+ lines.append(f"Search year, {self.cfg.search_year}")
2056
+ lines.append("")
2057
+ lines.append(f"CoTS control start year, {self.cfg.start_CoTS_control}")
2058
+ lines.append(f"CoTS control ecological threshold (CoTS per ha), {self.cfg.eco_threshold}")
2059
+ lines.append(f"CoTS control CoTS threshold (CoTS per ha), {self.cfg.CoTS_threshold}")
2060
+ lines.append(f"CoTS control coral threshold (CoTS per ha), {self.cfg.coral_threshold}")
2061
+ lines.append(f"CoTS vessels across GBR, {self.cfg.CoTS_vessels_GBR}")
2062
+ lines.append(f"CoTS vessels in Far-northern Region, {self.cfg.CoTS_vessels_FN}")
2063
+ lines.append(f"CoTS vessels in Northern Region, {self.cfg.CoTS_vessels_N}")
2064
+ lines.append(f"CoTS vessels in Central Region, {self.cfg.CoTS_vessels_C}")
2065
+ lines.append(f"CoTS vessels in Southern Region, {self.cfg.CoTS_vessels_S}")
2066
+ lines.append(f"Catchment restoration start year, {self.cfg.start_catchment_restore}")
2067
+ lines.append(f"Catchment restoration timescale (years), {self.cfg.restore_timeframe}")
2068
+ lines.append(f"Future zoning start year, {self.cfg.start_modified_zoning}")
2069
+ lines.append(f"Number of reefs included in future rezoning, {self.cfg.rezoned_reefs}")
2070
+ lines.append("")
2071
+ lines.append(f"Reduction in fisheries catch start year, {self.cfg.start_modified_fishing}")
2072
+ lines.append(f" Fractional reduction in fisheries catches, {self.cfg.catch_reduction}")
2073
+ lines.append("")
2074
+ lines.append(f"Upper fish size limit start year, {self.cfg.start_upper_sizelimit}")
2075
+ lines.append(f"Lower fish size limit start year, {self.cfg.start_lower_sizelimit}")
2076
+ lines.append(
2077
+ f"Exclude fishing from active outbreak reefs start year, {self.cfg.start_CoTSlimit}"
2078
+ )
2079
+ lines.append("")
2080
+ lines.append(f"Emperor release start year, {self.cfg.start_emperor_release}")
2081
+ lines.append(f"Number of release reefs, {self.cfg.release_reefs}")
2082
+ lines.append(f"Maximum adult emperors (per ha) for release, {self.cfg.release_threshold}")
2083
+ lines.append(f"Number of juvenile emperors released per reef, {self.cfg.release_number}")
2084
+ lines.append("")
2085
+ lines.append(f"Regional shading start year, {self.cfg.start_regional_shading}")
2086
+ lines.append(
2087
+ f"Absolute DHW reduction due to regional shading (DHW), {self.cfg.regional_shading_reduction}"
2088
+ )
2089
+ lines.append("")
2090
+ lines.append(f"Minimum longitude of interventions, {self.cfg.intervene_lon_min}")
2091
+ lines.append(f"Maximum longitude of interventions, {self.cfg.intervene_lon_max}")
2092
+ lines.append(f"Minimum latitude of interventions, {self.cfg.intervene_lat_min}")
2093
+ lines.append(f"Maximum latitude of interventions, {self.cfg.intervene_lat_max}")
2094
+ lines.append("")
2095
+ lines.append(f"Rubble consolidation start year, {self.cfg.start_rubble_consolidation}")
2096
+ lines.append(f"Annual number of consolidated reefs, {self.cfg.consolidation_reefs}")
2097
+ lines.append(
2098
+ f"Minimum rubble cover threshold for consolidation [0 1], {self.cfg.consolidation_threshold}"
2099
+ )
2100
+ lines.append(f"Total annual consolidated area (ha) , {self.cfg.consolidation_hectares}")
2101
+ lines.append("")
2102
+ lines.append(f"Thermally tolerant coral seeding start year, {self.cfg.start_coral_seeding}")
2103
+ lines.append(f"Annual number of reefs seeded with coral, {self.cfg.seed_reefs}")
2104
+ lines.append(
2105
+ f"Maximum coral cover threshold for coral seeding [0 1], {self.cfg.seed_threshold}"
2106
+ )
2107
+ lines.append(f"Total annual area of seeded corals (ha), {self.cfg.seed_hectares}")
2108
+ lines.append(
2109
+ "Fraction of staghorn acropora corals able to hybridise with thermally tolerant corals [0 1], "
2110
+ f"{self.cfg.hybrid_fraction}"
2111
+ )
2112
+ lines.append(
2113
+ "Dominance of thermally tolerant corals in setting thermal tolerance of hybrids [0 1], "
2114
+ f"{self.cfg.dominance}"
2115
+ )
2116
+ lines.append("")
2117
+ lines.append(f"Coral slicks start year, {self.cfg.start_coral_slick}")
2118
+ lines.append(f"Annual number of reefs with coral slicks released, {self.cfg.slick_reefs}")
2119
+ lines.append(
2120
+ f"Maximum coral cover threshold for coral slicks [0 1], {self.cfg.slick_threshold}"
2121
+ )
2122
+ lines.append(f"Total annual area of slick corals (ha), {self.cfg.slick_hectares}")
2123
+ lines.append("")
2124
+ lines.append(f"Reef shading start year, {self.cfg.start_reef_shading}")
2125
+ lines.append(f"Annual number of reefs locally shaded, {self.cfg.shading_reefs}")
2126
+ lines.append(
2127
+ f"Fractional DHW reduction due to local shading [0 1], {self.cfg.reef_shading_reduction}"
2128
+ )
2129
+ lines.append("")
2130
+ lines.append(f"Ocean acidification treatment start year, {self.cfg.start_pH_protection}")
2131
+ lines.append(f"Annual number of reefs treated for ocean acidification, {self.cfg.pH_reefs}")
2132
+ lines.append(
2133
+ f"Fractional protection from ocean acidification [0 1], {self.cfg.pH_protection}"
2134
+ )
2135
+ lines.append("")
2136
+ lines.append(f"CoTS vessels in active sector, {self.cfg.CoTS_vessels_sector}")
2137
+ lines.append("")
2138
+ lines.append(
2139
+ "Ensemble, Year, Reef_ID, Region, Shelf_position, Rezone_year, Priority, Longitude, Latitude, km_offshore, Reef_sites,"
2140
+ "C_sa, C_ta, C_mo, C_po, C_fa, C_tt, C_out_degree, DHW, bleach_sa, bleach_ta, bleach_mo, bleach_po, bleach_fa, bleach_tt, "
2141
+ "Maximum_cyclone_category, cyclone_sa, cyclone_ta, cyclone_mo, cyclone_po, cyclone_fa, cyclone_tt, "
2142
+ "predate_sa, predate_ta, predate_mo, predate_po, predate_fa, predate_tt, "
2143
+ "S_1, S_2, S_3, S_4, S_5, S_6, S_manta, Control_dives, Benthic_invert, Triggerfish, "
2144
+ "E_1, E_2, E_3, E_4, E_5, E_catch_kg,G_1, G_2, G_3, G_4, G_5, G_catch_kg"
2145
+ )
2146
+ self.output_file.write_text("\n".join(lines) + "\n")
2147
+ logger.debug("Initialized output file header at %s.", self.output_file)
2148
+
2149
+ def write_output(self) -> None:
2150
+ self.rng.seed(1)
2151
+ lines = []
2152
+ for i in range(self.number_of_reefs):
2153
+ row: Iterable[object] = (
2154
+ self.ensemble,
2155
+ self.year,
2156
+ self.reef_id[i],
2157
+ self.region_name[i],
2158
+ self.shelf_position[i],
2159
+ self.rezone_year[i],
2160
+ self.priority_category[i],
2161
+ self.x[i],
2162
+ self.y[i],
2163
+ self.km_offshore[i],
2164
+ self.reef_sites[i],
2165
+ self.C_r["sa"][i],
2166
+ self.C_r["ta"][i],
2167
+ self.C_r["mo"][i],
2168
+ self.C_r["po"][i],
2169
+ self.C_r["fa"][i],
2170
+ self.C_r["tt"][i],
2171
+ self.C_out_degree[i],
2172
+ self.dhw[i],
2173
+ self.bleach_mort_r["sa"][i],
2174
+ self.bleach_mort_r["ta"][i],
2175
+ self.bleach_mort_r["mo"][i],
2176
+ self.bleach_mort_r["po"][i],
2177
+ self.bleach_mort_r["fa"][i],
2178
+ self.bleach_mort_r["tt"][i],
2179
+ self.cyclone_category,
2180
+ self.cyclone_mort_r["sa"][i],
2181
+ self.cyclone_mort_r["ta"][i],
2182
+ self.cyclone_mort_r["mo"][i],
2183
+ self.cyclone_mort_r["po"][i],
2184
+ self.cyclone_mort_r["fa"][i],
2185
+ self.cyclone_mort_r["tt"][i],
2186
+ self.predate_mort_r["sa"][i],
2187
+ self.predate_mort_r["ta"][i],
2188
+ self.predate_mort_r["mo"][i],
2189
+ self.predate_mort_r["po"][i],
2190
+ self.predate_mort_r["fa"][i],
2191
+ self.predate_mort_r["tt"][i],
2192
+ self.S_r["1"][i],
2193
+ self.S_r["2"][i],
2194
+ self.S_r["3"][i],
2195
+ self.S_r["4"][i],
2196
+ self.S_r["5"][i],
2197
+ self.S_r["6"][i],
2198
+ self.S_manta_r[i],
2199
+ self.dives_reef[i],
2200
+ self.B_r[i],
2201
+ self.T_r[i],
2202
+ self.E["1"][i],
2203
+ self.E["2"][i],
2204
+ self.E["3"][i],
2205
+ self.E["4"][i],
2206
+ self.E["5"][i],
2207
+ self.E_catch_kg[i],
2208
+ self.G["1"][i],
2209
+ self.G["2"][i],
2210
+ self.G["3"][i],
2211
+ self.G["4"][i],
2212
+ self.G["5"][i],
2213
+ self.G_catch_kg[i],
2214
+ )
2215
+ lines.append(",".join(str(x) for x in row))
2216
+ with self.output_file.open("a") as f:
2217
+ f.write("\n".join(lines) + "\n")
2218
+ logger.debug(
2219
+ "Appended %s reef rows to output (ensemble=%s year=%s).",
2220
+ len(lines),
2221
+ self.ensemble,
2222
+ self.year,
2223
+ )
2224
+
2225
+ def _write_priority_benefit(self, path: Path | None = None) -> None:
2226
+ out = path if path is not None else Path("priority_reef_benefit.csv")
2227
+ with out.open("a") as f:
2228
+ idx = self._reef_by_priority(1)
2229
+ if idx is not None:
2230
+ f.write(f"{self.reef_id[idx]}, {self.x[idx]}, {self.y[idx]}, {self.benefit[idx]}\n")
2231
+ logger.debug(
2232
+ "Appended priority benefit record for reef_id=%s to %s.",
2233
+ self.reef_id[idx],
2234
+ out,
2235
+ )
2236
+
2237
+
2238
+ def _run_simulation_ensemble_worker(
2239
+ cfg: CoconetConfig,
2240
+ checkpoint: SpinupCheckpoint,
2241
+ ensemble_id: int,
2242
+ output_part: Path,
2243
+ priority_part: Path | None,
2244
+ ) -> None:
2245
+ """Child process: build model, restore spinup checkpoint, run one simulation ensemble."""
2246
+ configure_logging(cfg.log_level)
2247
+ with threadpoolctl.threadpool_limits(limits=1, user_api="blas"):
2248
+ model = CoconetModel(cfg)
2249
+ model.setup(init_output=False)
2250
+ model.import_spinup_checkpoint(checkpoint)
2251
+ model.ensemble = ensemble_id
2252
+ model.output_file = output_part
2253
+ model.initialise_run()
2254
+ model._run_ensemble_year_steps()
2255
+ model._maybe_write_priority_benefit(priority_part)