Quickstart

A search, and then what each knob does. One import: everything below is on oroscope, and the submodules stay available when a narrower namespace reads better.

pip install oroscope

Cut a region

import oroscope

info = oroscope.crop("input/dem/arequipa_SRTMGL1.tif", "input/dem/colca.tif",
                     north=-15.30, south=-15.85, west=-72.40, east=-71.55)
print(info["origin_lat"], info["origin_lon"], info["rows"], info["cols"])

The crop carries its own north-west corner, so it stands alone as a georeferenced file and the search reads its geometry back out of it.

Read what it found

Every run ends with a plain-language summary of itself, and saves it as explanation.txt beside the results. It is on by default; --no_explain suppresses it.

It says four things the results file contains but does not spell out:

  • What was found — sites, area, capacity against the target.

  • Which constraint bound. The funnel stage that removed the largest share of what reached it, and the parameter behind it. When a search returns little or nothing, this is the answer.

  • What held the sites back. The score is a product of named components, so the lowest one is reported per site: under a product it bounds the total from above.

  • Which numbers are assumptions, with the measured sensitivity of each. Including the one every reader gets wrong unaided: reported area is not physics-accepted area, because morphological closing inflated it 2.35× at Colca.

Any results file can be re-explained later, with no DEM and nothing re-run:

import json, explain

with open("output/tambo_colca_config/oroscope_results_colca.json") as f:
    results = json.load(f)
print(explain.explain_results(results))

Combine two experiments

grand = oroscope.load_run("output/grand_colca_config")
tambo = oroscope.load_run("output/tambo_colca_config")
oroscope.check_alignment([grand, tambo])   # refuses to overlay the wrong ground

print(oroscope.explain_combination(report, {"GRAND": grand_results,
                                            "TAMBO": tambo_results}))

reports terrain viable for each, for both (co-location) and for either — and says which screening band decides that, which is usually slope: a pixel has one slope and both experiments must accept it. oroscope-combine does the same and writes a membership raster and an overview map.

The physics on its own

The physics layer takes arrays and returns values, with no side effects and no terrain:

from oroscope import physics

print(f"tau decay length at 100 PeV: {physics.tau_decay_length_m(100.0):,.0f} m")
print(f"X_max at 100 PeV:            {float(physics.shower_maximum_gcm2(100.0)):.0f} g/cm^2")

lo, hi = physics.grammage_band_from_energy(3.0, 1000.0)
print(f"particle shower band:        {lo:.0f} - {hi:.0f} g/cm^2")
tau decay length at 100 PeV: 4,898 m
X_max at 100 PeV:            645 g/cm^2
particle shower band:        236 - 1287 g/cm^2

The scan itself takes a terrain array and a list of candidate pixels:

import numpy as np
from oroscope import arrival_scan, site_searcher as ss

# A ridge to the east of a flat plain, so a westward-facing candidate sees it
n = 300
cell_y, cell_x = 30.7, 29.8
cols = np.arange(n)[None, :].repeat(n, 0)
z = (2000.0 + 1200.0 * np.exp(-((cols - 220) / 18.0) ** 2)).astype(np.float32)

grid = ss.resolve_grid_geometry("none.tif", -15.6, cell_size_deg=1 / 3600)
candidates = np.array([[150.0, 60.0, 90.0]])        # row, col, aspect east

out = arrival_scan.scan(candidates, z, grid, n_azimuths=1, half_width_deg=0.0,
                        elev_min_deg=-3.0, elev_max_deg=3.0, n_elev_bins=12,
                        min_dist_km=1.0, max_dist_km=10.0, max_range_m=10000.0)

print(f"accepted directions: {int(out['cells'][0])}")
print(f"mean exit distance:  {out['mean_distance_m'][0]:,.0f} m")
print(f"horizon:             {out['horizon_deg'][0]:.2f} deg")
accepted directions: 6
mean exit distance:  3,822 m
horizon:             14.16 deg

Before a long run

oroscope.preflight_memory gives the estimate and the address-space cap on their own — worth calling before a loop, since it was a sweep that once reached 6.9 GB and was killed by the kernel:

report = oroscope.preflight_memory("input/dem/arequipa_SRTMGL1.tif",
                                   downsample_factor=4, candidate_stride=5)
print(report["estimate_gb"], report["available_gb"])

Passing max_memory_gb to a search caps its address space, so one that outgrows the machine fails with MemoryError naming itself rather than inviting the kernel’s OOM killer to choose a victim.

Choosing parameters

The criteria are per experiment. The two bundled configurations differ in these ways, and the differences are the whole of what makes one GRAND and the other TAMBO:

Parameter

GRAND

TAMBO

Why

min_slope_deg / max_slope_deg

3 / 25

20 / 60

Deployable ground against a canyon wall

min_target_slope_deg

unset

25

TAMBO needs the far wall to be a wall

min_dist_km / max_dist_km

10 / 40

2 / 5

To the horizon, or across a canyon

elev_min_deg / elev_max_deg

−3 / +3

−20 / +20

Earth-skimming, against a wall that subtends tens of degrees

antenna_spacing_km

1.0

0.1

Radio antennas against particle detectors

min_width_km

2.0

0.0

A compact array against a strip along a wall

fresnel_frequency_mhz

50

null

Radio propagation, or none

use_geomagnetic

true

false

Radio emission goes as \(|v \times B|\); particles do not care

grammage_mode

radio

particle

A maturity threshold against a band

When a search returns nothing

Read the run’s own summary, which names the stage where the count collapsed and the parameter behind it. It is printed at the end of every run and saved as explanation.txt. Behind it is the funnel, in the results JSON: the survivor count after each filter, where the constraint responsible is the line where the count collapses.

Then check how firm the answer is:

oroscope-sensitivity config/tambo_colca_config.json \
    --sweep min_score 0.0 0.2 0.35 0.5 \
    --sweep decay_energy_pev 3 55 1000

Several criteria sit near cliffs, and a result that moves by an order of magnitude across a plausible range of an assumption is a result about the assumption. See Assumptions and limitations.