Implementation notes
For someone changing the code, or trying to work out why it is shaped the way it is. Nothing here is needed to use the tool — that is Quickstart and The command line — and nothing here is physics, which is The physics. This is the rest: the decisions that are invisible from outside and expensive to rediscover.
Almost every entry below exists because something went wrong once. The measurements are
in docs/ROADMAP.md §6, which is the long form of this page.
The shape of a run
One process, six phases, and the expensive one is third:
Phase |
Cost |
Note |
|---|---|---|
Load DEM |
seconds |
The GeoTIFF is converted once to a |
Screening |
seconds |
Tiled, |
Arrival scan |
minutes to tens of minutes |
Numba, |
Morphology |
seconds |
Closing and pruning, tiled with a halo. |
Capacity |
seconds |
Lattice packing per region. |
Save and draw |
seconds |
A separate memory peak. See below. |
Memory, which is the thing that actually breaks
Three separate quantities get confused, and confusing them has cost this project a machine.
1. The search’s anonymous memory. What
estimate_peak_memory_gb() models: the per-candidate
observable arrays and, dominating them, the ~36 arrays live at once inside
compose(). It deliberately excludes the memory-mapped DEM.
2. The map’s memory. A separate peak landing on top of the first at the very end.
The map renders at downsample_factor * 2; measured at ~190 bytes per viz pixel
plus ~130 MB of matplotlib fixed cost, by
estimate_visualisation_memory_gb(). Three runs in one
session finished their searches and then died drawing the picture, with the JSON and the
GeoTIFF already written. If a run reports its numbers but has no PNG, this is why.
3. The address-space cap. --max_memory_gb is applied as RLIMIT_AS, which caps
virtual address space and therefore counts every mapping — the DEM cache, the
ping-pong buffers, the arenas numba and BLAS reserve. So it is not comparable with either
estimate above.
Warning
A cap above available memory is not a cap. RLIMIT_AS protects the machine only
if the process reaches it before the kernel runs out; above that line the OOM killer
gets there first and picks its victim by size rather than by fault.
preflight_memory() warns, returns
cap_exceeds_available, and with refuse=True raises instead. 0 disables
capping entirely and tools/run_full_dem.py refuses it outright.
Two calibration points, both measured rather than assumed:
Run |
Estimate |
Peak RSS |
Peak virtual |
|---|---|---|---|
Arequipa, 129 Mpx, |
5.08 GiB |
6.59 GiB |
7.80 GiB |
Huaylas crop, 11.4 Mpx, |
3.27 GiB |
4.66 GiB |
5.86 GiB |
Both pairs were re-measured on the post-audit code, sampling VmHWM and VmPeak
from /proc once a second across the process tree, so the table is one vintage.
Arequipa had read 5.68 GiB RSS with the virtual column blank, and a blank there is the
dangerous kind of gap: a cap sized from the RSS figure is sized from the wrong quantity.
Three attempts settled it — a 6.5 GiB cap died in the final analysis refusing 69 MiB, a
7.5 GiB cap carried GRAND but lost TAMBO’s map to std::bad_alloc in the AGG backend,
and 8.0 GiB completed. The gap between the two columns is 1.2 GiB, and that gap is the
whole reason the estimate must not be used to set the cap.
Huaylas moved the other way, 5.31 → 4.66 GiB resident, which is the combination’s modelled memory getting 0.65 GiB cheaper. Measurements age in both directions, and neither direction is safe to assume.
The estimator is calibrated on a strided run and is roughly 2× optimistic at candidate_stride 1. Treat it as a lower bound and size a cap from measurement when the sampling is unusual.
``candidate_stride`` is the memory lever, not ``downsample_factor``. Candidates are taken on the native grid, so downsampling scales the labelling arrays as its inverse square and barely touches the dominant term. This is the single most useful fact for planning a large run and it is the opposite of what most people reach for.
Numerics and geometry
Two Earth radii, deliberately. The particle trajectory is not refracted, so it uses
the true radius; the radio path is, so Fresnel clearance uses the inflated radio radius
(k ≈ 4/3). Mixing them is a real error of a few hundred metres of apparent drop over
80 km.
Geographic pixels are not square in metres. A degree of longitude shrinks with the
cosine of the latitude, so a 1 arc-second pixel spans ~30.7 m north–south and ~29.5 m
east–west at 17° S. The longitude scale is evaluated at the DEM’s centre latitude so
the residual error is spread evenly rather than accumulating toward one edge. Every
metric quantity goes through MapGrid.
The elevation binning is free; the azimuths are not. The walk tracks the running maximum of the apparent terrain angle, which only increases, so each new value claims a contiguous band of bins and one pass fills them all. Doubling the bins costs almost nothing; doubling the azimuths doubles the run.
Detector placement is anchored, not fitted. count_grid_capacity lays the lattice
from each region’s bounding-box corner and counts positions on usable ground. Capacity is
therefore an estimate for an arbitrarily placed array, not the best achievable packing.
Positions are laid out in metres and only then looked up in the pixel grid — the earlier
version converted the spacing to an integer pixel stride and truncated three separate
times, overcounting by 7.4% at GRAND’s 1 km and 58% at TAMBO’s 100 m.
Things that were silently wrong once
Each of these produced plausible output while being incorrect, which is what made them expensive.
A preset name iterated character by character.
rfi_zonesreached the pipeline unresolved, and the pipeline iterates whatever it is given — so a search that believed it was excluding five zones excluded none, and printedRFI Zones: 8 active, one per letter. Fixed by makingresolve_rfi_zones()the single translation, called by all three callers.A component that appeared when switched off. Whether the geomagnetic weighting had been applied was judged by comparing weighted and plain solid angles — but a candidate that accepted no direction has a ratio of zero by construction, and those zeros stood in as evidence. Judged on viable candidates only, now.
A results prefix that named one experiment. Outputs were
grand_search_results_*even for TAMBO runs. Renamed tooroscope_results_*; readers still accept the old prefix, so older runs load.Funnel rows read by position. A run with RFI zones carries an extra stage, so index 4 means different things in two regions. Reading positionally made GRAND’s acceptance at Arequipa look like 20% when it is 60.1%. Read by name.
Conventions worth keeping
A library must not choose the matplotlib backend. CI asserts that importing
oroscopeleaves it untouched. Tools may setAgg; the package may not.Examples in docstrings are executed.
tests/test_doctests.pyruns everyExamplesblock, so the values in them must be computed, not predicted.Notebooks are generated, from
tools/make_notebooks.py. Edit the generator, never the.ipynb. They are executed in CI except where the cost is prohibitive, and what replaces that execution is a static check that every API name they call exists.Negative results are recorded in
docs/ROADMAP.mdso they are not retried.Lint from the repository root:
ruff check .also lints the notebooks.
Where the numbers live
Path |
What it holds |
|---|---|
|
Everything a run writes: GeoTIFF, world file, KML, PNG, results JSON, provenance, explanation. Gitignored. |
|
The small, readable artefacts a notebook reads: results JSON, provenance, explanation, combined report. Committed. |
|
Every region against every other, regenerated by |
|
The measurements behind everything on this page. |