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.
Run a search
config = oroscope.load_config("config/grand_colca_config.json")
results = oroscope.find_grand_regions_interactive(
dem_path="input/dem/colca.tif", run_output_dir="output/colca",
**{k: v for k, v in config.items()
if not k.startswith("_")
and k not in ("dem_path", "print_info",
"output_directory_base_with_given_json")})
It returns its results, so nothing has to find and re-read the file it just wrote:
print(results["results"]["total_sites"])
print(results["results"]["total_capacity"])
for site in oroscope.selected_sites(results)[0]:
print(site["site_id"], site["area_km2"], site["center_lat"], site["center_lon"])
Starting from oroscope.default_config() and overriding is the clearer habit — it
puts every knob in front of you — but it is no longer a correctness matter: the
function signature, oroscope --help and the template state the same default for
every parameter, and a test keeps all three in step.
The same search from a shell is oroscope --config_path config/grand_colca_config.json
— see The command line for every option, and for the other four console scripts.
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 |
|---|---|---|---|
|
3 / 25 |
20 / 60 |
Deployable ground against a canyon wall |
|
unset |
25 |
TAMBO needs the far wall to be a wall |
|
10 / 40 |
2 / 5 |
To the horizon, or across a canyon |
|
−3 / +3 |
−20 / +20 |
Earth-skimming, against a wall that subtends tens of degrees |
|
1.0 |
0.1 |
Radio antennas against particle detectors |
|
2.0 |
0.0 |
A compact array against a strip along a wall |
|
50 |
null |
Radio propagation, or none |
|
true |
false |
Radio emission goes as \(|v \times B|\); particles do not care |
|
|
|
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.