API reference
Generated from the docstrings. Every Examples block is executed when this page is
built, so what is shown is what the code returns rather than numbers written beside
it.
Physics
Closed-form physics with no terrain in it: atmosphere, Earth chord, tau range and exit probability, shower development, geomagnetic field, Cherenkov footprint. Self-contained and usable on its own.
Closed-form physics the terrain scan needs but cannot measure from a DEM.
Everything here is analytic. The scan supplies geometry – where the tau exits, how far away, through how much local rock – and these functions supply the physics that geometry alone does not determine: how much atmosphere a shower has to develop in, how much Earth a neutrino crossed to get there, how strongly the shower radiates into a given direction, and how large its radio footprint is when it arrives.
Constants that are genuinely uncertain are parameters with stated defaults rather than literals buried in an expression. Where a default encodes a convention or an approximation, the docstring says so.
- oroscope.physics.air_density_kgm3(altitude_m: float, sea_level_density: float = 1.225, scale_height_m: float = 8400.0) float[source]
Exponential atmosphere,
rho0 * exp(-h/H).An isothermal approximation, adequate over the few kilometres of relief a site search spans. It is not a substitute for a real profile at large zenith angles.
- Parameters:
- altitude_mfloat
Altitude above sea level, in metres.
- sea_level_densityfloat, optional
Density at sea level, in kg/m^3.
- scale_height_mfloat, optional
Density scale height, in metres.
- Returns:
- float
Air density in kg/m^3.
Examples
>>> from oroscope import physics >>> round(physics.air_density_kgm3(0.0), 3) 1.225 >>> round(physics.air_density_kgm3(4000.0), 3) # a third thinner at Andean altitude 0.761
- oroscope.physics.slant_grammage_gcm2(start_altitude_m: float, elevation_deg: float, distance_m: float, sea_level_density: float = 1.225, scale_height_m: float = 8400.0) float[source]
Atmospheric depth along a slanted path, in g/cm^2.
A shower develops through grammage, not through metres, and air density falls by a third between 2000 m and 4500 m. A site search that compares candidates at different altitudes while measuring path length in metres is comparing unlike things: 20 km at 4000 m is about 1500 g/cm^2, the same 20 km at sea level about 2450 g/cm^2.
Integrating
rho0 exp(-(z0 + l sin(theta))/H) dlalong the slant path has a closed form, so no numerical integration is needed:X = rho0 exp(-z0/H) * H/sin(theta) * (1 - exp(-D sin(theta)/H)) / 1
with the horizontal-path limit
rho0 exp(-z0/H) * D / cos(theta)as theta -> 0.- Parameters:
- start_altitude_mfloat
Altitude of the near end of the path, in metres.
- elevation_degfloat
Elevation angle of the path, in degrees, positive upward.
- distance_mfloat
Ground distance covered, in metres. Zero or less returns zero.
- sea_level_densityfloat, optional
Density at sea level, in kg/m^3.
- scale_height_mfloat, optional
Density scale height, in metres.
- Returns:
- float
Atmospheric depth along the path, in g/cm^2.
Examples
>>> from oroscope import physics >>> horizontal = physics.slant_grammage_gcm2(4000.0, 0.0, 20000.0) >>> sea_level = physics.slant_grammage_gcm2(0.0, 0.0, 20000.0) >>> f"{horizontal:.0f} vs {sea_level:.0f} g/cm^2" '1522 vs 2450 g/cm^2'
- oroscope.physics.shower_maturity(grammage_gcm2: float | ndarray, x_max_gcm2: float = 700.0) float | ndarray[source]
Path grammage as a fraction of the depth of shower maximum.
Below 1 the shower is still developing when it arrives. What “above 1” means depends on what is being detected, and the two cases are not alike:
Radio. Emission comes from the region around shower maximum and then simply propagates; air is effectively transparent at 50-200 MHz. Being far beyond maximum costs nothing directly, so the criterion is a threshold, not a band. The real trade at greater distance is amplitude against footprint area, which belongs to the footprint term rather than here.
Particles. The charged-particle content peaks at maximum and dies away after, so a particle array such as TAMBO does want to sit near it, and there the criterion genuinely is a band.
This is one more reason criteria have to be per-channel rather than global.
- Parameters:
- grammage_gcm2float or array_like
Atmospheric depth traversed, in g/cm^2.
- x_max_gcm2float, optional
Depth of shower maximum, in g/cm^2.
- Returns:
- float or ndarray
Grammage as a fraction of shower maximum. Below 1 the shower is still growing.
See also
shower_size_fractionthe particle content itself, rather than this ratio.
Examples
>>> from oroscope import physics >>> round(physics.shower_maturity(750.0), 3) 1.071
- oroscope.physics.shower_maximum_gcm2(energy_pev: float | ndarray, x_max_ref_gcm2: float = 700.0, reference_energy_pev: float = 1000.0, elongation_rate: float = 55.0) ndarray[source]
Depth of shower maximum at a given primary energy.
X_max(E) = X_max(E_ref) + D * log10(E / E_ref)
Over TAMBO’s 3 PeV to 1 EeV this runs from about 560 to 700 g/cm^2, so the energy dependence is real but mild – the band below is set far more by how much of the profile is being accepted than by where its peak sits.
- Parameters:
- energy_pevfloat or array_like
Primary energy, in PeV.
- x_max_ref_gcm2float, optional
Depth of maximum at the reference energy, in g/cm^2.
- reference_energy_pevfloat, optional
Energy at which
x_max_ref_gcm2is quoted, in PeV.- elongation_ratefloat, optional
Deepening per decade of energy, in g/cm^2. About 55 for a hadronic cascade; a purely electromagnetic one is nearer the Heitler value of 85.
- Returns:
- ndarray
Depth of shower maximum, in g/cm^2.
Examples
>>> from oroscope import physics >>> [f"{float(physics.shower_maximum_gcm2(e)):.0f}" for e in (3.0, 1000.0)] ['561', '700']
- oroscope.physics.shower_size_fraction(grammage_gcm2: float | ndarray, x_max_gcm2: float, lambda_gcm2: float = 70.0) ndarray[source]
Charged-particle content at depth X, as a fraction of the content at maximum.
Gaisser-Hillas with the first interaction at X_0 = 0:
N(X)/N_max = (X / X_max)^(X_max/lambda) * exp((X_max - X) / lambda)
Zero at and below X = 0. This is what makes a particle array’s criterion a band rather than a threshold: the content rises steeply, peaks, and then dies.
- Parameters:
- grammage_gcm2float or array_like
Depth at which to evaluate the profile, in g/cm^2.
- x_max_gcm2float
Depth of shower maximum, in g/cm^2.
- lambda_gcm2float, optional
Gaisser-Hillas interaction length, in g/cm^2, setting how fast the profile rises and falls.
- Returns:
- ndarray
Particle content as a fraction of the content at maximum, in [0, 1].
Examples
>>> from oroscope import physics >>> round(float(physics.shower_size_fraction(700.0, 700.0)), 3) # at maximum 1.0 >>> round(float(physics.shower_size_fraction(172.0, 561.0)), 3) # a 2 km crossing 0.02
- oroscope.physics.grammage_band_from_energy(energy_min_pev: float, energy_max_pev: float, fraction: float = 0.1, lambda_gcm2: float = 70.0, samples: int = 4000, **x_max_kw: float) tuple[float, float][source]
Atmospheric-depth band over which a particle array still sees a usable shower.
A particle detector needs the cascade to have grown but not yet died, so the criterion is the depth range where the charged-particle content stays above
fractionof its own maximum. The band is taken across the requested energy range: the low edge at the lowest energy, whose maximum is shallowest, and the high edge at the highest, whose maximum is deepest.At TAMBO’s 3 PeV to 1 EeV and fraction 0.1 this gives roughly 235 to 1300 g/cm^2. That matters for siting: a canyon crossing supplies only what its own width of air contains – about 170 g/cm^2 across 2 km of Colca, and ~390 g/cm^2 across its full 4.5 km rim to rim – so the criterion selects the widest crossings, which is the physically right answer and not one the default (X_max, 4*X_max) band could express.
- Parameters:
- energy_min_pev, energy_max_pevfloat
Ends of the primary-energy range, in PeV.
- fractionfloat, optional
Fraction of peak particle content that still counts as a usable shower. A choice about detector capability rather than a property of the shower, and one of the parameters a result is most sensitive to.
- lambda_gcm2float, optional
Gaisser-Hillas interaction length, in g/cm^2.
- samplesint, optional
Points on the depth grid searched for the band edges.
- **x_max_kw
Passed through to
shower_maximum_gcm2().
- Returns:
- tuple of float
(low_gcm2, high_gcm2), the low edge taken at the lowest energy and the high edge at the highest, so the band spans the whole requested range.
Examples
>>> from oroscope import physics >>> lo, hi = physics.grammage_band_from_energy(3.0, 1000.0) >>> f"{lo:.0f} - {hi:.0f} g/cm^2" '236 - 1287 g/cm^2'
- oroscope.physics.earth_chord_m(elevation_deg: float, radius_m: float = 6371000.0) float[source]
Chord length through the Earth for a ray arriving from below the horizontal.
A ray making angle theta below the local horizontal cuts a chord of
2R sin(theta): zero along the tangent, a full diameter straight down. Directions at or above the horizontal return zero.This matters because it dwarfs local topography. At -1 degree the chord is about 220 km, at -3 degrees about 670 km, against the tens of km of mountain a DEM can see. The deepest part of even a 670 km chord lies only ~9 km below the surface, so it stays in the crust and a constant density is adequate.
- Parameters:
- elevation_degfloat
Arrival elevation angle, in degrees. Zero or above returns zero.
- radius_mfloat, optional
Earth radius, in metres. The true radius: the neutrino is not refracted.
- Returns:
- float
Chord length through the Earth, in metres.
Examples
>>> from oroscope import physics >>> f"{physics.earth_chord_m(-3.0) / 1000:.0f} km" '667 km'
- oroscope.physics.earth_chord_gcm2(elevation_deg: float, radius_m: float = 6371000.0, density_gcm3: float = 2.65) float[source]
Column depth of the Earth chord, in g/cm^2.
- Parameters:
- elevation_degfloat
Arrival elevation angle, in degrees.
- radius_mfloat, optional
Earth radius, in metres.
- density_gcm3float, optional
Crust density, in g/cm^3.
- Returns:
- float
Column depth of the chord, in g/cm^2.
Examples
>>> from oroscope import physics >>> f"{physics.earth_chord_gcm2(-3.0):.2e}" '1.77e+08'
- oroscope.physics.neutrino_survival(elevation_deg: float, interaction_length_gcm2: float, radius_m: float = 6371000.0, density_gcm3: float = 2.65, nc_regeneration: bool = False, spectral_index: float = 2.0) float[source]
Fraction of neutrinos surviving the Earth chord to reach the exit region.
exp(-X_chord / X_int). The interaction length is a parameter because it depends on the cross-section at the energy of interest; around an EeV it is of order 1e8 g/cm^2, which is the same order as the chord at -3 degrees, so the suppression across a +/-3 degree window is substantial rather than marginal.With
nc_regeneration=Truethe result is multiplied bync_regeneration_factor(), and clipped at 1 so that regeneration can offset absorption but never invent flux. Off by default, because it is an approximation and every published number here was computed without it.- Parameters:
- elevation_degfloat
Arrival elevation angle, in degrees.
- interaction_length_gcm2float
Neutrino interaction length at the energy of interest, in g/cm^2. Zero or less disables the attenuation and returns 1.
- radius_mfloat, optional
Earth radius, in metres.
- density_gcm3float, optional
Crust density, in g/cm^3.
- nc_regenerationbool, optional
Apply the leading-order neutral-current regeneration correction.
- spectral_indexfloat, optional
Power-law index used by that correction.
- Returns:
- float
Surviving fraction, in [0, 1].
Examples
>>> from oroscope import physics >>> lam = physics.neutrino_interaction_length_gcm2(1000.0) >>> round(physics.neutrino_survival(-1.0, lam), 3) 0.7
Regeneration lifts it, because absorption alone was overstating the suppression:
>>> round(physics.neutrino_survival(-1.0, lam, nc_regeneration=True), 3) 0.778
- oroscope.physics.muon_shielding_gcm2(thickness_km: float, density_gcm3: float = 2.65) float[source]
Column depth corresponding to a rock thickness, for muon rejection.
A mountain in the arrival direction is a muon filter: an air-shower muon from that direction would have to cross the whole thickness, which a few km of rock makes impossible. Anything detected from behind that much rock is therefore not a cosmic-ray muon, which is what lets a surface array claim neutrino purity.
Unlike the production-and-escape band, this is a floor: more rock is always better for background rejection, and only the signal side wants an upper limit.
4 km of standard rock is about 1.06e6 g/cm^2.
- Parameters:
- thickness_kmfloat
Rock thickness along the arrival direction, in km.
- density_gcm3float, optional
Rock density, in g/cm^3.
- Returns:
- float
Column depth, in g/cm^2.
Examples
>>> from oroscope import physics >>> f"{physics.muon_shielding_gcm2(physics.DEFAULT_MUON_SHIELDING_KM):.2e}" '1.06e+06'
Through the constant rather than a literal
4.0: the thickness is a sourced number, and this function was exported while the answer to “how thick?” was not.
- oroscope.physics.tau_decay_length_m(energy_pev: float, mass_gev: float = 1.77686, ctau_m: float = 8.703e-05) float[source]
Lorentz-boosted tau decay length,
(E/m) c*tau.The quantity that decides whether a tau decays inside a canyon crossing or flies through it: 147 m at 3 PeV against 49 km at 1 EeV, over a gap of a few km.
- Parameters:
- energy_pevfloat
Tau energy, in PeV.
- mass_gevfloat, optional
Tau mass, in GeV.
- ctau_mfloat, optional
Proper decay length
c * tau, in metres.
- Returns:
- float
Decay length in the laboratory frame, in metres.
Examples
>>> from oroscope import physics >>> [f"{physics.tau_decay_length_m(e):.0f}" for e in (3.0, 1000.0)] ['147', '48980']
- oroscope.physics.cc_cross_section_cm2(energy_pev: float | ndarray) float | ndarray[source]
Charged-current neutrino-nucleon cross-section.
A power-law fit to the standard parameterisations, good to tens of per cent over 1e8-1e10 GeV and an extrapolation above that, where no data constrain it.
- Parameters:
- energy_pevfloat or array_like
Neutrino energy, in PeV.
- Returns:
- float or ndarray
Cross-section, in cm^2.
Examples
>>> from oroscope import physics >>> f"{physics.cc_cross_section_cm2(1000.0):.2e}" '1.01e-32'
- oroscope.physics.neutrino_interaction_length_gcm2(energy_pev: float | ndarray) float | ndarray[source]
Column depth over which a neutrino interacts once,
1/(N_A sigma).Falls from about 3.8e8 g/cm^2 at 100 PeV to 7e7 at 10 EeV. Only charged-current attenuation is counted; neutral-current regeneration would soften it slightly.
- Parameters:
- energy_pevfloat or array_like
Neutrino energy, in PeV.
- Returns:
- float or ndarray
Interaction length, in g/cm^2.
Examples
>>> from oroscope import physics >>> f"{physics.neutrino_interaction_length_gcm2(100.0):.2e}" '3.76e+08'
- oroscope.physics.nc_regeneration_factor(chord_gcm2: float, cc_length_gcm2: float, spectral_index: float = 2.0, nc_to_cc: float = 0.42, inelasticity: float = 0.25) float[source]
Leading-order enhancement from neutral-current regeneration. An approximation.
A charged-current interaction removes a neutrino from the beam. A neutral-current one does not – it degrades the energy and the neutrino continues. Counting only CC absorption, as
neutrino_survivaldoes by default, therefore understates the flux arriving at a given energy: neutrinos that started higher up the spectrum scatter down into the band and partially refill it.The size of that refilling follows from the spectrum. For a power law
Phi(E) ~ E**-gamma, a neutrino observed atEafter one NC interaction of inelasticityybegan atE' = E/(1-y). The flux there is larger by(1-y)**gamma, and the JacobiandE'/dE = 1/(1-y)gives one more power, so each NC scatter contributes(1-y)**(gamma-1)relative to the unscattered flux. Withtau_nc = (chord/X_cc) * (sigma_NC/sigma_CC)NC interactions expected along the chord, the leading term is:1 + tau_nc * (1 - y)**(gamma - 1)
What this is not. It is the first term of a series, not a solution of the cascade equations, and it ignores the
nu_tau -> tau -> nu_tauchain that makes the Earth genuinely translucent to tau neutrinos at high energy. It is a correction of the right sign and roughly the right size, and it is capped below (seeneutrino_survival()) so it cannot manufacture more flux than arrived. Treat a result that depends strongly on it as a result that needs a real transport code.- Parameters:
- chord_gcm2float
Column depth along the chord, in g/cm^2.
- cc_length_gcm2float
Charged-current interaction length, in g/cm^2.
- spectral_indexfloat, optional
gammaof the assumed power-law flux. A steeper spectrum regenerates less: the neutrinos scattering down into the band come fromE/(1-y), above it, and a steeper spectrum has less flux there.- nc_to_ccfloat, optional
sigma_NC / sigma_CC.- inelasticityfloat, optional
Mean fraction of energy lost in one NC interaction.
- Returns:
- float
Multiplicative enhancement, at least 1.
Examples
A chord one CC interaction length deep, on an E^-2 spectrum:
>>> from oroscope import physics >>> round(physics.nc_regeneration_factor(1.0e8, 1.0e8), 3) 1.315
A steeper spectrum regenerates less, since less flux sits above the band:
>>> round(physics.nc_regeneration_factor(1.0e8, 1.0e8, spectral_index=2.7), 3) 1.258
A short chord regenerates nothing:
>>> round(physics.nc_regeneration_factor(0.0, 1.0e8), 3) 1.0
- oroscope.physics.tau_energy_loss_beta(energy_pev: float, reference: float | None = None, reference_energy_pev: float | None = None, index: float | None = None) float[source]
Energy-loss coefficient beta(E), rising with energy as photonuclear does.
An estimate, not a fit to published tables: see the module comments for how it was arrived at. It is the least certain number in this module, and it moves the production-and-escape optimum in proportion.
- Parameters:
- energy_pevfloat
Tau energy, in PeV.
- referencefloat, optional
Value of beta at
reference_energy_pev, in cm^2/g. Defaults to whateverset_tau_energy_loss()last established, and toBETA_REFERENCE_CM2Gif it has not been called.- reference_energy_pevfloat, optional
Energy at which
referenceapplies, in PeV.- indexfloat, optional
Power-law index of the energy dependence. Zero recovers a constant beta.
- Returns:
- float
Energy-loss coefficient, in cm^2/g.
Examples
>>> from oroscope import physics >>> f"{physics.tau_energy_loss_beta(100.0):.2e}" '3.79e-07'
An argument overrides the module setting for that one call:
>>> f"{physics.tau_energy_loss_beta(100.0, reference=1.0e-6, index=0.0):.2e}" '1.00e-06'
- oroscope.physics.tau_range_gcm2(energy_pev: float, beta_cm2g: float | None = None, density_gcm3: float = 2.65) float[source]
Column depth over which a tau’s survival probability falls to 1/e.
Decay and energy loss couple, because losing energy shortens the boosted decay length. With
E(X) = E0 exp(-beta X)the decay probability per unit depth isexp(beta X)/X_decay(E0), and integrating the survival givesS(X) = exp( -(X_loss/X_decay) (exp(X/X_loss) - 1) ), X_loss = 1/beta
so the 1/e point is
R = X_loss * ln(1 + X_decay/X_loss)
Note this grows logarithmically at high energy rather than saturating at
1/beta. An earlier version of this module combined the two lengths harmonically, which saturates and underestimates the range by a factor of 2 at an EeV and 4 at 10 EeV.- Parameters:
- energy_pevfloat
Tau energy on entering the rock, in PeV.
- beta_cm2gfloat, optional
Energy-loss coefficient, in cm^2/g. Defaults to
tau_energy_loss_beta()at this energy.- density_gcm3float, optional
Rock density, in g/cm^3.
- Returns:
- float
Column depth at which survival falls to 1/e, in g/cm^2.
Examples
>>> from oroscope import physics >>> f"{physics.tau_range_gcm2(1000.0) / physics.CRUST_DENSITY_GCM3 / 1e5:.1f} km" '13.7 km'
- oroscope.physics.tau_survival(depth_gcm2: float | ndarray, energy_pev: float, beta_cm2g: float | None = None, density_gcm3: float = 2.65) ndarray[source]
Probability a tau of the given energy crosses
depth_gcm2of rock without decaying.The double-exponential form above, which falls far more sharply than a simple exponential once the depth exceeds
1/beta: the tau is losing energy, so its decay length shrinks as it goes.- Parameters:
- depth_gcm2float or array_like
Rock traversed, in g/cm^2.
- energy_pevfloat
Tau energy on entering the rock, in PeV.
- beta_cm2gfloat, optional
Energy-loss coefficient, in cm^2/g.
- density_gcm3float, optional
Rock density, in g/cm^3.
- Returns:
- ndarray
Survival probability, in [0, 1].
Examples
>>> from oroscope import physics >>> round(float(physics.tau_survival(0.0, 1000.0)), 3) 1.0
- oroscope.physics.tau_exit_probability(column_depth_gcm2: float | ndarray, energy_pev: float, beta_cm2g: float | None = None, density_gcm3: float = 2.65, inelasticity: float = 0.2, samples: int = 2000) float | ndarray[source]
Relative probability that a traversing neutrino yields a tau that escapes.
The neutrino interacts at depth x with probability
exp(-x/lambda) dx/lambda; the tau, carrying(1 - y)of the energy, must then cross the remainingX - xand survive:P(X) = integral_0^X (dx/lambda) exp(-x/lambda) S(X - x)
Evaluated numerically, because
Sis a double exponential and the integral has no clean closed form. Relative, not absolute: normalisation needs the trigger response (seeaperture).- Parameters:
- column_depth_gcm2float or array_like
Rock along the arrival direction, in g/cm^2.
- energy_pevfloat
Neutrino energy, in PeV.
- beta_cm2gfloat, optional
Tau energy-loss coefficient, in cm^2/g.
- density_gcm3float, optional
Rock density, in g/cm^3.
- inelasticityfloat, optional
Mean inelasticity of the charged-current interaction; the tau carries away
1 - yof the neutrino energy.- samplesint, optional
Points used for the numerical integration over interaction depth, spaced logarithmically in the remaining depth
X - x.
- Returns:
- float or ndarray
Relative exit probability, matching the shape of
column_depth_gcm2.
Notes
The grid is in ``u = X - x``, not in ``x``, and that is the whole of the accuracy. Only interactions within a few tau ranges of the far surface contribute anything – everything produced deeper is absorbed – so the integrand is a spike against the far end of a range that can be five decades wide. Sampled uniformly in
x, as this was, the spacing outruns the spike and the trapezoid rule reports the area of something it never resolved. Measured at 3 PeV andX= 10^9 g/cm^2:grid
P(X)
uniform, 2000 pts
8.884e-05
uniform, 20,000
1.328e-05
uniform, 200,000
1.103e-05
uniform, 2,000,000
1.100e-05
log in u, 2000
1.1004e-05
So the old default was 8x the converged value, and the substitution reaches that value with a thousandth of the points. Worse than the magnitude, it inverted the trend. P(X) has a genuine maximum –
production_escape_optimum_gcm2()exists to find it, and dP/dX = (S(X) - P(X))/lambda is positive wherever S(X) exceeds P(X) – so it rises to that peak and falls beyond. At 3 PeV the peak sits near 3.1e5 g/cm^2. What the unresolved grid did was invent a second, spurious rise far past the peak, at the edge of the grid, where the converged curve is falling.The error was confined to low energy against deep rock – at 100 PeV and above the worst case over the same grid was 3%, and below 10^7 g/cm^2 it was exact everywhere – which is why nothing else showed it. But
depth_band_from_energy()takes its low edge at the lowest energy asked for, and TAMBO’s configured range starts at 3 PeV, so the band inherited the whole of it: (1.18e8, 2.89e8) over 3 PeV - 1 EeV where the converged answer is (2.17e4, 1.15e8). The published 1 EeV optimum of 5.7e6 g/cm^2 lies inside the second and 20x below the first. No published number moved: every config leavesdepth_band_gcm2null, so no search has ever called this (roadmap 6.44, 6.56).Examples
>>> from oroscope import physics >>> p = physics.tau_exit_probability(1.0e6, 1000.0) >>> 0.0 <= p <= 1.0 True
Converged where it used to be eight times too large:
>>> round(physics.tau_exit_probability(1.0e9, 3.0) * 1.0e5, 3) 1.1
and falling with depth, as more rock must:
>>> p = physics.tau_exit_probability([1.0e8, 3.0e8, 1.0e9, 3.0e9], 3.0) >>> bool((p[1:] < p[:-1]).all()) True
- oroscope.physics.set_tau_energy_loss(reference: float | None = None, reference_energy_pev: float | None = None, index: float | None = None) dict[source]
Adopts a different beta, in one place, for every function that uses it.
beta is the least certain number in this module – an estimate from mass scaling, in the range (0.4-1.0)e-6 cm^2/g – and the collaboration’s own value should replace it when there is one. Until now that meant editing the source, which is not something a user of an installed package can reasonably do, and which leaves no record of what was used.
This does not change any site-search result. beta enters tau range and survival – production and escape through rock – and the search does not model those: it uses the decay length
L = (E/m_tau) c*tau, which is kinematics and carries no beta. So this affectstau_range_gcm2(),tau_survival()andtau_exit_probability(), which the notebooks and the physics page use, and nothing in a search. See ROADMAP 6.38.- Parameters:
- referencefloat, optional
beta at
reference_energy_pev, in cm^2/g. Left unchanged when None.- reference_energy_pevfloat, optional
Energy at which
referenceapplies, in PeV.- indexfloat, optional
Power-law index of the energy dependence. Zero gives a constant beta.
- Returns:
- dict
The settings now in force, as
tau_energy_loss_settings()returns.
Examples
A collaboration value, adopted once:
>>> from oroscope import physics >>> _ = physics.set_tau_energy_loss(reference=0.8e-6, index=0.0) >>> f"{physics.tau_energy_loss_beta(100.0):.2e}" '8.00e-07'
A constant beta means the same value at every energy:
>>> f"{physics.tau_energy_loss_beta(10000.0):.2e}" '8.00e-07'
And back to the shipped estimate, which does rise with energy:
>>> _ = physics.restore_tau_energy_loss() >>> f"{physics.tau_energy_loss_beta(100.0):.2e}" '3.79e-07'
- oroscope.physics.restore_tau_energy_loss() dict[source]
Restores the shipped estimate, undoing
set_tau_energy_loss().- Returns:
- dict
The settings now in force.
Examples
>>> from oroscope import physics >>> physics.restore_tau_energy_loss() == { ... "reference": physics.BETA_REFERENCE_CM2G, ... "reference_energy_pev": physics.BETA_REFERENCE_ENERGY_PEV, ... "index": physics.BETA_ENERGY_INDEX} True
- oroscope.physics.tau_energy_loss_settings() dict[source]
The beta parameters currently in force.
- Returns:
- dict
{"reference", "reference_energy_pev", "index"}, in cm^2/g and PeV.
Examples
>>> from oroscope import physics >>> physics.tau_energy_loss_settings()["reference"] 6e-07
- oroscope.physics.production_escape_optimum_gcm2(energy_pev: float, beta_cm2g: float | None = None, density_gcm3: float = 2.65, inelasticity: float = 0.2, samples: int = 400) float[source]
Column depth maximising
tau_exit_probability().Found on a log grid, since the corrected exit probability has no closed-form peak. Rises with energy and then flattens – about 12 km of standard rock at 100 PeV, 20 km at 1 EeV, 23 km at 10 EeV – as the logarithmic growth of the tau range is tempered by beta rising.
- Parameters:
- energy_pevfloat
Neutrino energy, in PeV.
- beta_cm2gfloat, optional
Tau energy-loss coefficient, in cm^2/g.
- density_gcm3float, optional
Rock density, in g/cm^3.
- inelasticityfloat, optional
Mean charged-current inelasticity.
- samplesint, optional
Points on the log-spaced depth grid searched for the peak.
- Returns:
- float
Column depth maximising the exit probability, in g/cm^2.
Examples
>>> from oroscope import physics >>> km = physics.production_escape_optimum_gcm2(100.0) / physics.CRUST_DENSITY_GCM3 / 1e5 >>> 8.0 < km < 16.0 True
- oroscope.physics.depth_band_from_energy(energy_min_pev: float, energy_max_pev: float, fraction: float = 0.5, beta_cm2g: float | None = None, density_gcm3: float = 2.65, inelasticity: float = 0.2, samples: int = 400) tuple[float, float][source]
Column-depth band where the tau exit probability stays above
fractionof peak.Very wide – roughly 5e5 to 2.6e8 g/cm^2 at half maximum across 100 PeV to 10 EeV, some two and a half decades – which is itself the result: column depth is an intrinsically weak discriminant and the criterion should not pretend otherwise.
- Parameters:
- energy_min_pev, energy_max_pevfloat
Ends of the neutrino energy range, in PeV.
- fractionfloat, optional
Fraction of peak exit probability defining the band edges.
- beta_cm2gfloat, optional
Tau energy-loss coefficient, in cm^2/g.
- density_gcm3float, optional
Rock density, in g/cm^3.
- inelasticityfloat, optional
Mean charged-current inelasticity.
- samplesint, optional
Points on the log-spaced depth grid.
- Returns:
- tuple of float
(low_gcm2, high_gcm2), the low edge taken at the lowest energy and the high edge at the highest, so the band spans the whole requested range.
Examples
>>> from oroscope import physics >>> lo, hi = physics.depth_band_from_energy(100.0, 10000.0) >>> lo < hi True
- oroscope.physics.earth_absorption_cutoff_deg(energy_pev: float, fraction: float = 0.5, radius_m: float = 6371000.0, density_gcm3: float = 2.65, **kw: float) float | None[source]
Elevation below which the Earth chord itself exceeds the useful column depth.
The chord is
2R sin(theta), hundreds of km for degrees below the horizontal, so steep arrival directions carry far more matter than the optimum wants and the neutrino is absorbed before reaching the exit region. Setting the chord equal to the upper band edge gives the elevation at which acceptance has fallen tofractionof peak.The result narrows sharply with energy – about -4.5 degrees at 100 PeV, -2.1 at 1 EeV, -1.0 at 10 EeV – so the effective arrival window is not a fixed +/-3 degrees but an energy-dependent one whose lower edge climbs toward the horizon.
- Parameters:
- energy_pevfloat
Neutrino energy, in PeV.
- fractionfloat, optional
Fraction of peak acceptance defining the cut.
- radius_mfloat, optional
Earth radius, in metres.
- density_gcm3float, optional
Crust density, in g/cm^3.
- **kw
Passed through to
tau_exit_probability().
- Returns:
- float or None
A negative elevation angle in degrees, or
Nonewhen the cut lies below the horizon entirely.
Examples
>>> from oroscope import physics >>> cut = physics.earth_absorption_cutoff_deg(1000.0) >>> -6.0 < cut < 0.0 True
- oroscope.physics.spectrum_weighted_decay_probability(distance_m, energy_min_pev, energy_max_pev, spectral_index=2.0, shower_development_m=0.0, samples=96, index_samples=129, weight_by='flux', response=None)[source]
Probability the tau decays inside the usable gap, folded over a power-law spectrum.
A tau of energy \(E\) decays within a usable path \(u\) with probability \(1 - e^{-u/L(E)}\), and \(L = (E/m_\tau)c\tau\) runs over three decades across a single experiment’s reach. Evaluating that at one representative energy is therefore not an approximation but a choice of answer: measured on a real canyon search, the reported capacity ran from 10878 detector positions at 3 PeV to zero at 100 PeV. Weighting by the flux instead gives a number that is a property of the terrain and the spectrum rather than of the energy someone picked.
\[P(u) = \frac{\int E^{-\gamma}\left(1 - e^{-u/L(E)}\right)\,{\rm d}E} {\int E^{-\gamma}\,{\rm d}E}\]integrated on a log-spaced grid, since the range spans decades.
What weights the average is selectable. An event rate is \(\int \Phi(E) A(E) P(E)\,{\rm d}E\), and weighting by the flux alone is only one of three defensible choices:
"flux"\(w(E) = E^{-\gamma}\). The default, and what every published number here was computed with. Says: of the neutrinos that arrive, what fraction decays usefully?
"acceptance"\(w(E) = A(E)\), per unit energy. Says: over the energies this detector actually responds to, what fraction decays usefully?
This does not remove the spectral assumption; it replaces it. Weighting by \(A(E)\,{\rm d}E\) is weighting by \(A(E)\) against a flat differential flux, which is \(\gamma = 0\) – harder than any astrophysical spectrum, so the average leans on the top of the range rather than sitting free of it. Measured against TAMBO’s rising \(A(E)\) over 3 PeV - 1 EeV, 97.4% of the weight lands above 100 PeV, against 31.3% for
flux. That is a large part of why weighting by acceptance alone emptied a search (roadmap 6.42), alongside the shape of the inferred response itself. Read it as a different assumption, useful for asking what the instrument’s own band favours, and not as the assumption-free option."flux_times_acceptance"\(w(E) = E^{-\gamma} A(E)\), the event-rate integrand itself, and the right one when both the spectrum and a response table are trusted.
A(E)is not something this module can supply; pass a callable, most usefully one recovered from a published curve byaperture.infer_response(), which divides that curve by the geometric model and leaves everything else.Note a steep spectrum weights low energies heavily, where the tau decays readily – so a soft spectrum drives \(P\) toward 1 and the term stops discriminating. That is the physics rather than a defect, but it means the spectral index deserves the same scrutiny as any other assumption – including the \(\gamma = 0\) that
"acceptance"quietly substitutes for it.- Parameters:
- distance_mfloat or array_like
Distance from the exit point to the detector, in metres.
- energy_min_pev, energy_max_pevfloat
Ends of the tau energy range, in PeV.
- spectral_indexfloat, optional
\(\gamma\) in \({\rm d}N/{\rm d}E \propto E^{-\gamma}\).
- shower_development_mfloat, optional
Path the shower needs after the decay, in metres. Subtracted from the gap, so a target closer than this yields nothing usable.
- samplesint, optional
Points on the log-spaced energy grid.
- index_samplesint, optional
Points across the spectral-index range, when one is given. Generous by default because the index integral is folded into a weight vector computed once, so a fine grid costs nothing per candidate. Ignored for a single index.
- weight_by{‘flux’, ‘acceptance’, ‘flux_times_acceptance’}, optional
What weights the average. See the discussion above.
'acceptance'ignoresspectral_indexentirely, which is the point of it.- responsecallable, optional
A(E)in PeV, required by the two acceptance weightings. A response that is zero everywhere on the grid leaves nothing to average and raises.
- Returns:
- ndarray
Flux-weighted decay probability, in [0, 1], matching the shape of
distance_m.
- Raises:
- ValueError
If the energy range is inverted, or
spectral_indexis neither one value nor a pair.
See also
arrival_scan.decay_probabilitythe single-energy form, for one baseline window.
Examples
>>> from oroscope import physics >>> p = physics.spectrum_weighted_decay_probability(3000.0, 3.0, 1000.0) >>> round(float(p), 3) 0.954
A harder spectrum puts more weight at high energy, where the tau outruns the gap:
>>> soft = physics.spectrum_weighted_decay_probability(3000.0, 3.0, 1000.0, 2.7) >>> hard = physics.spectrum_weighted_decay_probability(3000.0, 3.0, 1000.0, 1.5) >>> bool(hard < soft) True
Or marginalise over the index rather than choosing one, which lands between the extremes it spans:
>>> spread = physics.spectrum_weighted_decay_probability( ... 3000.0, 3.0, 1000.0, (1.5, 2.7)) >>> bool(hard < spread < soft) True
- oroscope.physics.geomagnetic_latitude_deg(latitude_deg: float, longitude_deg: float, pole_lat_deg=80.7, pole_lon_deg=-72.7)[source]
Latitude in the centered-dipole frame.
- Parameters:
- latitude_deg, longitude_degfloat
Geographic coordinates of the site, in degrees.
- pole_lat_deg, pole_lon_degfloat, optional
Geographic coordinates of the north geomagnetic pole, in degrees.
- Returns:
- float
Magnetic latitude, in degrees.
Examples
>>> from oroscope import physics >>> round(physics.geomagnetic_latitude_deg(-16.4, -71.5), 1) -7.1
- oroscope.physics.centered_dipole_inclination(latitude_deg: float, longitude_deg: float, **kw: float) float[source]
Inclination from a centered dipole,
tan(I) = 2 tan(magnetic latitude).Useful when no IGRF lookup is available: it captures the dominant behaviour, that inclination passes through zero at the magnetic equator and steepens away from it. At Arequipa it gives about -14 degrees, the site being roughly 7 degrees south of the magnetic equator.
Note this approximation is only worth using for inclination. The dipole declination at Arequipa is about -0.2 degrees against an IGRF value of -6.9, so the non-dipole terms dominate there and a dipole declination would be misleading.
- Parameters:
- latitude_deg, longitude_degfloat
Geographic coordinates of the site, in degrees.
- **kw
Passed through to
geomagnetic_latitude_deg().
- Returns:
- float
Inclination in degrees, positive downward.
Examples
>>> from oroscope import physics >>> round(physics.centered_dipole_inclination(-16.4, -71.5), 1) -14.0
- oroscope.physics.default_field_for_site(latitude_deg: float, longitude_deg: float, declination_deg: float | None = None, inclination_deg: float | None = None) tuple[float, float][source]
Geomagnetic field for an arbitrary site, falling back sensibly.
Inclination is computed from the site’s own coordinates with the dipole model, so moving the search to another location gets the right inclination automatically – it is the quantity that varies most across Peru, from about -5 degrees near Lima to -14 near Arequipa.
Declination cannot be had that way: the dipole gives about -0.2 degrees at Arequipa against an IGRF -6.9, so non-dipole terms dominate. It therefore falls back to the Arequipa IGRF value, which is right for the prototype region and approximately right for the rest of southern Peru. Supply the IGRF declination for anywhere else.
- Parameters:
- latitude_deg, longitude_degfloat
Geographic coordinates of the site, in degrees.
- declination_degfloat, optional
Declination in degrees east of north. Supply the IGRF value for the site; the fallback is Arequipa’s.
- inclination_degfloat, optional
Inclination in degrees, positive downward. Derived from the site’s coordinates when omitted.
- Returns:
- tuple of float
(declination_deg, inclination_deg).
Examples
>>> from oroscope import physics >>> dec, inc = physics.default_field_for_site(-16.4, -71.5) >>> f"{dec:.1f} {inc:.1f}" '-6.9 -14.0'
- oroscope.physics.set_declination_model(model)[source]
Supplies a declination model, so declination can follow the site as inclination does.
Inclination is computed from the site’s coordinates with a centred dipole and is good enough. Declination is not: the dipole gives about -0.2 degrees at Arequipa against an IGRF -6.9, because the non-dipole terms dominate it. So declination has always fallen back to a single constant – Arequipa’s IGRF value – wherever the DEM happened to be, which is right for the prototype region and wrong anywhere else.
No IGRF model is shipped, deliberately. IGRF is a spherical-harmonic expansion with a couple of hundred coefficients per epoch, and coefficients typed from memory would produce declinations that look entirely plausible and are wrong – the exact failure this project keeps finding. What is provided instead is the socket: pass any callable
model(latitude_deg, longitude_deg) -> declination_deg, fromppigrf/pyIGRF, from a NOAA grid viadeclination_from_grid(), or from the collaboration’s own numbers.- Parameters:
- modelcallable or None
model(latitude_deg, longitude_deg)returning degrees east of north.Nonerestores the constant fallback.
- Returns:
- callable or None
The model now in force.
Examples
>>> from oroscope import physics >>> _ = physics.set_declination_model(lambda lat, lon: -6.0 + 0.1 * (lon + 71.5)) >>> dec, inc = physics.default_field_for_site(-16.4, -71.5) >>> f"{dec:.2f}" '-6.00'
It follows the site, which the constant never did:
>>> dec_east, _ = physics.default_field_for_site(-16.4, -66.5) >>> f"{dec_east:.2f}" '-5.50' >>> _ = physics.set_declination_model(None) >>> physics.default_field_for_site(-16.4, -66.5)[0] -6.9
- oroscope.physics.declination_model() object[source]
The declination model in force, or
Nonewhen the constant fallback is in use.Examples
>>> from oroscope import physics >>> physics.declination_model() is None True
- oroscope.physics.declination_from_grid(latitudes, longitudes, declinations)[source]
Builds a declination model by bilinear interpolation over a supplied grid.
The practical way to get real IGRF values in without a spherical-harmonic implementation: export a declination grid covering the DEM (NOAA’s geomagnetic calculator will produce one), and hand the three arrays here.
- Parameters:
- latitudesarray_like
Grid latitudes, ascending, in degrees.
- longitudesarray_like
Grid longitudes, ascending, in degrees.
- declinationsarray_like
(len(latitudes), len(longitudes))of declination in degrees east of north.
- Returns:
- callable
model(latitude_deg, longitude_deg) -> float, suitable forset_declination_model(). Queries outside the grid are clamped to its edge, which is the right behaviour for a DEM that pokes slightly past its corners and a poor one for a grid that does not cover the region at all – so cover it.
Examples
>>> import numpy as np >>> from oroscope import physics >>> lats, lons = np.array([-18.0, -14.0]), np.array([-74.0, -70.0]) >>> dec = np.array([[-5.0, -7.0], [-6.0, -8.0]]) >>> model = physics.declination_from_grid(lats, lons, dec) >>> f"{model(-18.0, -74.0):.2f}" '-5.00'
Halfway in both directions is the average of the four corners:
>>> f"{model(-16.0, -72.0):.2f}" '-6.50'
- oroscope.physics.geomagnetic_unit_vector(declination_deg: float, inclination_deg: float) tuple[float, float, float][source]
Unit geomagnetic field in local East-North-Up coordinates.
Declination is measured from geographic north, positive eastward; inclination is positive downward, so it enters the Up component with a minus sign.
- Parameters:
- declination_degfloat
Declination, in degrees east of north.
- inclination_degfloat
Inclination, in degrees, positive downward.
- Returns:
- tuple of float
(east, north, up)components of the unit field vector.
Examples
>>> from oroscope import physics >>> e, n, u = physics.geomagnetic_unit_vector(0.0, 0.0) >>> f"{e:.1f} {n:.1f} {u:.1f}" # due north, horizontal '0.0 1.0 -0.0'
- oroscope.physics.geomagnetic_sin_alpha(azimuth_deg: float, elevation_deg: float, field_unit_vector: tuple[float, float, float]) float[source]
sin(alpha)between a shower axis and the geomagnetic field.Radio emission from an air shower is dominantly geomagnetic, with amplitude proportional to
|v x B|, so a shower travelling along the field radiates very little of it. Peru lies near the magnetic equator, where the field is close to horizontal and roughly northward: north-south showers are therefore strongly suppressed and east-west ones near maximal. The azimuth of a target matters, not merely whether one exists.The sign of the axis is irrelevant –
|(-v) x B| = |v x B|– so the same value applies whether the direction is taken as arrival or propagation.- Parameters:
- azimuth_degfloat
Shower azimuth, in degrees clockwise from north.
- elevation_degfloat
Shower elevation angle, in degrees.
- field_unit_vectortuple of float
Field direction as
(east, north, up), fromgeomagnetic_unit_vector().
- Returns:
- float
sin(alpha), in [0, 1]. Zero for a shower along the field.
Examples
>>> from oroscope import physics >>> B = physics.geomagnetic_unit_vector(0.0, 0.0) # horizontal, northward >>> round(physics.geomagnetic_sin_alpha(0.0, 0.0, B), 3) # along the field 0.0 >>> round(physics.geomagnetic_sin_alpha(90.0, 0.0, B), 3) # across it 1.0
- oroscope.physics.refractivity(altitude_m: float, sea_level_value: float = 0.00029, scale_height_m: float = 8400.0) float[source]
n - 1at altitude, falling with density.- Parameters:
- altitude_mfloat
Altitude above sea level, in metres.
- sea_level_valuefloat, optional
Refractivity at sea level.
- scale_height_mfloat, optional
Density scale height, in metres.
- Returns:
- float
Refractivity
n - 1.
Examples
>>> from oroscope import physics >>> f"{physics.refractivity(4000.0):.2e}" '1.80e-04'
- oroscope.physics.cherenkov_angle_rad(altitude_m: float, **kw: float) float[source]
Cherenkov angle in air,
sqrt(2(n-1))for small angles.About 1.4 degrees at sea level and 1.1 degrees at 4000 m: the cone narrows with altitude because the air is thinner.
- Parameters:
- altitude_mfloat
Altitude of the emission point, in metres.
- **kw
Passed through to
refractivity().
- Returns:
- float
Cherenkov angle, in radians.
Examples
>>> import math >>> from oroscope import physics >>> f"{math.degrees(physics.cherenkov_angle_rad(4000.0)):.2f} deg" '1.09 deg'
- oroscope.physics.cherenkov_footprint_radius_m(altitude_m: float, distance_m: float, **kw: float) float[source]
Radius of the radio footprint on the ground,
D * theta_C.The consequence for layout is counter-intuitive: a higher site has a smaller footprint, so it needs a denser array for the same trigger efficiency. A 1 km grid under-samples a footprint of a few hundred metres either way, which is why counted antennas are a cost proxy rather than an effective area.
- Parameters:
- altitude_mfloat
Altitude of the emission point, in metres.
- distance_mfloat
Distance from emission point to the ground, in metres.
- **kw
Passed through to
refractivity().
- Returns:
- float
Footprint radius, in metres.
Examples
>>> from oroscope import physics >>> f"{physics.cherenkov_footprint_radius_m(4000.0, 20000.0):.0f} m" '380 m'
- oroscope.physics.footprint_sampling(spacing_m: float, altitude_m: float, distance_m: float, **kw: float) float[source]
Antennas per footprint diameter:
2 r / spacing.Below 1 the array does not resolve the footprint and triggering relies on a single antenna happening to fall inside it.
- Parameters:
- spacing_mfloat
Detector spacing, in metres. Zero or less returns 0.
- altitude_mfloat
Altitude of the emission point, in metres.
- distance_mfloat
Distance from emission point to the ground, in metres.
- **kw
Passed through to
refractivity().
- Returns:
- float
Antennas spanning one footprint diameter.
Examples
>>> from oroscope import physics >>> round(physics.footprint_sampling(1000.0, 4000.0, 20000.0), 2) 0.76
The arrival scan
The scan kernel: profile walking, column depth, Fresnel clearance and radio-noise line-of-sight. Compiled with Numba; see The physics for what it computes.
Arrival-direction scanning: what a candidate site can actually see, and what lies behind it.
The question a site search must answer is not “is there a tall mountain out there” but “from which arrival directions does a backward ray from this pixel enter rock, and how much rock does it cross”. This module answers that directly.
Tracing backward from a candidate along an arrival direction (azimuth phi, elevation angle theta measured from horizontal):
rays above the local horizon escape to the sky and see no matter;
rays below it strike terrain. That first intersection is the tau exit point, the distance to it is the decay baseline, and the path length beyond it that runs under the surface is the column depth.
Different experiments accept different arrival directions, so the elevation window is
a parameter rather than a constant. GRAND accepts neutrinos within roughly +/-3 deg of
the horizon and cosmic rays from above it; TAMBO looks across a canyon. The cosmic-ray
case inverts the test – terrain in the accepted directions is an obstruction rather
than a target – which require_terrain=False expresses without a second code path.
Algorithm
For a fixed azimuth, one walk outward yields every elevation bin at once. Writing the elevation angle of the terrain at ground distance d as
theta_terrain(d) = atan( (z(d) - d^2/2R - z0) / d )
then a ray at angle theta first meets terrain at the smallest d where theta_terrain(d) >= theta. Since the running maximum of theta_terrain only increases, each new maximum claims a contiguous band of elevation bins, so first-intersection distances for all bins are filled in a single pass.
Column depth follows from the same samples: the ray at angle theta is underground wherever theta_terrain(d) > theta, so binning theta_terrain and taking an inclusive suffix sum gives the underground path length for every bin at once. Rays crossing several ridges accumulate all of the rock they traverse, not just the first chord.
This costs one profile walk per (candidate, azimuth) regardless of how finely the elevation window is sampled.
- oroscope.arrival_scan.scan(candidates, elevation, map_grid, *, elev_min_deg=-3.0, elev_max_deg=3.0, n_elev_bins=12, n_azimuths=9, half_width_deg=60.0, use_aspect=True, step_m=None, max_range_m=80000.0, min_dist_km=0.0, max_dist_km=80.0, min_depth_gcm2=0.0, require_terrain=True, min_target_slope_deg=None, max_target_slope_deg=None, rock_density=2650.0, earth_radius_m=6371000.0, radio_earth_radius_m=8500000.0, frequency_mhz=None, shower_offset_m=3000.0, antenna_height_m=2.0, near_field_m=500.0, bilinear=True, geomag_declination_deg=None, geomag_inclination_deg=None)[source]
Convenience wrapper over
scan_candidates()with defaults for GRAND neutrinos.Sampling defaults to one DEM pixel, since a coarser step can miss a ridge entirely.
- Parameters:
- candidatesndarray
(N, 3)array of[row, col, aspect_deg], as produced by the topographic screen.- elevationndarray
The DEM, as a 2-D array. Converted to float32 if it is neither float32 nor float64.
- map_gridMapGrid
Angular and metric pixel sizes of the DEM.
- elev_min_deg, elev_max_degfloat, optional
Edges of the accepted arrival window, in degrees.
- n_elev_binsint, optional
Bins across that window. Nearly free: one walk serves every bin, so cost scales with azimuths rather than with this.
- n_azimuthsint, optional
Azimuths scanned per candidate. This is what sets the cost.
- half_width_degfloat, optional
Half-width of a forward arc about each candidate’s aspect.
Nonesweeps the full circle.- use_aspectbool, optional
Treat
half_width_degoffsets as relative to each candidate’s aspect rather than as absolute bearings.- step_mfloat, optional
Sampling step along the profile, in metres. Defaults to one DEM pixel, since a coarser step can miss a ridge entirely.
- max_range_mfloat, optional
How far to walk, in metres.
- min_dist_km, max_dist_kmfloat, optional
Accepted range to the first intersection – the decay-baseline window.
- min_depth_gcm2float, optional
Column depth a direction must have to count.
- require_terrainbool, optional
Trueselects directions striking rock (neutrino channels);Falseselects directions escaping to the sky (cosmic-ray channels), where terrain is an obstruction and the depth and distance criteria do not apply.- min_target_slope_deg, max_target_slope_degfloat, optional
Bounds on the struck terrain’s slope along the arrival azimuth. Unset by default, which asks only that rock is present – true almost everywhere in mountainous terrain.
- rock_densityfloat, optional
Density used to turn path length into column depth, in kg/m^3.
- earth_radius_mfloat, optional
True Earth radius, for the particle geometry.
- radio_earth_radius_mfloat, optional
Inflated radius for the refracted radio path. Used only by the Fresnel term.
- frequency_mhzfloat, optional
Radio band for the Fresnel clearance measurement.
Noneskips that pass entirely.- shower_offset_mfloat, optional
Path the shower needs after the tau decays, in metres. The far endpoint of the Fresnel measurement.
- antenna_height_mfloat, optional
Height of the receiver above ground, in metres. Without it every path scores near zero, since a ground-level receiver always has terrain in its own first Fresnel zone.
- near_field_mfloat, optional
Stretch of path skipped by the Fresnel measurement, in metres.
- bilinearbool, optional
Interpolate the terrain profile between pixel centres. Costs about 1.44x and removes an asymmetric half-pixel bias.
- geomag_declination_deg, geomag_inclination_degfloat, optional
Field direction. Both must be given for geomagnetic weighting to apply; guessing a field would be worse than declining to weight at all.
- Returns:
- dict of ndarray
One entry per candidate for each of:
cells,solid_angle_sr,mean_distance_m,max_depth_gcm2,mean_depth_gcm2,horizon_deg,best_clearance_ratio,geomag_solid_angle_sr,path_grammage_gcm2,earth_chord_gcm2andtarget_slope_deg.
See also
scoring.score_candidatesturns these observables into a comparable score.
- oroscope.arrival_scan.rfi_exposure(candidates: ndarray, elevation: ndarray, map_grid, zones_rowcol_weight, step_m: float | None = None, earth_radius_m: float = 8500000.0) ndarray[source]
Line-of-sight-weighted radio noise exposure for each candidate.
- Parameters:
- candidatesndarray
(N, 3)array of[row, col, aspect_deg].- elevationndarray
The DEM.
- map_gridMapGrid
Angular and metric pixel sizes.
- zones_rowcol_weightiterable
Noise sources as
(row, col, weight)in pixel coordinates; weight is normally the zone’s radius or a population proxy.- step_mfloat, optional
Sampling step along the sight line, in metres. Defaults to one pixel.
- earth_radius_mfloat, optional
Radius for the curvature drop. The radio one, since this is a radio path.
- Returns:
- ndarray
Exposure in weight per metre squared, one entry per candidate; smaller is quieter. Sources hidden behind terrain contribute nothing, which a plain distance-based exclusion zone cannot express.
- oroscope.arrival_scan.earth_radius_for_k(k_factor: float) float[source]
Effective Earth radius for a refraction k-factor.
k = 1 is true geometry; k = 4/3 is the standard radio convention and gives the 8500 km the searcher has always used. The choice is not negligible: over an 80 km path the apparent drop is 376 m at k = 4/3 against 502 m at k = 1, a difference comparable to the Fresnel clearance itself.
- Parameters:
- k_factorfloat
Refraction factor. 1 is true geometry; 4/3 is the standard radio convention.
- Returns:
- float
Effective Earth radius, in metres.
Examples
>>> from oroscope import arrival_scan >>> f"{arrival_scan.earth_radius_for_k(4/3) / 1e3:.0f} km" '8495 km'
- oroscope.arrival_scan.azimuth_fan(n_azimuths: int, half_width_deg: float | None = None) ndarray[source]
Azimuths to scan, as offsets from each candidate’s aspect.
half_width_degrestricts the fan to a forward arc, which is what a slope-mounted array sees; leave it None for a full 360 degree sweep, appropriate when the array orientation does not constrain the acceptance.- Parameters:
- n_azimuthsint
Number of azimuths to scan. This is what sets the cost of a search: one profile walk per (candidate, azimuth), with the elevation binning nearly free.
- half_width_degfloat, optional
Half-width of a forward arc about the aspect, in degrees.
Nonegives a full sweep.
- Returns:
- ndarray
Azimuth offsets, in degrees.
Examples
Samples sit at cell centres, so each stands for the same arc and the arcs tile the fan exactly. The wedge used to be endpoint-inclusive –
linspace(-hw, hw, n)– which places two samples on the fan’s edges and gives them the same weight as the interior ones. The solid angle then only came out right when every azimuth accepted: forazimuth_fan(9, 60)the true arcs are 7.5, 15x7, 7.5 degrees against a uniform 13.333, so a candidate open only at the two edges reported 1.78x the sky it saw, and one open everywhere but the edges reported 0.89x. Centring the samples makesspan / nthe exact arc for every one of them, which is the same convention the full sweep already used.With an odd
n_azimuthsthe centre sample still lands exactly on the aspect.>>> from oroscope import arrival_scan >>> arrival_scan.azimuth_fan(4, None) array([ 0., 90., 180., 270.]) >>> arrival_scan.azimuth_fan(3, 60.0) array([-40., 0., 40.])
- oroscope.arrival_scan.balanced_order(n_candidates: int, n_threads: int, block: int = 256) ndarray | None[source]
Candidate ordering that balances thread load without destroying locality.
Numba’s
prangeschedules statically, giving each thread one contiguous slice of the index range. Candidates leave the topographic screen in spatial order, so that slice is a contiguous patch of map – and walk cost varies enormously across the map, since rays near an edge terminate early while interior ones run the full range. The result is that some threads finish long before others: measured scaling was 2.4x on 12 cores against 4-5x for randomly scattered candidates.Shuffling fixes the balance but destroys cache locality, and measured barely better overall. Dealing blocks of neighbouring candidates round-robin keeps locality inside a block while spreading each thread’s slice across the whole map.
- Parameters:
- n_candidatesint
Number of candidates to be scanned.
- n_threadsint
Threads the scan will run on.
- blockint, optional
Candidates per block. Large enough to keep locality inside a block, small enough that dealing them spreads each thread across the map.
- Returns:
- ndarray or None
An index array, or
Nonewhen reordering cannot help – one thread, or too few candidates for the deal to be worth its own cost.
Examples
>>> from oroscope import arrival_scan >>> arrival_scan.balanced_order(100, 1) is None # nothing to balance True >>> order = arrival_scan.balanced_order(20000, 8) >>> sorted(order.tolist()) == list(range(20000)) # a permutation, nothing lost True
- oroscope.arrival_scan.tau_decay_length_m(energy_pev: float) float[source]
Lorentz-boosted tau decay length,
(E/m) * c*tau.This is what sets the scale of the useful detector-to-exit-point distance, and it is exactly analytic — no simulation input required. Worth noting that it reproduces the published numbers on both sides: 1-100 PeV gives 49 m to 4.9 km, matching TAMBO’s quoted 50 m - 5 km range, while the searcher’s inherited 10-80 km GRAND window corresponds to 0.2-1.6 EeV.
- Parameters:
- energy_pevfloat
Tau energy, in PeV.
- Returns:
- float
Decay length in the laboratory frame, in metres.
Examples
>>> from oroscope import arrival_scan >>> f"{arrival_scan.tau_decay_length_m(1.0):.0f} m" '49 m'
- oroscope.arrival_scan.energy_pev_for_decay_length(distance_m: float) float[source]
Inverse of
tau_decay_length_m(), for reporting what a distance implies.- Parameters:
- distance_mfloat
A decay length, in metres.
- Returns:
- float
The tau energy having that decay length, in PeV.
Examples
>>> from oroscope import arrival_scan >>> round(arrival_scan.energy_pev_for_decay_length(49000.0)) 1000
- oroscope.arrival_scan.decay_probability(min_dist_m: float, max_dist_m: float, energy_pev: float) float[source]
Probability the tau decays inside the accepted baseline window.
exp(-d_min/L) - exp(-d_max/L). One of the few factors that needs no acceptance table, and the one that couples most strongly to site geometry.- Parameters:
- min_dist_m, max_dist_mfloat
Ends of the accepted baseline window, in metres.
- energy_pevfloat
Tau energy, in PeV.
- Returns:
- float
Probability of decaying inside the window, in [0, 1].
Examples
>>> from oroscope import arrival_scan >>> round(arrival_scan.decay_probability(0.0, 3000.0, 3.0), 3) 1.0 >>> round(arrival_scan.decay_probability(0.0, 3000.0, 1000.0), 3) 0.059
- oroscope.arrival_scan.distance_window_from_energy(energy_min_pev: float, energy_max_pev: float, shower_development_m: float = 3000.0) tuple[float, float][source]
A decay-baseline window implied by an energy range.
The tau must decay before reaching the detector and the shower then needs room to develop, so the window runs from about one decay length at the low end to one at the high end plus the shower length. Ref. [2] quotes 3-10 km of shower development.
This is a stated convention rather than a derivation: it fixes the scale correctly, but the useful window also depends on acceptance details this tool does not model. Callers can always set the distances directly.
- Parameters:
- energy_min_pev, energy_max_pevfloat
Ends of the tau energy range, in PeV.
- shower_development_mfloat, optional
Path the shower needs after the tau decays, in metres.
- Returns:
- tuple of float
(min_dist_m, max_dist_m).
Examples
>>> from oroscope import arrival_scan >>> lo, hi = arrival_scan.distance_window_from_energy(1.0, 100.0) >>> f"{lo:.0f} m to {hi / 1000:.1f} km" '49 m to 7.9 km'
Scoring
Score shapes — band, saturating, ramp — and their composition into a single figure of
merit. Read the warning in Assumptions and limitations about thresholding a product before
choosing min_score.
Scoring: turning geometric observables into comparable site quality.
Every criterion returns a value in [0, 1] with a documented shape, so criteria can be combined and so a site’s weakness can be attributed to a named component rather than disappearing into a single opaque number.
Why scores rather than cuts. Measurement on real terrain showed the binary geometric test carries almost no discriminating power: in the Andes the whole of a +/-3 degree arrival window sits below the local horizon, so nearly every direction strikes rock and a hit/no-hit criterion selects most of the map. What separates sites is how much rock, at what distance, over how much solid angle – all continuous quantities.
What these scores are and are not. They rank sites against each other for one experiment and energy band. They are not apertures. Any factor that is energy-dependent but site-independent cancels in a ranking, which is precisely why useful relative scoring is possible without the differential acceptance table that absolute apertures would need (see the roadmap, section 4.10).
- oroscope.scoring.band_score(x: float | ndarray, lo: float, hi: float, soft_lo: float | None = None, soft_hi: float | None = None) ndarray[source]
A plateau of 1 between
loandhi, falling linearly to 0 outside it.The shape criteria of this kind want: acceptable over a range, degrading either side, rather than a cliff at an arbitrary threshold.
soft_lo/soft_hiset the width of the falling flanks and default to a quarter of the band width, so a value must be well outside the band before it scores zero.Column depth is the motivating case: the tau must be produced, which needs rock, and must escape, which limits how much – so its score is a band with an optimum, not a floor.
- Parameters:
- xfloat or array_like
Value or values to score.
- lo, hifloat
Edges of the plateau. Swapped automatically if given the wrong way round.
- soft_lo, soft_hifloat, optional
Widths of the falling flanks below
loand abovehi. Default to a quarter of the band width each. Zero gives a hard cliff.
- Returns:
- ndarray
Scores in [0, 1].
Examples
>>> from oroscope import scoring >>> float(scoring.band_score(5.0, 0.0, 10.0)) # inside the plateau 1.0 >>> float(scoring.band_score(-5.0, 0.0, 10.0)) # well below it 0.0
- oroscope.scoring.saturating_score(x: float | ndarray, half_value: float) ndarray[source]
x / (x + half_value): rises from 0, reaching 0.5 athalf_value.For quantities where more is better with diminishing returns and no natural maximum – accepted solid angle being the case in hand.
- Parameters:
- xfloat or array_like
Value or values to score. Negative values are clipped to zero.
- half_valuefloat
Value scoring 0.5. Choosing it far below the range actually observed saturates the term so that it stops discriminating, which is a real failure mode: a GRAND-scale value flattens completely against a canyon, which sees several times the sky. Note this function takes a bare number and does not care what it measures –
score_candidates()feeds it the fraction of available sky for exactly that reason, since an absolute in steradians silently encodes the azimuth fan and the arrival window.
- Returns:
- ndarray
Scores in [0, 1).
Examples
>>> from oroscope import scoring >>> float(scoring.saturating_score(0.05, 0.05)) 0.5 >>> round(float(scoring.saturating_score(0.7, 0.05)), 3) # saturated 0.933
- oroscope.scoring.ramp_score(x: float | ndarray, zero_at: float, one_at: float) ndarray[source]
Linear ramp from 0 at
zero_atto 1 atone_at; either order.- Parameters:
- xfloat or array_like
Value or values to score.
- zero_atfloat
Value scoring 0.
- one_atfloat
Value scoring 1. May be below
zero_at, giving a falling ramp.
- Returns:
- ndarray
Scores in [0, 1].
Examples
>>> from oroscope import scoring >>> [float(scoring.ramp_score(v, 0.0, 10.0)) for v in (-1.0, 5.0, 99.0)] [0.0, 0.5, 1.0]
- oroscope.scoring.compose(components: dict[str, ndarray], mode: str = 'product', weights: dict[str, float] | None = None) ndarray[source]
Combines named component scores into one value in [0, 1].
- Parameters:
- componentsdict
Component name to array of scores in [0, 1].
- mode{‘product’, ‘mean’, ‘min’}, optional
How to combine them.
productis unforgiving – one bad component sinks the site –meanlets a strong component compensate, andminreports the weakest link.- weightsdict, optional
Per-component weights, used by
productas exponents and bymeanas linear weights. A weight of 0 excludes a component, in every mode.minhas no meaningful notion of a relative weight – the smallest component is the smallest however it is scaled – so 0 is the only value it reads.
- Returns:
- ndarray
The composed score, in [0, 1].
- Raises:
- ValueError
If no components are given, the mode is unknown, a weight is negative, the weights for
meando not sum to a positive value, or every component has been excluded by a zero weight.
- Warns:
- UserWarning
When a weight names a component this composition does not contain. The name is a real one – a typo is rejected earlier, by
parse_score_weights()– so this means the component is switched off in this run and the weight does nothing.
Notes
A product of several components each in [0, 1] concentrates near zero, so a threshold on the result sits on a cliff. Measured on a real search, a score cut of 0.0, 0.35 and 0.5 gave 65268, 10437 and 0 detector positions. Prefer ranking sites over thresholding a product.
A weight naming a component that is not here used to be dropped in silence, by an
if n in wfilter with nothing behind it. Combined with an unvalidated spelling at the command line, that made--score_weights geomag=0– forgeomagnetic– a request the tool accepted, ignored, and never mentioned again: the component the user had switched off ran at full weight and every number in the run moved.Examples
>>> import numpy as np >>> from oroscope import scoring >>> parts = {"a": np.array([0.5]), "b": np.array([0.5])} >>> float(scoring.compose(parts, "product")[0]) 0.25 >>> float(scoring.compose(parts, "mean")[0]) 0.5
A zero weight excludes a component under
minas it does underproduct:>>> float(scoring.compose(parts, "min", {"a": 0.0})[0]) 0.5
- oroscope.scoring.score_candidates(observables: dict[str, ndarray], config: dict | None = None, distance_window_m: tuple[float, float] | None = None) tuple[ndarray, dict[str, ndarray]][source]
Scores candidates from their scan observables.
Components:
depthband on column depth: enough rock to interact, not so much thatthe tau cannot escape;
distanceband on the exit-point distance, defaulting to the configureddecay-baseline window;
solid_anglesaturating in accepted solid angle, since more acceptance isbetter with diminishing returns;
decayprobability the tau decays in the gap with room left for a shower.Present when either an energy range (
decay_energy_min_pevanddecay_energy_max_pev, folded over the spectrum – preferred) or a singledecay_energy_pevis supplied;
clearancepresent only when a Fresnel frequency was configured.
- Parameters:
- observablesdict
Per-candidate arrays from
arrival_scan.scan().cells,solid_angle_sr,mean_distance_mandmax_depth_gcm2are required; the rest enable their components when present.- configdict, optional
Overrides for
DEFAULT_SCORE_CONFIG.Nonevalues are ignored, so a sparse configuration leaves the remaining defaults alone.- distance_window_mtuple of float, optional
Fallback distance band, used when the configuration does not set one. Normally the decay-baseline window the scan was run with.
- Returns:
- totalndarray
The composed score per candidate. Zero wherever no direction was accepted, whatever the components say.
- componentsdict
The individual component scores, by name, so a site’s weakness can be attributed rather than disappearing into one number.
Examples
>>> import numpy as np >>> from oroscope import scoring >>> obs = {"cells": np.array([4, 0]), ... "solid_angle_sr": np.array([0.4, 0.4]), ... "mean_distance_m": np.array([2.0e4, 2.0e4]), ... "max_depth_gcm2": np.array([1.0e6, 1.0e6])} >>> total, parts = scoring.score_candidates(obs) >>> float(total[1]) # no accepted direction scores zero 0.0 >>> sorted(parts) ['depth', 'solid_angle']
- oroscope.scoring.summarize_scores(scores: ndarray, components: dict[str, ndarray]) dict[source]
Distribution summary of a score set, for reporting and per-site records.
Storing a distribution rather than a single number is deliberate: it lets a site’s quality be re-examined later without re-running the terrain analysis.
- Parameters:
- scoresndarray
Composed scores, one per candidate.
- componentsdict
The per-component scores behind them, from
score_candidates().
- Returns:
- dict
Count, mean, median and 90th percentile of the total, and the mean of each named component so a weak site can be attributed rather than merely ranked.
Aperture
Aperture estimate, tabulated response, and inference of a response curve from a published one.
Aperture estimation, and validation against what is independently known.
The tool’s own geometry gives a site’s usable area and the solid angle over which it sees rock. Turning that into an aperture in m^2 sr needs a detection response, and the part of that response which depends on tau production and escape is exactly what no available table supplies (see the roadmap, section 4.10). This module therefore separates cleanly into:
the geometric aperture, computed here and fully determined by terrain,
the analytic decay factor, also computed here and free of free parameters,
a pluggable response, defaulting to unity and replaceable by a table.
Under that split the absolute normalisation is unknown but the shape in energy and the ranking between sites are not. Both are testable, and are tested.
On validating against published apertures. Ref. [1] Fig. 25 and ref. [2] Fig. 3 are integral quantities over a whole array, all geometries and one site, so they cannot be applied per pixel. They can anchor the normalisation once supplied as data. Reading numbers off a published figure by eye is not a measurement, so this module provides the machinery to compare against a supplied curve rather than a transcription of one. What is validated here are the physical invariants the estimate must satisfy regardless of normalisation.
- oroscope.aperture.unit_response(energy_pev: float | ndarray) ndarray[source]
Default detection response: energy-independent and equal to one.
A placeholder that makes the normalisation explicit rather than hidden. Replace it with a
TabulatedResponseonce a real acceptance table is available.- Parameters:
- energy_pevfloat or array_like
Energies at which to evaluate the response, in PeV.
- Returns:
- ndarray
Ones, matching the shape of the input.
Examples
>>> from oroscope import aperture >>> aperture.unit_response([1.0, 10.0]).tolist() [1.0, 1.0]
- class oroscope.aperture.TabulatedResponse(energy_pev, response)[source]
Detection response interpolated from a supplied table.
Log-log interpolation, since both response and energy span decades. Supply a two-column CSV of energy in PeV against relative response, or the arrays directly.
Outside the tabulated range the response is returned as zero rather than extrapolated: beyond the table the response is unknown, and a silent extrapolation over decades of energy would be an invention.
- Parameters:
- energy_pevarray_like
Tabulated energies, in PeV. Sorted internally, so any order will do.
- responsearray_like
Relative response at each energy, same shape.
- Raises:
- ValueError
If the two are not matching one-dimensional arrays.
Examples
>>> from oroscope import aperture >>> r = aperture.TabulatedResponse([1.0, 10.0, 100.0], [0.1, 1.0, 0.5]) >>> round(float(r(10.0)), 3) 1.0 >>> float(r(1000.0)) # outside the table: unknown, not extrapolated 0.0
- classmethod from_csv(path: str) TabulatedResponse[source]
Builds a response from a two-column CSV of energy in PeV against response.
- Parameters:
- pathstr
Path to the CSV.
#introduces a comment.
- Returns:
- TabulatedResponse
- oroscope.aperture.geometric_aperture_m2sr(area_km2: float | ndarray, solid_angle_sr: float | ndarray) ndarray[source]
Area times accepted solid angle, in m^2 sr.
The purely geometric part of an aperture: no physics beyond the terrain.
- Parameters:
- area_km2float or array_like
Usable area, in km^2.
- solid_angle_srfloat or array_like
Accepted solid angle, in steradians.
- Returns:
- ndarray
Geometric aperture, in m^2 sr.
Examples
>>> from oroscope import aperture >>> f"{float(aperture.geometric_aperture_m2sr(100.0, 0.5)):.2e}" '5.00e+07'
- oroscope.aperture.aperture_vs_energy(area_km2: float, solid_angle_sr: float, min_dist_m: float, max_dist_m: float, energies_pev, response=None)[source]
Aperture as a function of energy for one site.
A(E) = area * Omega * P_decay(E) * response(E)
P_decayis the probability the tau decays inside the accepted baseline window,exp(-d_min/L) - exp(-d_max/L)withL = (E/m_tau) c*tau. It is exact and carries the whole geometric energy dependence: short baselines favour low energies, long baselines high ones.- Parameters:
- area_km2float
Usable area of the site, in km^2.
- solid_angle_srfloat
Accepted solid angle, in steradians.
- min_dist_m, max_dist_mfloat
Ends of the accepted decay-baseline window, in metres.
- energies_pevarray_like
Energies at which to evaluate, in PeV.
- responsecallable, optional
Detection response as a function of energy. Defaults to
unit_response(), which leaves the normalisation explicit.
- Returns:
- ndarray
Aperture in m^2 sr, one entry per energy.
Examples
>>> import numpy as np >>> from oroscope import aperture >>> a = aperture.aperture_vs_energy(100.0, 0.5, 1.0e4, 8.0e4, [1.0, 100.0]) >>> bool(a[1] > a[0]) # a long baseline favours higher energies True
- oroscope.aperture.peak_energy_pev(min_dist_m: float, max_dist_m: float, energies_pev: ndarray | None = None) float[source]
Energy at which the decay factor peaks for a given baseline window.
A site’s geometry therefore predicts which energies it is best suited to, which is a check that can be made without any normalisation.
- Parameters:
- min_dist_m, max_dist_mfloat
Ends of the accepted decay-baseline window, in metres.
- energies_pevndarray, optional
Energies to search, in PeV. Defaults to a wide log-spaced grid.
- Returns:
- float
Energy at which the decay factor peaks, in PeV.
Examples
>>> from oroscope import aperture >>> near = aperture.peak_energy_pev(1.0e3, 5.0e3) >>> far = aperture.peak_energy_pev(1.0e4, 8.0e4) >>> bool(far > near) # longer baselines peak at higher energy True
- oroscope.aperture.infer_response(published_energy_pev: ndarray, published_value: ndarray, area_km2: float, solid_angle_sr: float, min_dist_m: float, max_dist_m: float, min_model_fraction: float = 0.001) tuple[ndarray, ndarray][source]
Response function implied by a published curve, given our geometric model.
This is the useful thing to do with an integral aperture when no differential acceptance table exists. Our model supplies the two factors terrain and kinematics determine – geometric aperture and the analytic tau decay probability – so dividing a published curve by them leaves everything else:
response(E) = published(E) / (area * Omega * P_decay(E))
What remains is the neutrino interaction and tau exit probability, the trigger efficiency, and any normalisation the published configuration carries. Its shape in energy is then usable as a weight for a site of the same experiment, which is exactly the piece section 4.10 had to leave out.
The caveat is the same one that applies to the published curves themselves: they are integral over one array and one site, so the inferred response inherits that site’s geometry. It is a better weight than a flat response, not a substitute for a differential table.
Where the decay probability is negligible the division is ill-conditioned and the ratio explodes – at 0.35 PeV over a canyon baseline it is of order 1e-8 – so energies whose model value falls below
min_model_fractionof its own peak are excluded rather than allowed to dominate the normalisation.- Parameters:
- published_energy_pevarray_like
Energies of the published curve, in PeV.
- published_valuearray_like
Published aperture at each energy, in m^2 sr.
- area_km2float
Usable area of the configuration the curve describes, in km^2.
- solid_angle_srfloat
Accepted solid angle of that configuration, in steradians.
- min_dist_m, max_dist_mfloat
Ends of its accepted decay-baseline window, in metres.
- min_model_fractionfloat, optional
Energies whose model aperture falls below this fraction of its own peak are excluded, since the division there is ill-conditioned.
- Returns:
- energies_pevndarray
The well-conditioned subset of the input energies.
- responsendarray
Inferred response over that range, normalised to 1 at its maximum.
- oroscope.aperture.load_curve_csv(path: str) tuple[ndarray, ndarray][source]
Loads a two-column digitized curve, returning (energy_pev, value).
The files under
data/store energy in GeV, as the published axes do, and this converts to PeV on the way in.- Parameters:
- pathstr
Path to a two-column CSV of energy in GeV against value.
- Returns:
- energy_pevndarray
Energies, converted to PeV.
- valuendarray
The second column, unchanged.
- oroscope.aperture.summarize_sites(site_details: list[dict], min_dist_m: float, max_dist_m: float, energies_pev: ndarray, response: ndarray | None = None) dict[source]
Aperture-versus-energy for every site that carries scan observables, plus the total.
Uses each site’s median accepted solid angle, so a site is credited with the acceptance typical of its pixels rather than of its best one.
- Parameters:
- site_detailslist of dict
Site records from the pipeline. Sites without an
arrival_scanentry are skipped.- min_dist_m, max_dist_mfloat
Ends of the accepted decay-baseline window, in metres.
- energies_pevarray_like
Energy grid, in PeV.
- responsecallable, optional
Detection response. Defaults to
unit_response().
- Returns:
- dict
energies_pev, asitesmapping of site id to aperture curve, andtotal, their sum.
- oroscope.aperture.array_scale_factor(target_units, published, target_spacing_km=None, target_grid_type=None)[source]
How much larger this array is than the one a published curve was simulated for.
A published aperture or effective area belongs to a specific array at a specific site. Oroscope changes both, and only one of them can be corrected for by arithmetic. This is that one: the array size.
Aperture scales with instrumented ground, not with detector count as such, so the factor is
(N_target * s_target^2) / (N_published * s_published^2)
which collapses to the ratio of counts when the two spacings agree. Both terms are carried because they must be: doubling the count at fixed spacing doubles the ground and roughly doubles the aperture, while doubling it at fixed ground only makes the array denser, and a denser array past the point where it already samples the Cherenkov footprint adds very little. Scaling a densified array by its count alone would inflate the answer by exactly the factor by which it was densified.
- Parameters:
- target_unitsint
Detectors oroscope fits on this ground.
- publisheddict or str
An entry of
PUBLISHED_ARRAYS, or its key.- target_spacing_kmfloat, optional
Lattice spacing oroscope used. Defaults to the published spacing, which makes the factor a plain ratio of counts.
- target_grid_type{‘hex’, ‘square’}, optional
Lattice oroscope used. Ground per detector is
spacing^2times sin60 for a triangular lattice and 1 for a square one, so the factor cancels only when both lattices match. Defaults to the published array’s.
- Returns:
- float
Multiplier to apply to the published curve.
- Raises:
- ValueError
If the published entry is unknown or either count is not positive.
Notes
This corrects the array and not the site. The published simulation carries its own terrain – Colca’s walls for TAMBO, a prototypical site for GRAND – with its own distribution of column depth, arrival elevation and target distance, and no operation on an integral curve can remove them. A scaled curve therefore reads: “what this many detectors would have achieved on the ground the simulation assumed”, not “what they will achieve on this ground”. See Assumptions and limitations.
Examples
>>> from oroscope import aperture >>> round(aperture.array_scale_factor(10000, "tambo_aperture_fig3"), 3) 2.0
Same detector count, spread twice as far apart, is four times the ground:
>>> round(aperture.array_scale_factor(5000, "tambo_aperture_fig3", ... target_spacing_km=0.30), 3) 4.0
- oroscope.aperture.scale_published_curve(values, target_units, published, target_spacing_km=None, target_grid_type=None)[source]
A published curve rescaled to the array oroscope actually found room for.
- Parameters:
- valuesarray_like
The published curve, in whatever units it came in – m^2 sr for an aperture, cm^2 for an effective area. The scaling is dimensionless, so the units survive.
- target_unitsint
Detectors oroscope fits on this ground.
- publisheddict or str
- target_spacing_kmfloat, optional
- target_grid_type{‘hex’, ‘square’}, optional
- Returns:
- ndarray
The curve, scaled.
Examples
Half the published detector count is half the aperture, at the same spacing:
>>> from oroscope import aperture >>> published = [10.0, 100.0, 1000.0] >>> aperture.scale_published_curve(published, 2500, "tambo_aperture_fig3") array([ 5., 50., 500.])
- oroscope.aperture.absolute_from_published(results, curve_path, published, target_spacing_km=None, target_grid_type=None)[source]
A run’s absolute aperture, by scaling a published curve to the array it found room for.
This is post-processing, exactly as roadmap §4.10 step 4 intends: the search stores what terrain determines, and folding a published curve against it needs no re-run.
- Parameters:
- resultsdict
A results dictionary, or one loaded from a run’s JSON.
- curve_pathstr
Two-column digitized curve, as in
data/.- publisheddict or str
The array that curve was simulated for – an entry of
PUBLISHED_ARRAYSor its key.- target_spacing_kmfloat, optional
Spacing this run used. Read from the run’s own
spacing_kmwhen omitted, which is what makes the answer honest: reading it from anywhere else invites the density errorarray_scale_factor()exists to prevent.- target_grid_type{‘hex’, ‘square’}, optional
Lattice this run used. Read from the run’s own
grid_typewhen omitted.
- Returns:
- dict
{"energies_pev", "aperture", "units", "scale_factor", "detectors", "published", "caveat"}.aperturecarries the published curve’s own units.
Notes
This corrects the array, not the site, and the returned
caveatsays so in the artefact itself rather than only here. The published simulation carries its own terrain – its column depths, its target distances, its trigger geometry – and no operation on an integral curve separates those from it. Read the result as what this many detectors would have achieved on the ground that simulation assumed.Examples
Paths are resolved by the caller, so this example builds one from the package’s own location rather than from the working directory – the test job runs from
tests/.This resolves only in a source checkout.
data/sits besidesrc/in the repository and is not shipped in the wheel, so from an installedsite-packages/oroscope/aperture.pythe threedirnamecalls land outside site-packages entirely. That is not a limitation of the function – it takes whatever path it is given – but the example below cannot be pasted into a fresh install unchanged. Point it at your own copy of the curve, or at a clone.>>> import os >>> from oroscope import aperture >>> repo = os.path.dirname(os.path.dirname(os.path.dirname(aperture.__file__))) >>> curve = os.path.join(repo, "data", "tambo_aperture_fig3.csv") >>> results = {"results": {"total_capacity": 10000}, ... "parameters": {"antenna_spacing_km": 0.15}} >>> out = aperture.absolute_from_published(results, curve, "tambo_aperture_fig3") >>> out["units"], round(out["scale_factor"], 3), out["detectors"] ('m^2 sr', 2.0, 10000)
The pipeline
Screening, morphology, capacity and outputs, plus the command-line entry point.
The search itself: from a digital elevation model to sites with detector capacity.
Six stages, each streamed so that a DEM larger than memory is not a special case.
Terrain is screened by slope, aspect, altitude and exclusion zones; the survivors
are scanned over a fan of arrival directions (arrival_scan) and scored
against per-experiment criteria (scoring); the accepted mask is cleaned
morphologically, labelled into sites and packed with a detector lattice; and the
result is written as GeoTIFF, world file, KML, PNG and JSON, with a selection
funnel, a provenance record and a plain-language summary (explain).
Three things are worth knowing before reading further.
GRAND and TAMBO are configurations, not code paths. Adding an experiment means
writing a JSON file. Nothing that shapes a result is hard-coded; every criterion is a
parameter of find_grand_regions_interactive(), and the command line, the
configuration file and the library all reach the same function.
The funnel is the diagnostic. Every filter records how many pixels survived it, so
a search that returns little or nothing names the constraint responsible rather than
leaving it to be guessed. It is printed, stored in the results JSON, and read back by
explain.binding_constraint().
Geometry comes from the file. Pixel size and the north-west corner are read from the DEM’s own GeoTIFF tags, because an origin typed by hand does not fail when it is wrong – it silently georeferences every output to the wrong ground. A supplied origin that disagrees with the file is reported rather than honoured in silence.
The pipeline returns its results dictionary, so a caller does not have to find and re-read the file it was just handed the path to.
- oroscope.site_searcher.find_grand_regions_interactive(dem_path, cell_size_deg=None, target_antennas=10000, rfi_zones=None, origin_lat=None, origin_lon=None, min_width_km=2.0, min_altitude=None, max_altitude=None, antenna_spacing_km=1.0, min_dist_km=10.0, max_dist_km=80.0, road_map_path=None, max_road_dist_km=20.0, grid_type='hex', generate_kml=False, search_mode='distributed', min_sub_array_size=500, min_aspect_deg=None, max_aspect_deg=None, min_slope_deg=3.0, max_slope_deg=25.0, region_name=None, downsample_factor=4, run_output_dir='.', output_image_format='png', tile_size=2048, resume=False, resume_dir=None, num_cores=-1, candidate_stride=5, slope_baseline_m=None, energy_min_pev=None, energy_max_pev=None, n_azimuths=9, azimuth_half_width_deg=60.0, elev_min_deg=-3.0, elev_max_deg=3.0, n_elev_bins=12, min_column_depth_gcm2=0.0, require_terrain=True, min_target_slope_deg=None, max_target_slope_deg=None, max_range_km=None, score_percentile=None, decay_weight_by='flux', decay_response_csv=None, settlements='auto', roads_geojson=None, stop_at_target=False, decay_energy_pev=None, decay_energy_min_pev=None, decay_energy_max_pev=None, decay_spectral_index=None, shower_development_m=3000.0, gap_close_km=None, fresnel_frequency_mhz=None, refraction_k=None, antenna_height_m=2.0, fresnel_near_field_m=500.0, exclude_near_field=True, depth_band_gcm2=None, score_composition='product', score_weights=None, distance_band_m=None, solid_angle_half_sr=None, solid_angle_half_fraction=None, clearance_full_at=None, min_score=0.0, geomag_declination_deg=None, geomag_inclination_deg=None, use_geomagnetic=True, grammage_mode='radio', grammage_band_gcm2=None, grammage_maturity_gcm2=None, grammage_band_fraction=None, shower_elongation_rate_gcm2=None, shower_lambda_gcm2=None, muon_shielding_km=None, bilinear_sampling=True, nu_interaction_length_gcm2=None, max_memory_gb=None, explain=True)[source]
The main orchestrator. Now decoupled from logic, it sets up the environment, calls the pipeline helpers in sequence, and manages memory cleanup and checkpointing.
The map resolution (cell_size_deg) is read from the DEM’s own georeferencing tags unless the caller overrides it; every metric conversion downstream derives from it.
- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- cell_size_degfloat, optional
Pixel size in degrees, overriding the GeoTIFF’s own tag.
- target_antennasint, optional
Capacity wanted from a single site.
- rfi_zoneslist or str, optional
Exclusion zones, or the name of a bundled set.
- origin_lat, origin_lonfloat, optional
Coordinates of the DEM’s north-west corner, in degrees. Read from the file’s own
ModelTiepointTagwhen omitted, which is the recommended use; a supplied value that disagrees with the tag by more than ~100 m is reported rather than silently honoured.- min_width_kmfloat, optional
Narrowest feature to keep, in km. 0 disables pruning, which is what a strip-shaped array along a canyon wall needs.
- min_altitude, max_altitudefloat, optional
Altitude bounds, in metres.
- antenna_spacing_kmfloat, optional
Detector spacing, in km.
- min_dist_km, max_dist_kmfloat, optional
Accepted range to the first terrain intersection, in km – the decay-baseline window. Also sets how far the profile is walked.
- road_map_pathstr, optional
Aligned GeoTIFF of distance-to-road values.
- max_road_dist_kmfloat, optional
Maximum allowed distance from a road, in km.
- grid_type{‘square’, ‘hex’}, optional
Lattice the detectors are placed on.
- generate_kmlbool, optional
Also write a Google Earth
.kml.- search_mode{‘single’, ‘distributed’}, optional
Whether one site must hold the whole array.
- min_sub_array_sizeint, optional
Capacity a sub-array must reach in distributed mode.
- min_aspect_deg, max_aspect_degfloat, optional
Required facing directions, in degrees clockwise from north.
- min_slope_deg, max_slope_degfloat, optional
Slope band the detector site must fall in, in degrees. This is the near wall, the ground the array stands on.
- region_namestr, optional
Human-readable label for the outputs.
- downsample_factorint, optional
Factor at which sites are labelled and areas measured. Above 1, a feature only a few pixels wide loses area it keeps detectors on.
- run_output_dirstr, optional
Directory to write into.
- output_image_formatstr, optional
Extension for the overview map, such as
pngorpdf.- tile_sizeint, optional
Side of the square tile held in RAM at once.
- resumebool, optional
Reuse a previous run’s scan buffer instead of recomputing it.
- resume_dirstr, optional
Directory holding that buffer.
- num_coresint, optional
Threads for the scan.
-1uses all of them.- candidate_strideint, optional
Keeps every Nth screened pixel. Measured to be unbiased; see
get_candidates_chunked().- slope_baseline_mfloat, optional
Ground distance over which slope is measured, in metres. Slope is scale-dependent, so this is an explicit choice rather than an accident of the DEM’s resolution.
- energy_min_pev, energy_max_pevfloat, optional
Tau energy range. When given it overrides the distance window with one derived from the decay length, and in particle mode also sets the shower band.
- n_azimuthsint, optional
Azimuths scanned per candidate. This is what sets the cost of a run.
- azimuth_half_width_degfloat, optional
Half-width of the fan about each candidate’s aspect, in degrees.
- elev_min_deg, elev_max_degfloat, optional
Edges of the accepted arrival window, in degrees.
- n_elev_binsint, optional
Bins across that window. Nearly free: one walk serves them all.
- min_column_depth_gcm2float, optional
Column depth a direction must have to count, in g/cm^2.
- require_terrainbool, optional
Trueselects directions striking rock;Falseselects directions escaping to the sky, which is the cosmic-ray channel.- min_target_slope_deg, max_target_slope_degfloat, optional
Bounds on the struck terrain’s slope along the arrival azimuth. This is the far wall. Unset by default, which asks only that rock is present – true almost everywhere in mountainous terrain.
- max_range_kmfloat, optional
How far each profile is walked, in km. Defaults to
max_dist_km. Worth setting larger for a short-range search: column depth accumulates over the whole walk, so tying the two makes the reported depth a property of where the walk stopped rather than of the target’s thickness.- score_percentilefloat, optional
Keep this percentage of viable candidates, ranked by score, instead of cutting at an absolute
min_score. Preferred, and for the same reason: a rank is scale-free, so it does not move when the composition or the number of components changes.- decay_weight_by{‘flux’, ‘acceptance’, ‘flux_times_acceptance’}, optional
What weights the spectrum-folded decay probability.
'flux'asks what fraction of arriving neutrinos decays usefully and is the default;'acceptance'asks the same over the energies the detector responds to, with no assumed spectrum;'flux_times_acceptance'is the event-rate integrand. The latter two requiredecay_response_csv.- decay_response_csvstr or callable, optional
Detection response
A(E)for the acceptance weightings: a path to a two-column CSV of energy in PeV against relative response, or a callable.aperture.infer_response()recovers one from a published integral curve.- settlementsstr or sequence, optional
Named places to mark on the map.
'auto'uses whichever curated list has points inside the DEM; a path to a places GeoJSON fromoroscope-fetch-roadsmarks those;'none'marks nothing. Seeresolve_settlements().- roads_geojsonstr, optional
Road geometry to draw on the map, from
oroscope-fetch-roads. Context only: distinct fromroad_map_path, which screens candidates, this changes no result.- stop_at_targetbool, optional
In distributed mode, stop selecting sites once
target_antennasis reached. Sites are ranked by capacity, so this reports the best sites for the array actually wanted rather than every patch of qualifying ground.- decay_energy_pevfloat, optional
Single energy at which to score the probability the tau decays in the gap. Superseded by the range below and kept for asking what one energy would have said: measured, the answer ran from 10878 detector positions at 3 PeV to zero at 100, so one number does not stand in for a spectrum.
- decay_energy_min_pev, decay_energy_max_pevfloat, optional
Tau energy range over which to fold the decay probability against the flux. The defensible form, and what makes the result a property of the terrain rather than of the energy someone picked.
- decay_spectral_indexfloat, optional
Gamma in dN/dE ~ E^-gamma for that folding (default 2.0). A softer spectrum weights low energies, where the tau decays readily, so it drives the term toward 1 – an assumption deserving the same scrutiny as any other.
- shower_development_mfloat, optional
Path the shower needs after the tau decays, in metres.
- gap_close_kmfloat, optional
Size of the morphological closing element, in km. Defaults to
antenna_spacing_km. Closing more than doubles reported area on real terrain, so it is worth setting deliberately; 0 disables it.- fresnel_frequency_mhzfloat, optional
Radio band for the Fresnel clearance measurement.
Noneskips that pass, as a particle experiment wants.- refraction_kfloat, optional
Refraction factor for the radio path only. The particle geometry always uses the true Earth radius.
- antenna_height_mfloat, optional
Receiver height above ground, in metres.
- fresnel_near_field_mfloat, optional
Stretch of path the clearance measurement skips, in metres.
- exclude_near_fieldbool, optional
Apply that near-field cut-off.
- depth_band_gcm2tuple of float, optional
Column-depth band scoring 1, in g/cm^2.
- score_composition{‘product’, ‘mean’, ‘min’}, optional
How components combine.
- score_weightsdict, optional
Per-component weights.
- distance_band_mtuple of float, optional
Exit-distance band scoring 1, in metres. Defaults to the decay window.
- solid_angle_half_srfloat, optional
Accepted solid angle scoring 0.5, in steradians. The 0.05 default is GRAND-scale and saturates against a canyon’s much larger acceptance.
- solid_angle_half_fractionfloat, optional
Fraction of the sky the azimuth fan and arrival window could accept that scores 0.5. Dimensionless, so it does not move when either of those does – which the steradian form could not manage.
- clearance_full_atfloat, optional
Fresnel clearance ratio scoring 1.
- min_scorefloat, optional
Score a candidate must reach. Note a product composition concentrates near zero, so any threshold in the middle sits on a cliff.
- geomag_declination_deg, geomag_inclination_degfloat, optional
Field direction, in degrees. Supply the IGRF values for the site.
- use_geomagneticbool, optional
Weight directions by
|v x B|. Radio only; particles do not care.- grammage_mode{‘radio’, ‘particle’}, optional
Whether atmospheric depth is scored as a maturity threshold or as a band.
- grammage_band_gcm2tuple of float, optional
Explicit shower band in particle mode. Setting it disables
grammage_band_fraction.- grammage_maturity_gcm2float, optional
Depth at which the radio maturity ramp reaches 1, in g/cm^2.
- grammage_band_fractionfloat, optional
Fraction of peak particle content still counted as a usable shower, when the band is derived from an energy range.
- shower_elongation_rate_gcm2, shower_lambda_gcm2float, optional
Shower-profile parameters: how much deeper maximum sits per decade of energy, and the Gaisser-Hillas interaction length.
- muon_shielding_kmfloat, optional
Rock overburden required for muon rejection, in km.
- bilinear_samplingbool, optional
Interpolate the terrain profile between pixel centres. Costs about 1.44x and removes an asymmetric half-pixel bias.
- nu_interaction_length_gcm2float, optional
Neutrino interaction length, enabling the Earth-chord attenuation term.
- max_memory_gbfloat, optional
Ceiling on this process’s address space, in GiB.
Noneuses 80% of what the system reports available, so a search that outgrows the machine fails withMemoryErrorinstead of inviting the OOM killer to choose a victim; 0 disables the cap. Seepreflight_memory().- explainbool, optional
Print the plain-language account of the run – what was found, which constraint set the size of the answer, and which numbers are assumptions – and save it as
explanation.txtbeside the results. On by default. The text is also in the returned dictionary under"explanation", and can be regenerated from any results file withexplain.explain_results().
- Returns:
- dict
The run’s results:
parameters,results(sites, capacity),funnel,regions,timings_sec,aperture,provenance,explanationand the paths of the files written. The same content as the results JSON, so a caller no longer has to find and re-read the file this just wrote. A run that finds no candidate at all still returns its funnel, which is the case where the funnel matters most.
Notes
Writes GeoTIFF, world file, PNG, optional KML, a results JSON and a provenance record into
run_output_dir, and prints a selection funnel. When a search returns nothing, the funnel is the first place to look: the constraint responsible is the line where the survivor count collapses.
- oroscope.site_searcher.main()[source]
Command-line entry point: parses arguments, reconciles them against the config file and the fallbacks, validates, and runs one search.
Kept as a function rather than a bare
__main__block so the console script declared in pyproject.toml has something to call, and so the argument handling can be exercised from a test without spawning a subprocess.
- oroscope.site_searcher.resolve_grid_geometry(dem_path, origin_lat, cell_size_deg=None, origin_lon=None)[source]
Determines the sampling geometry used by the whole pipeline.
Resolution priority: explicit user value > GeoTIFF ModelPixelScaleTag > 1 arc-second.
A geographic raster has pixels that are square in degrees but not in metres: a degree of longitude shrinks with the cosine of the latitude, so a 1 arc-second pixel spans roughly 30.7 m north-south but only ~29.5 m east-west at 17 degrees south. The longitude scale is evaluated at the DEM’s centre latitude so the residual error from ignoring its north-south variation is spread evenly over the map rather than accumulating towards one edge.
- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- origin_latfloat
Latitude of the DEM’s northern edge, in degrees, for the latitude-dependent east-west scaling.
- cell_size_degfloat, optional
Explicit pixel size in degrees, overriding whatever the file says.
- origin_lonfloat, optional
Longitude of the DEM’s western edge, in degrees. Supplied only so the result can carry
center_lon; nothing about the pixel sizes depends on it. Omitted,center_lonisNone.
- Returns:
- MapGrid
Angular pixel size, both metric pixel sizes, the centre latitude used for the longitude scaling, and where the resolution value came from – recorded so a run’s provenance says whether the resolution was detected or asserted.
Examples
>>> from oroscope import site_searcher as ss >>> grid = ss.resolve_grid_geometry("nonexistent.tif", -15.6, cell_size_deg=1/3600) >>> f"{grid.cell_size_y:.1f} m x {grid.cell_size_x:.1f} m" '30.7 m x 29.8 m'
- oroscope.site_searcher.read_dem_geometry(dem_path)[source]
Reads the angular pixel size and row count of a GeoTIFF DEM from its header.
Standard geographic (EPSG:4326) DEMs such as SRTMGL1 or AW3D30 store the pixel size in degrees, which is what the georeferenced outputs (.tfw, .kml) require. Only the header is touched, so this stays cheap on multi-gigabyte files.
- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- Returns:
- tuple
Pixel size in degrees, the number of rows and the number of columns. Any of them is
Nonewhen the file or the tag cannot be read, which is not an error: the caller falls back to an explicit value or to 1 arc-second.
- oroscope.site_searcher.read_dem_origin(dem_path)[source]
North-west corner of a GeoTIFF, from its
ModelTiepointTag.Standard geographic DEMs carry their own corner, so asking a user to type it is asking for a mistake that nothing catches: an origin that disagrees with the file does not fail, it silently georeferences every output to the wrong ground. Reading it removes the most error-prone input the tool has.
- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- Returns:
- tuple
(latitude, longitude)of the north-west corner in degrees, or(None, None)when the file or the tag cannot be read – which is not an error, since a caller may supply the origin explicitly.
- oroscope.site_searcher.resolve_origin(dem_path, origin_lat=None, origin_lon=None, tolerance_deg=0.001)[source]
Settles the DEM’s origin, preferring the file and checking anything supplied.
Two failure modes, and the second is the dangerous one. An origin nobody supplied used to be a fatal error even though the file knows it. And an origin supplied wrongly was accepted in silence, mis-georeferencing every output – the GeoTIFF, the world file, the KML and every coordinate in the results – while the search itself ran perfectly and looked right.
So the tag wins when nothing is given, and disagreement past
tolerance_degis reported loudly rather than resolved quietly. 1e-3 degrees is about 100 m, which is a few pixels: closer than that is rounding in a config file, further is a mistake.- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- origin_lat, origin_lonfloat, optional
Origin as supplied by the user, if any.
- tolerance_degfloat, optional
Disagreement beyond which the supplied value is called out.
- Returns:
- tuple
(latitude, longitude, source), where source describes where the value came from and is recorded in the run’s provenance.
Examples
>>> from oroscope import site_searcher as ss >>> lat, lon, source = ss.resolve_origin("nonexistent.tif", -15.3, -72.4) >>> (lat, lon, source) (-15.3, -72.4, 'supplied (DEM carries no tiepoint)')
- oroscope.site_searcher.build_elevation_cache(dem_path, npy_path, block_rows=2048)[source]
Converts a DEM to the memory-mapped float32 cache, without ever holding it in RAM.
The obvious
tiff.imread(path).astype(np.float32)materialises the whole DEM and then a second full copy of it — which defeats the point of the out-of-core design the rest of the pipeline is built around, and fails outright on the multi-gigabyte DEMs this tool is meant to handle. Instead the page is decoded straight into a native-dtype file, then converted a block of rows at a time. Peak memory is one block, whatever the size of the DEM.float32 with NaN is kept rather than the DEM’s own integer dtype: NaN propagates through the gradient and comparison chain in the screening stage, so nodata is excluded without a sentinel test in every kernel. That costs twice the disk of an int16 cache and buys correctness that would otherwise have to be re-established in half a dozen places.
- Parameters:
- dem_pathstr
Path to the input GeoTIFF.
- npy_pathstr
Path to write the float32 memory-mapped cache to.
- block_rowsint, optional
Rows converted at a time. Peak memory is one block, whatever the DEM’s size.
- oroscope.site_searcher.load_dem_and_init_buffers(dem_path, temp_dir, resume=False, resume_dir=None)[source]
Step 1 Pipeline: Converts TIF to memory-mapped NPY for rapid random access and initializes the ping-pong buffers for later morphology steps. If resume is True and resume_dir is provided, it attempts to load an existing ray-tracing buffer.
- Parameters:
- dem_pathstr
Path to the input elevation GeoTIFF.
- temp_dirstr
Directory for the working buffers.
- resumebool, optional
Reuse a previous run’s scan buffer instead of recomputing it.
- resume_dirstr, optional
Directory holding that buffer.
- Returns:
- elevationndarray
Memory-mapped DEM.
- rows, colsint
Array dimensions.
- path_A, path_Bstr
Paths to the boolean ping-pong buffers.
- buf_andarray
Open memory map of buffer A.
- is_resumingbool
Trueif a previous scan buffer was loaded successfully.
- oroscope.site_searcher.terrain_gradients(elevation_block: ndarray, cell_size_y: float, cell_size_x: float, smooth_y: int = 0, smooth_x: int = 0) tuple[ndarray, ndarray][source]
Smoothed partial derivatives of the surface, the raw material for slope and aspect.
Smoothing before differentiating gives the average gradient over the window, which is what “slope at 1 km scale” means physically. Callers must supply a block with a halo of at least max(smooth)//2 + 1 and crop the result, otherwise the window reaches past the block edge.
Kept separate from
terrain_derivatives()because the screening stage wants the gradients themselves: a slope band can be tested without ever forming the angle (seeslope_band_gradient_sq()), and aspect is needed only at the few pixels that survive.- Parameters:
- elevation_blockndarray
Elevation tile, including a halo of at least
max(smooth)//2 + 1.- cell_size_y, cell_size_xfloat
Ground size of one pixel on each axis, in metres. They differ on a geographic grid, which is why they are separate.
- smooth_y, smooth_xint, optional
Smoothing window in pixels, from
slope_baseline_pixels().
- Returns:
- tuple of ndarray
(d/dy, d/dx), in metres per metre.
- oroscope.site_searcher.terrain_derivatives(elevation_block: ndarray, cell_size_y: float, cell_size_x: float, smooth_y: int = 0, smooth_x: int = 0) tuple[ndarray, ndarray][source]
Slope and aspect over a stated measurement baseline.
- Parameters:
- elevation_blockndarray
Elevation tile, including a halo. See
terrain_gradients().- cell_size_y, cell_size_xfloat
Ground size of one pixel on each axis, in metres.
- smooth_y, smooth_xint, optional
Smoothing window in pixels.
- Returns:
- tuple of ndarray
Slope in degrees, and aspect in degrees clockwise from north.
See also
slope_band_gradient_sqtests a slope band without forming the angle at all, which is what the screening stage uses.
- oroscope.site_searcher.slope_band_gradient_sq(min_slope_deg: float | None, max_slope_deg: float | None) tuple[float | None, float | None][source]
The slope band restated as bounds on the squared gradient magnitude.
slope = atan(|grad|)rises monotonically with the gradient magnitude, somin <= atan(sqrt(g)) <= max <=> tan(min)^2 <= g <= tan(max)^2
which tests the same pixels without a sqrt or an arctan. Bounds at or beyond the vertical, and non-positive lower bounds, are returned as None meaning “unbounded”: tan is singular at 90 degrees and every real gradient satisfies them anyway.
- Parameters:
- min_slope_deg, max_slope_degfloat or None
Edges of the accepted slope band, in degrees.
- Returns:
- tuple
Lower and upper bounds on
dx^2 + dy^2. Either may beNone, meaning unbounded on that side.
Examples
>>> from oroscope import site_searcher as ss >>> lo, hi = ss.slope_band_gradient_sq(3.0, 25.0) >>> f"{lo:.4f} {hi:.4f}" '0.0027 0.2174' >>> ss.slope_band_gradient_sq(0.0, 90.0) # both edges degenerate (None, None)
- oroscope.site_searcher.slope_baseline_pixels(map_grid, slope_baseline_m: float | None) tuple[int, int][source]
Converts a slope measurement baseline in metres to a per-axis window in pixels.
Slope is scale-dependent: on real Andean terrain the median slope falls from ~17.8 deg measured over the DEM’s native ~61 m to ~10.8 deg over 1 km, and the fraction passing a 3-25 deg band rises from 60% to 78%. Which of those is “the” slope depends on the footprint being deployed, so the baseline is an explicit parameter rather than an accident of the DEM’s resolution.
- Parameters:
- map_gridMapGrid
Angular and metric pixel sizes of the DEM.
- slope_baseline_mfloat or None
Ground distance over which slope is measured, in metres.
Noneor 0 uses the DEM’s native resolution, which on 30 m data is dominated by DEM noise.
- Returns:
- tuple of int
Smoothing window as
(rows, columns)in pixels.(0, 0)when no baseline is requested, meaning the native gradient.
- oroscope.site_searcher.get_candidates_chunked(elevation, map_grid, rfi_zones, origin_lat, origin_lon, min_alt=None, max_alt=None, min_aspect_deg=None, max_aspect_deg=None, road_map_path=None, max_road_dist_km=None, min_slope_deg=3.0, max_slope_deg=25.0, tile_size=2048, candidate_stride=5, slope_baseline_m=None, funnel=None)[source]
Step 2 Pipeline: Memory-efficient topographic screening. Iterates over the large DEM in chunks (tiles) to find pixels that meet the primary geometrical criteria (slope, aspect, altitude) and logistics constraints (RFI distance, road distance) prior to running ray-tracing.
Parameters:
elevation (ndarray): Full DEM array (usually memory-mapped).
map_grid (MapGrid): Angular and metric pixel sizes of the DEM.
rfi_zones (list): List of configured exclusion zones (circles/polygons).
origin_lat, origin_lon (float): Reference coordinates for converting km to pixels.
min_alt, max_alt (float): Elevation restrictions.
min_aspect_deg, max_aspect_deg (float): Required facing directions for slopes.
road_map_path (str): Path to an aligned TIFF containing distance-to-road values.
max_road_dist_km (float): Maximum allowed distance from a road.
min_slope_deg, max_slope_deg (float): Required steepness limits for detector slopes.
tile_size (int): Size of the square chunk to process in RAM at one time.
candidate_stride (int): Keeps every Nth surviving pixel before ray-tracing. Higher values trade spatial sampling density for speed; 1 keeps every candidate.
slope_baseline_m (float): Ground distance over which slope is measured. None uses the DEM’s native resolution, which on 30 m data is dominated by DEM noise.
funnel (Funnel): Optional accounting object recording per-filter survivor counts.
- Parameters:
- elevationndarray
Full DEM, usually memory-mapped.
- map_gridMapGrid
Angular and metric pixel sizes.
- rfi_zoneslist or None
Exclusion zones as
('circle', lat, lon, radius_km, name)or('poly', [(lat, lon), ...], name).- origin_lat, origin_lonfloat
North-west corner of the DEM, in degrees, for converting zones to pixels.
- min_alt, max_altfloat, optional
Altitude bounds, in metres.
- min_aspect_deg, max_aspect_degfloat, optional
Required facing directions, in degrees clockwise from north. Wraps through 360 when the lower bound exceeds the upper.
- road_map_pathstr, optional
Aligned GeoTIFF of distance-to-road values.
- max_road_dist_kmfloat, optional
Maximum allowed distance from a road, in km.
- min_slope_deg, max_slope_degfloat, optional
Slope band, in degrees. Tested on the squared gradient, so neither a square root nor an arctangent is formed over the tile.
- tile_sizeint, optional
Side of the square chunk processed in RAM at once.
- candidate_strideint, optional
Keeps every Nth surviving pixel. Measured to be unbiased: acceptance is identical at strides 1 and 5, and the stride-corrected area matches the stride-1 truth to 0.05%.
- slope_baseline_mfloat, optional
Ground distance over which slope is measured, in metres.
Noneuses the DEM’s native resolution, which on 30 m data is dominated by DEM noise.- funnelFunnel, optional
Accounting object recording per-filter survivor counts.
- Returns:
- ndarray
(N, 3)array of surviving pixels as[row, col, aspect_deg], ready forrun_arrival_scan().
- oroscope.site_searcher.run_arrival_scan(candidates_arr, elevation, map_grid, buf_a, scan_params, score_config=None, min_score=0.0, rfi_zones_px=None, score_percentile=None, funnel=None)[source]
Step 3 alternative: scan arrival directions instead of casting one ray per pixel.
Marks a candidate as valid when at least one accepted (azimuth, elevation) direction strikes rock within the decay-baseline window with enough column depth. See arrival_scan.py for the geometry.
- Parameters:
- candidates_arrndarray
(N, 3)array of[row, col, aspect_deg].- elevationndarray
The DEM.
- map_gridMapGrid
Angular and metric pixel sizes.
- buf_andarray
Open memory map to mark accepted pixels in.
- scan_paramsdict
Keyword arguments for
arrival_scan.scan().- score_configdict, optional
Overrides for
scoring.DEFAULT_SCORE_CONFIG.- min_scorefloat, optional
Absolute score a candidate must reach. Used only when
score_percentileis not given. The default composition is a product, whose distribution piles up near zero, so any threshold in the middle sits on a cliff.- rfi_zones_pxsequence, optional
Radio-noise sources in pixel coordinates, enabling the exposure observable.
- score_percentilefloat, optional
Keep this percentage of viable candidates, by score. Rank-based and so scale-free: preferred over
min_scorefor exactly the reason above.- funnelFunnel, optional
Records the two stages this function decides:
directions accepted, the candidates the geometry accepted, and – only when a score cut is in force – how many of those the cut kept.
- Returns:
- n_hitsint
Number of accepted candidates, after the score cut when one applies.
- observablesdict
Per-candidate arrays, including the scores and their named components, kept for per-site aggregation.
Notes
directions acceptedcounts the geometry alone. It previously carried the post-cut count, identical to the score row beside it, which made the score stage invisible:binding_constraint()compares each stage against the one before, so a stage that keeps exactly 100% can never be named the binding constraint however much it removed. A run emptied bymin_scorewas therefore blamed on the arrival geometry, and the summary told the reader to widen the arrival and distance windows – advice that could not help.Stored results written before this carry the post-cut count under
directions accepted. Only runs with a cut are affected:min_score0 accepts every viable candidate, so every GRAND run in the store already held the geometric number and is unchanged.
- oroscope.site_searcher.summarize_observables_by_site(labeled, downsample_factor, candidates_arr, observables, site_ids)[source]
Aggregates per-candidate scan observables over each labelled site.
Storing the distributions rather than a single score is deliberate: absolute apertures can then be obtained later by folding these against an acceptance table, without re-running the terrain analysis (roadmap 4.10).
- Parameters:
- labeledndarray
Downsampled labelled site map.
- downsample_factorint
Factor relating full-resolution candidate coordinates to
labeled.- candidates_arrndarray
(N, 3)array of[row, col, aspect_deg].- observablesdict
Per-candidate arrays from
run_arrival_scan().- site_idssequence of int
Sites to summarise.
- Returns:
- dict
Site id to summary statistics – mean, median and 90th percentile of each observable – over that site’s accepted candidates. Empty when there are no accepted candidates at all.
- oroscope.site_searcher.clean_shape_artifacts(path_A, path_B, rows, cols, cell_size_y, cell_size_x, antenna_spacing_km, min_width_km, tile_size, gap_close_km=None)[source]
Step 4 Pipeline: Prunes spatial artifacts to ensure solid, block-like arrays. Applies closing to fill gaps and opening to prune unusable tendrils.
The structuring elements are sized per axis so that they cover the requested ground distance in both directions rather than only north-south.
min_width_km = 0degenerates the opening to a 1x1 element, i.e. an identity that only carries the closed map back intopath_A. That is deliberate: a “block-like array” is a GRAND assumption, and an experiment deployed along a canyon wall is a strip a few hundred metres wide and tens of kilometres long, which the opening would delete outright.- Parameters:
- path_A, path_Bstr
The two ping-pong buffers. The result is left in
path_A.- rows, colsint
Array dimensions.
- cell_size_y, cell_size_xfloat
Ground size of one pixel on each axis, in metres.
- antenna_spacing_kmfloat
Detector spacing, used as the default closing scale.
- min_width_kmfloat
Narrowest feature to keep. 0 disables pruning, which is what a strip-shaped array needs.
- tile_sizeint
Side of the square tile held in RAM at once.
- gap_close_kmfloat, optional
Size of the closing element, in km. Defaults to
antenna_spacing_km.
- Returns:
- tuple of int
Set-pixel counts after closing and after pruning.
- oroscope.site_searcher.apply_morphology_pingpong(source_path, dest_path, shape, dtype, operation_func, structure, desc='Processing', tile_size=2048)[source]
Applies image morphology operations (closing/opening) on a massive memory-mapped array without loading the whole array into RAM. It reads from one file and writes to another (“ping-pong”).
- Parameters:
- source_path, dest_pathstr
Paths to the two
.npybuffers, read and written respectively.- shapetuple of int
Shape of the arrays.
- dtypedtype
Element type of the destination.
- operation_funccallable
Morphological operation, applied tile by tile.
- structurendarray
Structuring element.
- descstr, optional
Label for the progress bar.
- tile_sizeint, optional
Side of the square tile held in RAM at once.
- Returns:
- int
Set pixels in the result, counted while writing so the funnel accounting costs nothing extra.
- oroscope.site_searcher.separable_closing(chunk, structure)[source]
Binary closing with a rectangular structuring element, done separably.
A rectangle of ones factorises into a column and a row, so dilation or erosion by (h, w) is dilation by (h, 1) followed by (1, w). That turns an O(N h w) operation into O(N (h + w)) – about 10x for the 33x33 element a 1 km antenna spacing implies – and the result is bit-identical, not an approximation.
- Parameters:
- chunkndarray
Boolean tile to operate on.
- structurendarray
Rectangle of ones. Its two side lengths are what the operation factorises into.
- Returns:
- ndarray
The closed tile.
- oroscope.site_searcher.separable_opening(chunk, structure)[source]
Binary opening with a rectangular element, separably.
Prunes features narrower than the element. See
separable_closing()for why the factorisation is exact rather than an approximation.- Parameters:
- chunkndarray
Boolean tile to operate on.
- structurendarray
Rectangle of ones.
- Returns:
- ndarray
The opened tile.
- oroscope.site_searcher.analyze_sites_and_capacity(path_A, elevation, rows, cols, cell_size_y, cell_size_x, downsample_factor, search_mode, target_antennas, min_sub_array_size, antenna_spacing_km, grid_type, funnel=None, origin_lat=None, origin_lon=None, cell_size_deg=None, candidates_arr=None, observables=None, stop_at_target=False)[source]
Step 5 Pipeline: Isolates unique sites and measures their capacity mathematically. Uses SciPy labeling to find continuous regions and simulates physical grid placement.
Returns: - small_final (ndarray): Downsampled binary mask of the validated sites. - labeled_viz (ndarray): Multi-integer labeled array for color coding visualizations. - site_details (list): Dictionaries containing metadata about each valid site found. - cumulative_capacity (int): Sum of all antennas fitting in valid sites. - count (int): Total number of independent valid sites found. - region_stats (dict): Region-level accounting for the funnel report.
- Parameters:
- path_Astr
Buffer holding the cleaned, full-resolution site mask.
- elevationndarray
The DEM, for the per-site mean aspect.
- rows, colsint
Full-resolution dimensions.
- cell_size_y, cell_size_xfloat
Ground size of one pixel on each axis, in metres.
- downsample_factorint
Factor at which labelling and area are computed. Note area is measured on the downsampled map while capacity is measured at full resolution, so a feature only a few pixels wide loses area it keeps detectors on.
- search_modestr
singleordistributed, deciding which capacity threshold applies.- target_antennasint
Capacity wanted from a single site.
- min_sub_array_sizeint
Capacity a sub-array must reach in distributed mode.
- antenna_spacing_kmfloat
Detector spacing, in km.
- grid_typestr
squareorhex.- funnelFunnel, optional
Accounting object recording survivor counts.
- origin_lat, origin_lonfloat, optional
The DEM’s north-west corner. With
cell_size_deg, each site record gains its centre coordinates and bounding box, so a reader can find the ground without opening the raster.- cell_size_degfloat, optional
Pixel size in degrees, at full resolution.
- candidates_arrndarray, optional
Candidates, for folding scan observables into each site’s record.
- observablesdict, optional
Their per-candidate observables.
- stop_at_targetbool, optional
In distributed mode, stop selecting sites once
target_antennasis reached. Sites are sorted by capacity, so this takes the best ones and reports the array actually wanted rather than every patch of qualifying ground.
- Returns:
- small_finalndarray
Downsampled binary mask of the validated sites.
- labeled_vizndarray
Site labels for colour coding, sized from the label count so that selecting more than 255 sites does not overflow.
- site_detailslist of dict
Per-site metadata for every site that cleared the thresholds, sorted by capacity, each carrying a
selectedflag. Withstop_at_targetthe list is longer than the selection:cumulative_capacity,countand the exported mask cover the selected sites only, so anything totalling this list must filter onselectedor it will over-report both area and site count.- cumulative_capacityint
Total capacity across the selected sites.
- countint
Number of sites selected.
- region_statsdict
Region-level accounting for the funnel report.
- oroscope.site_searcher.count_grid_capacity(mask_chunk, cell_size_y, cell_size_x, spacing_m, grid_type_code)[source]
Counts the detectors that fit on the validated terrain at a given ground spacing.
Detector positions are laid out in metres on the ground and only then looked up in the pixel grid. The earlier version did the reverse: it converted the spacing to an integer number of pixels and stepped the array by that stride, which truncated three separate times —
int()on the row stride, on the column stride, and on the hexagonal row pitchint(spacing_r * sin60). Every truncation shortens the spacing, so detectors ended up closer together than asked for and the count came out high: +7.4% at GRAND’s 1 km, and +58% at TAMBO’s 100 m, where only about three pixels span one separation on a 30 m DEM and the hex pitch collapsed from 2.6 to 2 pixels. Placing points in continuous coordinates has no stride to truncate, so the count follows the requested geometry at any spacing.The layout is anchored at the chunk’s own corner rather than fitted to it, so this is a capacity estimate for an arbitrarily-placed array, not the best packing achievable by sliding the grid around. That is the same convention as before.
A spacing finer than the DEM’s own pixels is permitted and yields several detectors per pixel. That is the honest continuum limit — capacity is usable area divided by area per detector — but note the terrain mask cannot resolve whether those sub-pixel positions really are usable.
- Parameters:
- mask_chunkndarray
2D boolean array;
Truemarks valid terrain.- cell_size_y, cell_size_xfloat
Ground size of one pixel, in metres. They differ on a geographic grid, which is why an equal ground spacing is a different number of pixels on each axis.
- spacing_mfloat
Distance between neighbouring detectors, in metres. Zero or less returns 0.
- grid_type_codeint
0 for a square grid, 1 for a hexagonal (triangular) one.
- Returns:
- int
Detectors fitting inside the valid terrain.
Examples
>>> import numpy as np >>> from oroscope import site_searcher as ss >>> mask = np.ones((100, 100), dtype=bool) # 3 km square of 30 m pixels >>> ss.count_grid_capacity(mask, 30.0, 30.0, 1000.0, 1) 12 >>> ss.count_grid_capacity(mask, 30.0, 30.0, 0.0, 1) # degenerate spacing 0
- oroscope.site_searcher.create_world_file(tif_filename, top_left_lat, top_left_lon, cell_size_deg)[source]
Creates an ESRI World File (.tfw) which accompanies a standard TIFF image, allowing GIS software (like QGIS or ArcGIS) to project it correctly on a map.
- Parameters:
- tif_filenamestr
Path to the raster the world file accompanies. The
.tfwis written beside it with a matching stem.- top_left_lat, top_left_lonfloat
Coordinates of the raster’s north-west corner, in degrees.
- cell_size_degfloat
Pixel size in degrees, after any downsampling.
- oroscope.site_searcher.generate_kml_file(mask, elevation, filename, origin_lat, origin_lon, cell_size_deg, downsample=1)[source]
Generates a Google Earth compatible KML file representing the valid site polygons. It extracts polygon contours from the binary mask using Matplotlib’s contour tool.
Parameters: - mask (ndarray): Binary mask indicating valid deployment sites. - filename (str): Output path for the KML file. - origin_lat, origin_lon, cell_size_deg: Used to convert array pixel indices to GPS coordinates.
- Parameters:
- maskndarray
Boolean site mask.
- elevationndarray
The DEM, used to place the contours in height.
- filenamestr
Path to write the
.kmlto.- origin_lat, origin_lonfloat
North-west corner of the mask, in degrees.
- cell_size_degfloat
Pixel size in degrees.
- downsampleint, optional
Factor by which
maskis already downsampled relative to the DEM.
- oroscope.site_searcher.generate_visualizations_and_outputs(dem_path, elevation, small_final, labeled_viz, site_details, count, cumulative_capacity, origin_lat, origin_lon, map_grid, downsample_factor, generate_kml, run_output_dir, output_image_format, rfi_zones, search_mode, grid_type, antenna_spacing_km, min_altitude, max_altitude, region_name, final_params, run_info=None, settlements='auto', roads_geojson=None)[source]
Step 6 Pipeline: Formats and exports all scientific products including geo-registered TIFs, KML models, an annotated map graphic, and a serialized JSON summary of the run parameters and results to the designated unified output directory.
- Parameters:
- dem_pathstr
Path to the DEM, used for the output stem.
- elevationndarray
The DEM, as the map background.
- small_finalndarray
Downsampled binary mask of the selected sites.
- labeled_vizndarray
Site labels for colour coding.
- site_detailslist of dict
Per-site records.
- countint
Number of selected sites.
- cumulative_capacityint
Total detector capacity across them.
- origin_lat, origin_lonfloat
North-west corner of the DEM, in degrees.
- map_gridMapGrid
Resolved grid geometry.
- downsample_factorint
Factor relating
small_finalto the DEM.- generate_kmlbool
Also write a Google Earth
.kml.- run_output_dirstr
Directory to write into.
- output_image_formatstr
Extension for the overview map, such as
pngorpdf.- rfi_zonessequence
Exclusion zones, drawn on the map.
- search_modestr
singleordistributed.- grid_typestr
squareorhex.- antenna_spacing_kmfloat
Detector spacing, in km.
- min_altitude, max_altitudefloat or None
Altitude bounds applied, for the annotation.
- region_namestr
Human-readable region label.
- final_paramsdict
Resolved parameters, serialised into the results JSON.
- run_infodict, optional
Timings, funnel and provenance to record alongside the results.
- settlementsstr or sequence, optional
Named places to mark on the map. See
resolve_settlements().- roads_geojsonstr, optional
Road geometry to draw as context, from
oroscope-fetch-roads.
- Returns:
- generated_fileslist of str
Absolute paths of everything written.
- out_datadict
The results as serialised into the JSON. Returned as well as written so the caller does not have to find and re-read the file it was just handed the path to, which is what every caller was doing.
- oroscope.site_searcher.collect_provenance(dem_path, map_grid)[source]
Captures everything needed to reproduce a run: code version, input identity, environment and invocation. Written alongside the scientific outputs.
- Parameters:
- dem_pathstr
Path to the DEM, whose sha256 is recorded.
- map_gridMapGrid
Resolved grid geometry, recorded along with where its resolution came from.
- Returns:
- dict
Git commit and dirty flag, DEM path, size and checksum, resolved grid geometry, third-party package versions, and the module-level physics state – enough to say what produced a result months later.
- oroscope.site_searcher.validate_parameters(params)[source]
Pre-flight validation checks to enforce ‘Fail Fast’ mechanisms. Verifies the existence of critical files and the physical logic of search bounds before engaging the memory-heavy processing loops.
- Parameters:
- paramsdict
Fully resolved parameters, after the config, fallback and command line have been reconciled.
- Raises:
- SystemExit
If any check fails. Every problem is collected and reported at once rather than one per run, since the expensive stages come afterwards.
- oroscope.site_searcher.parse_score_weights(value)[source]
Normalises per-component score weights from either input form.
A config file is JSON, so it can carry a mapping directly. The command line cannot, so it takes
shower=2,solid_angle=1instead. Both end up as a dict, and anything unnamed keeps weight 1.- Parameters:
- valuestr, dict or None
Either
name=valuepairs separated by commas, or a mapping, orNone.
- Returns:
- dict or None
Component name to weight, or
Nonewhen nothing was supplied, which leaves the composition unweighted.
- Raises:
- SystemExit
If a pair lacks
=, its value is not a number, or it names something that is not a score component.
Notes
The names are checked. They were not, and
compose()then dropped anything it did not recognise with anif n in wfilter, so a misspelling was accepted, ignored and never reported.--score_weights geomag=0– one character short ofgeomagnetic– parsed cleanly, and the component the user meant to switch off ran at full weight through the whole search: measured on a two-component product, 0.18 where the correct spelling gives 0.9. Nothing anywhere said so. Weights are the one input whose failure leaves no trace in the output, so they are the one input worth rejecting outright.Examples
>>> from oroscope import site_searcher as ss >>> ss.parse_score_weights("shower=2,depth=0.5") == {"shower": 2.0, "depth": 0.5} True >>> ss.parse_score_weights(None) is None True
- oroscope.site_searcher.explicitly_passed(parser, argv=None)[source]
The set of options the user actually typed, as opposed to argparse’s defaults.
argparse gives no way to distinguish
--candidate_stride 5from the default of 5, which is why the configuration merge used to prefer a config file over the command line: with no way to tell a typed flag from an untyped one, honouring the command line would have let every default silently overwrite the config.Re-parsing with every default suppressed answers the question directly — with
SUPPRESS, argparse only sets an attribute for an option that actually appeared. The defaults are restored afterwards, so the originalargsis untouched.- Parameters:
- parserargparse.ArgumentParser
The parser to interrogate. Left exactly as it was found.
- argvlist of str, optional
Arguments to parse. Defaults to
sys.argv.
- Returns:
- set of str
Destinations of the options that actually appeared on the command line.
- oroscope.site_searcher.is_point_in_poly(x, y, poly_verts)[source]
Determines if a given 2D point lies inside a polygon using the Ray-Casting algorithm. Optimized for Numba execution.
- Parameters:
- x, yfloat
Coordinates of the test point.
- poly_vertsndarray
(M, 2)array of polygon vertices as(x, y).
- Returns:
- bool
Trueif the point is inside the polygon.
- oroscope.site_searcher.apply_poly_mask_numba(valid_rows, valid_cols, poly_verts, mask_out)[source]
Parallelized application of the polygon ray-casting check across an array of coordinates. Used for excluding regions defined by arbitrary polygonal RFI zones.
- Parameters:
- valid_rows, valid_colsndarray
Row and column coordinates of the points to check.
- poly_vertsndarray
(M, 2)array of polygon vertices.- mask_outndarray
Boolean array modified in place; entries inside the polygon are cleared. Only ever clears bits, so several polygons can be applied in sequence.
- class oroscope.site_searcher.Funnel[source]
Records how many pixels survive each successive stage of the search.
A search that returns nothing gives the user no clue which constraint was responsible. Every stage reports the count of pixels that passed it and all preceding stages, so the table reads as a funnel from the raw DEM down to the selected sites. Counts accumulate across tiles.
- class oroscope.site_searcher.MapGrid(cell_size_deg, cell_size_y, cell_size_x, center_lat, center_lon, source)
Resolved pixel geometry of a DEM.
- Parameters:
- cell_size_degfloat
Angular pixel size, in degrees. A geographic DEM steps by the same angle on both axes, which is why this is a single number while the metric sizes are two.
- cell_size_yfloat
North-south ground size of one pixel, in metres.
- cell_size_xfloat
East-west ground size of one pixel, in metres. Smaller than
cell_size_yaway from the equator, by the cosine of the latitude.- center_latfloat
Latitude at which
cell_size_xwas evaluated, in degrees.- center_lonfloat or None
Longitude of the DEM’s middle, in degrees, or
Nonewhen no origin longitude was supplied. Paired withcenter_latit is a point actually on the DEM, which is what a field model should be asked about; the geomagnetic field used to be resolved at this latitude and the west edge longitude.- sourcestr
Where the resolution came from – detected from the GeoTIFF, supplied explicitly, or defaulted. Recorded so a run’s provenance says which.
- cell_size_deg
Alias for field number 0
- cell_size_x
Alias for field number 2
- cell_size_y
Alias for field number 1
- center_lat
Alias for field number 3
- center_lon
Alias for field number 4
- source
Alias for field number 5
- oroscope.site_searcher.find_results_json(run_dir)[source]
Locates a run’s results JSON, under either the current or the legacy prefix.
Outputs used to be named
grand_search_results_*whatever the experiment. The prefix is noworoscope_results_, and both are accepted so that runs made before the rename still load – a reader that could not open last week’s output would make the rename cost more than it saves.- Parameters:
- run_dirstr
A run’s output directory.
- Returns:
- str or None
Path to the results JSON, or
Noneif the directory holds none.
- oroscope.site_searcher.default_config(preset='default')[source]
The full set of knobs with their default values, as a plain dictionary.
Every key the pipeline understands appears here, which is the point: a template with holes in it silently falls back for whatever it omits, and the fallbacks are the least visible input the tool has.
- Parameters:
- presetstr, optional
"default", or"lima"/"arequipa"to fill in that region’s origin, RFI zones, name and DEM filename.
- Returns:
- dict
Configuration, ready to serialise or to pass to the pipeline.
- Raises:
- ValueError
If the preset is not one of
CONFIG_PRESETS.
Examples
>>> from oroscope import site_searcher as ss >>> cfg = ss.default_config("arequipa") >>> cfg["rfi_zones"], cfg["min_slope_deg"], cfg["explain"] ('arequipa', 3.0, True)
- oroscope.site_searcher.generate_config(path, preset='default')[source]
Writes a configuration template to
path, creating its directory if needed.What
--generate_configdoes, available to anyone driving the pipeline in a loop or generating a family of runs.- Parameters:
- pathstr
Destination JSON file.
- presetstr, optional
As
default_config().
- Returns:
- dict
The configuration that was written.
Notes
A generated config names every key, so it also overrides every fallback. That is intended – but note the command line still wins over both, which it did not always do.
- oroscope.site_searcher.load_config(path)[source]
Reads a configuration JSON, resolving its relative paths against its own directory.
- Parameters:
- pathstr
Path to the file.
- Returns:
- dict
Its contents, or an empty dictionary when the file does not exist – which is how the command line has always treated a missing
--config_path, and matching it here keeps one behaviour rather than two.Path-valued keys (
dem_path,road_map_path,resume_dir) come back absolute, resolved against the directory holding the configuration rather than the working directory. Seeresolve_config_paths().
- Raises:
- json.JSONDecodeError
If the file exists but is not valid JSON. Unlike a missing file, that is a mistake worth failing on.
- oroscope.site_searcher.estimate_peak_memory_gb(rows, cols, downsample_factor=1, candidate_stride=5, survival_fraction=0.6, n_observables=12, n_scoring_arrays=24)[source]
Rough estimate of the anonymous memory one search will need, in GiB.
Only the allocations that can exhaust RAM are counted. The DEM itself is memory-mapped and file-backed, so the kernel can evict it under pressure and it is excluded deliberately – counting it would make every large search look impossible when the streaming design exists precisely so that it is not.
This is an estimate and says so.
survival_fractionin particular is the fraction of pixels passing the topographic screen, which is terrain-dependent and not known until the screen has run; 0.6 is typical of Andean terrain at a 3-25 degree band. It is meant to catch the order-of-magnitude mistake – a full DEM atdownsample_factor: 1– rather than to predict a number.- Parameters:
- rows, colsint
DEM dimensions in pixels.
- downsample_factorint, optional
Factor at which sites are labelled and areas measured. Scales the labelling arrays as its inverse square, and nothing else – see the note above.
- candidate_strideint, optional
Keeps every Nth screened pixel. Scales the dominant term directly.
- survival_fractionfloat, optional
Fraction of pixels expected to pass the topographic screen.
- n_observablesint, optional
Per-candidate arrays the scan returns.
- n_scoring_arraysint, optional
Further per-candidate arrays live at the peak, inside
compose: the score components, the float64 copy made of each, and the temporaries.
- Returns:
- float
Estimated peak anonymous memory, in GiB.
Notes
The peak is in the scoring, not in the scan. This counted only the arrays
arrival_scan.scanreturns, which is not where the high-water mark is: by the timescoring.compose()runs, the scan’s arrays are still live, a score component has been built alongside them for each criterion,composehas clipped a float64 copy of every component, and the composition and the scoring intermediates need several more. About three times the scan’s own count is live at once, all of itn_candlong.Under-counting that term is not academic: it advertised 2.32 GiB for the full Arequipa DEM, which then peaked at 5.68 GiB measured RSS and died against its own cap 23 minutes in.
n_scoring_arraysis calibrated on that run – 15.1M candidates, 7 components – where the anonymous share of the peak implies ~36 live per-candidate arrays against the 12 this modelled.Re-measured after the audit, the same run peaks at 6.59 GiB resident and 7.80 GiB virtual, against an estimate of 5.08. So this remains optimistic by ~1.5 GiB on the run it was calibrated against, and the calibration above is left as it was rather than quietly re-fitted:
n_scoring_arraysmoves the pre-flight for every region, and changing it to chase one number is how the estimate came to be sized against the cheaper of two configurations in the first place. What matters for a caller is the other column – what this function estimates is anonymous memory, and what--max-memory-gbcaps is address space, 1.2 GiB larger here. Do not set one from the other.Note also which knob moves it.
downsample_factorscales only the labelling and gradient terms, because candidates are taken on the native grid; at full-DEM scale the per-candidate terms dominate, so going from 1 to 4 cuts the estimate by about 1.4x rather than the 16x the inverse-square scaling suggests. To move the dominant term, raisecandidate_strideor crop the DEM.Examples
>>> from oroscope import site_searcher as ss >>> round(ss.estimate_peak_memory_gb(1981, 3061, downsample_factor=1), 2) 0.77 >>> round(ss.estimate_peak_memory_gb(10204, 12603, downsample_factor=4), 2) 5.08
Downsampling is the weaker of the two levers at this scale, and striding the stronger, because the candidates are taken on the native grid either way:
>>> round(ss.estimate_peak_memory_gb(10204, 12603, downsample_factor=1), 2) 7.21 >>> round(ss.estimate_peak_memory_gb(10204, 12603, downsample_factor=4, ... candidate_stride=10), 2) 2.83
- oroscope.site_searcher.estimate_visualisation_memory_gb(rows, cols, downsample_factor=1, combine=False)[source]
Memory the map costs, in GiB, which is not what the search costs.
estimate_peak_memory_gb()models the candidate and scoring arrays and nothing else, because those are what a search allocates. The map is a separate peak that lands on top of them at the very end, and it is the stage that has actually been failing: three runs in one session finished their searches and then died drawing the picture, having written the JSON and the GeoTIFF first. A pre-flight that models only the search will size a cap that cannot survive the run.The map renders at
viz_ds = downsample_factor * 2, so its raster isrows/(2d) x cols/(2d). Measured on this machine over 0.35 to 5.6 Mpx of viz raster – shading throughLightSource.shadeand saving at 150 dpi:viz raster
peak RSS above idle
700 x 500 (0.35 Mpx)
126 MB
1400 x 1000 (1.4 Mpx)
263 MB
1981 x 1441 (2.85 Mpx)
539 MB
2800 x 2000 (5.6 Mpx)
959 MB
which is ~190 bytes per viz pixel once matplotlib’s own ~130 MB of import and canvas is taken out. Both terms are carried here.
The combination is a different, larger stage, and was not modelled at all.
combine_experimentsrenders at the mask’s own resolution rather than atviz_ds, so its raster is four times the search map’s, and it draws the two experiments and their overlap on top of the relief. Sized by the search map’s numbers it looked cheap, ran last, and is what actually kept failing. Measured over 2.0 to 12.5 Mpx of raster, with the overlay composited in float32:combine raster
peak RSS
1276 x 1576 (2.0 Mpx)
0.46 GiB
1806 x 1740 (3.1 Mpx)
0.63 GiB
2551 x 3151 (8.0 Mpx)
1.31 GiB
3200 x 3900 (12.5 Mpx)
1.95 GiB
which fits 182 MiB + 152.2 bytes per pixel to within 1.2% at every point. Four points rather than the one the search estimator is calibrated on.
- Parameters:
- rows, colsint
Full-resolution DEM dimensions.
- downsample_factorint, optional
As the pipeline’s. The search map uses twice it; the combination uses it as-is.
- combinebool, optional
Estimate the combination overlay instead of a search’s own map. It renders at four times the pixels and costs about 2.7x as much at DEM scale.
- Returns:
- float
Estimated peak for that one figure, in GiB.
Examples
>>> from oroscope import site_searcher as ss >>> round(ss.estimate_visualisation_memory_gb(3961, 2881, 1), 2) 0.63
The same DEM’s combination, which the search’s own estimate does not cover:
>>> round(ss.estimate_visualisation_memory_gb(3961, 2881, 1, combine=True), 2) 1.8
- oroscope.site_searcher.apply_memory_cap(max_memory_gb)[source]
Caps this process’s address space, so a runaway fails instead of taking the machine.
Without a cap, an over-large search does not fail: it grows until the kernel’s OOM killer chooses a victim, which may well be something else the user cares about. A
MemoryErrorinside this process is a far better outcome than a dead editor, and it names the run that caused it.- Parameters:
- max_memory_gbfloat or None
Ceiling in GiB.
Noneor non-positive leaves the limit alone.
- Returns:
- bool
Whether a cap was applied. False on platforms without
RLIMIT_AS.
Notes
RLIMIT_ASlimits virtual address space, which is larger than resident memory: numba and BLAS reserve address space they never touch. Set it generously – a little above physical RAM is usually right – or it will refuse runs that would have fit.
- oroscope.site_searcher.available_memory_gb()[source]
Memory the system can give us right now, in GiB, or None if it cannot be told.
Reads
MemAvailablefrom/proc/meminfo, which accounts for reclaimable page cache and so is the figure that matters;freealone understates it badly on a machine that has been running a while.- Returns:
- float or None
Available memory in GiB, or
Noneon a platform without/proc/meminfo.
- oroscope.site_searcher.preflight_memory(dem_path, downsample_factor=1, candidate_stride=5, max_memory_gb=None, quiet=False, refuse=False, combine=False)[source]
Estimates what a search will need, says so, and caps the address space.
This ran only inside
main(), so a library user – a sweep, a notebook, a service – got neither the warning nor the cap unless they knew to ask for both. That is exactly the caller most likely to need them: a ten-point sweep once reached 6.9 GB and was killed by the OOM killer, taking other work with it.- Parameters:
- dem_pathstr
DEM whose dimensions set the estimate. An unreadable file skips the estimate but not the cap.
- downsample_factorint, optional
As the pipeline’s. Dominates the estimate: the labelling arrays scale as its inverse square.
- candidate_strideint, optional
As the pipeline’s.
- max_memory_gbfloat, optional
Ceiling in GiB.
Noneuses 80% of what the system reports available, so the cap bites before the kernel does; 0 disables capping entirely.- quietbool, optional
Suppress the printed report, keeping the cap and the returned numbers.
- refusebool, optional
Raise
MemoryErrorinstead of warning when the estimate exceedsREFUSE_FRACTIONof what is available. DefaultFalse, which only warns — but warning is what it did while three runs died anyway, so a caller that can afford to stop should passTrue.- combinebool, optional
Also account for a combination overlay after the searches. It renders at four times the search map’s pixels and was not modelled at all, so a region could clear this pre-flight and then die at the last step — which is what happened, twice. The stages are sequential rather than simultaneous, so the number judged is
max(search + map, combine)rather than their sum.
- Returns:
- dict
{"estimate_gb", "search_gb", "visualisation_gb", "combine_gb", "available_gb", "cap_gb", "capped", "cap_exceeds_available"}.estimate_gbis the largest stage the run will pass through, and isNonewhen the DEM could not be measured.combine_gbisNoneunlesscombinewas asked for.
- Raises:
- MemoryError
When
refuseis set and the estimate exceedsREFUSE_FRACTIONof the available memory.
Notes
The estimate is rough by construction – it assumes a survival fraction the topographic screen has not yet measured – so an over-large search is warned about rather than refused.
A cap above what is available is not a cap.
RLIMIT_ASprotects the machine only if the process hits it before the kernel runs out of memory to give; set above the available figure, the OOM killer arrives first and the limit never fires. This is easy to do by accident, because the two pieces of advice around it pull opposite ways: the estimate counts only anonymous memory, so on a large DEM the cap has to clear it by the size of the memory-mapped file (roadmap 6.46), and raising it for that reason can quietly carry it past the available figure. It happened here – a 339 Mpx search capped at 13.0 GiB on a machine with 8.0 GiB available, which took the machine down. When the two constraints cannot both be met, the configuration does not fit and the answer is a coarser one, not a bigger number. Warned about rather than refused, for the same reason as the estimate: the available figure moves while the run is being set up.
- oroscope.site_searcher.emit_explanation(results, run_output_dir=None, print_it=True)[source]
Composes the run’s plain-language summary, prints it, and saves it beside the run.
A thin wrapper over
explain.explain_results(): the words themselves are that function’s business, so they can be regenerated from an old results file without this one. What is added here is the placement – last on the console, so it is what a reader is left with, and inexplanation.txtso the run can be handed on without the terminal it was run in.Failures are reported and swallowed. A summary that cannot be written is not a reason to lose a search that already succeeded.
- Parameters:
- resultsdict
The run’s results. Gains an
"explanation"key.- run_output_dirstr, optional
Directory to write
explanation.txtinto. Omitted writes no file.- print_itbool, optional
Whether to print. False still composes and stores the text.
- Returns:
- str or None
The summary, or
Noneif it could not be composed.
- oroscope.site_searcher.resolve_config_paths(config, config_dir, quiet=False)[source]
Makes a configuration’s relative paths absolute, against the config’s own directory.
A configuration that says
"dem_path": "../input/dem/colca.tif"is describing where the DEM sits relative to itself, which is the only thing it can know. The pipeline resolved it against the working directory instead, so the bundled configs ran only fromsrc/and produced a FileNotFoundError anywhere else – the long-standing “you mustcd srcfirst” wart.A path that does not resolve against the configuration’s directory but does resolve against the working directory is left alone, with a warning: that is the old behaviour, and silently breaking someone’s working setup to fix a wart is a poor trade. Absolute paths are untouched.
- Parameters:
- configdict
A configuration mapping. Not modified; a copy is returned.
- config_dirstr
Directory holding the configuration file.
- quietbool, optional
Suppress the warning about a working-directory-relative fallback.
- Returns:
- dict
A copy with the path keys resolved.
Examples
The repository’s own layout is why this is safe to change:
config/andsrc/are both one level below the root, so../input/dem/colca.tifnames the same file read either way, and no shipped configuration has to change.>>> import os >>> from oroscope import site_searcher as ss >>> a = os.path.normpath(os.path.join("/repo/config", "../input/dem/colca.tif")) >>> b = os.path.normpath(os.path.join("/repo/src", "../input/dem/colca.tif")) >>> a == b == "/repo/input/dem/colca.tif" True
An absolute path is left as it is:
>>> cfg = ss.resolve_config_paths({"dem_path": "/data/x.tif"}, "/repo/config") >>> cfg["dem_path"] '/data/x.tif'
- oroscope.site_searcher.stride_gap_m(candidate_stride, cell_size_y_m)[source]
Distance between kept candidates, in metres, after striding.
candidate_stridesubsamples the list of surviving pixels rather than the map, so the gap it leaves is a stride’s worth of pixels along a scanline.- Parameters:
- candidate_strideint
Keeps every Nth surviving pixel.
- cell_size_y_mfloat
Metric pixel size, N-S.
- Returns:
- float
Gap between kept candidates, in metres.
- oroscope.site_searcher.closing_element_m(gap_close_km, antenna_spacing_km)[source]
Size of the morphological closing element in metres, defaulting as the pipeline does.
- Parameters:
- gap_close_kmfloat or None
Closing element in km.
Nonedefaults toantenna_spacing_km.- antenna_spacing_kmfloat
Detector spacing, which the closing element defaults to.
- Returns:
- float
Closing element size, in metres.
- oroscope.site_searcher.warn_stride_outruns_closing(candidate_stride, cell_size_y_m, gap_close_km, antenna_spacing_km, quiet=False)[source]
Warns when the closing element is too small to bridge the gaps striding leaves.
Striding is unbiased in acceptance – measured at both scales, 58.414% against 58.415% for GRAND and 75.750% against 75.736% for TAMBO – so it is tempting to treat it as free. It is not. Accepted pixels are marked one in
candidate_stride, and the mask is then closed morphologically before areas are measured. If the closing element is smaller than the gap the stride leaves, the mask never reconnects: it stays a scatter of isolated pixels, small regions fall below the size and capacity thresholds, and the reported area collapses.Measured at Colca against a 154 m stride-5 gap: at TAMBO’s published 150 m element, 203.0 km² reported against 307.2 km² at stride 1, a 1.51x under-report. At the 100 m element this warning was written for – three pixels against a five-pixel gap – the same comparison was 83.6 against 396.9 km², 4.75x. Acceptance is identical to three decimal places either way. The same run at GRAND’s 1 km element, 32 px against the same gap, is unaffected, which is why this went unnoticed.
The 150 m figure is the one to quote, and the drop from 4.75x to 1.51x is the whole argument for heeding this warning: two pixels of element is the difference between the two sides of a cliff.
The rule is simply that the element must outrun the gap. Raise
gap_close_km, lowercandidate_stride, or accept the area as a lower bound and say so.- Parameters:
- candidate_strideint
Keeps every Nth surviving pixel.
- cell_size_y_mfloat
Metric pixel size, N-S.
- gap_close_kmfloat or None
Closing element in km.
Nonedefaults toantenna_spacing_km.- antenna_spacing_kmfloat
Detector spacing, which the closing element defaults to.
- quietbool, optional
Suppress the printed warning, keeping the returned verdict.
- Returns:
- dict or None
{"gap_m", "element_m", "ratio"}when the element cannot bridge the gap, andNonewhen it can.
Examples
GRAND’s 1 km element easily bridges a stride-5 gap at 30 m pixels:
>>> from oroscope import site_searcher as ss >>> ss.warn_stride_outruns_closing(5, 30.72, None, 1.0, quiet=True) is None True
TAMBO’s element does not – not at the old 100 m and not at the published 150 m either, which is why striding costs it so much area:
>>> r = ss.warn_stride_outruns_closing(5, 30.72, None, 0.15, quiet=True) >>> round(r["gap_m"]), round(r["element_m"]), round(r["ratio"], 2) (154, 150, 1.02)
>>> r = ss.warn_stride_outruns_closing(5, 30.72, None, 0.1, quiet=True) >>> round(r["ratio"], 2) 1.54
Closing disabled entirely is not this failure, so it does not warn:
>>> ss.warn_stride_outruns_closing(5, 30.72, 0.0, 0.1, quiet=True) is None True
- oroscope.site_searcher.add_scale_bar(ax, km_per_x_unit, fraction=0.22, colour='black')[source]
Draws a kilometre scale bar on a map, and returns the length it chose.
A map axis labelled in degrees or in pixels does not tell a reader how far anything is, and neither unit converts to distance without knowing where on the Earth it sits: a degree of longitude at Arequipa is 4% shorter than a degree of latitude, and a pixel is whatever the DEM says it is.
Taking
km_per_x_unitrather than a latitude keeps one function usable by both maps this project writes – the search map, whose axes are pixels, and the combination overlay, whose axes are degrees.- Parameters:
- axmatplotlib.axes.Axes
Axes to draw on. Its limits must already be final; the bar is placed relative to them.
- km_per_x_unitfloat
Kilometres per unit of the x axis. For degrees of longitude that is
111.32 * cos(latitude); for pixels it is the metric pixel size over 1000.- fractionfloat, optional
Roughly what fraction of the map width the bar should span, before rounding to a human number.
- colourstr, optional
Bar colour. The default reads on both terrain and shaded relief.
- Returns:
- float
Length of the bar drawn, in km. Always 1, 2 or 5 times a power of ten.
Examples
A two-degree map at 16 degrees south is about 214 km wide, so it gets a 50 km bar:
>>> import matplotlib; matplotlib.use("Agg") >>> import matplotlib.pyplot as plt, numpy as np >>> from oroscope import site_searcher as ss >>> fig, ax = plt.subplots() >>> _ = ax.set_xlim(-73.0, -71.0); _ = ax.set_ylim(-17.0, -15.0) >>> ss.add_scale_bar(ax, 111.32 * np.cos(np.radians(-16.0))) 50.0
The same function on a pixel axis, 3061 pixels of 30 m:
>>> _ = ax.set_xlim(0, 3061); _ = ax.set_ylim(1981, 0) >>> ss.add_scale_bar(ax, 0.030) 20.0 >>> plt.close(fig)
- oroscope.site_searcher.altitude_limits(elevation, low_percentile=0.5, high_percentile=99.8)[source]
Altitude range for a colour scale, from the DEM rather than from a constant.
A fixed 0-6000 m scale spends most of its range on altitudes a given DEM does not contain: the Colca crop runs 1500-6300 m, so half the colour bar described ground that is not in the picture and the relief that is there got half the contrast it could have had.
Percentiles rather than the extremes, because one spurious pixel – a nodata sentinel, a spike – otherwise sets the whole scale. Water is excluded from the upper end and pinned to the bottom, so a coastal DEM does not spend a third of its range on ocean.
- Parameters:
- elevationndarray
Elevation in metres. NaN is ignored.
- low_percentile, high_percentilefloat, optional
Percentiles of the land pixels to clip to.
- Returns:
- tuple of float
(vmin, vmax)in metres, rounded outward to a round number.
Examples
>>> import numpy as np >>> from oroscope import site_searcher as ss >>> z = np.linspace(1500.0, 6300.0, 1000) >>> ss.altitude_limits(z) (1500.0, 6300.0)
Ocean does not drag the floor down with it:
>>> z = np.concatenate([np.zeros(500), np.linspace(2000.0, 5000.0, 500)]) >>> ss.altitude_limits(z) (0.0, 5000.0)
- oroscope.site_searcher.add_north_arrow(ax, x=0.965, y=0.955, size=0.055)[source]
Draws a north arrow in axes coordinates.
Both maps this project writes are north-up, so the arrow is a convention rather than information – but a map without one asks the reader to assume, and a map in a talk gets read by people who did not make it.
- Parameters:
- axmatplotlib.axes.Axes
Axes to draw on.
- x, yfloat, optional
Position of the arrow’s tip, in axes coordinates.
- sizefloat, optional
Length of the arrow, as a fraction of the axes height.
- oroscope.site_searcher.attach_colorbar(fig, ax, mappable, label, width='2.6%', pad=0.12, **kwargs)[source]
Adds a colour bar whose height matches the plot panel exactly.
fig.colorbar(..., fraction=...)sizes the bar as a fraction of the figure, so on a map whose aspect is set by its data – which every map here is – the bar overshoots the panel top and bottom by however much the axes shrank to fit. Taking the space out of the axes’ own divider instead ties the two together whatever the aspect turns out to be.- Parameters:
- figmatplotlib.figure.Figure
Figure the axes belong to.
- axmatplotlib.axes.Axes
Axes to take the space from.
- mappablematplotlib.cm.ScalarMappable
What the bar describes.
- labelstr
Axis label for the bar.
- widthstr, optional
Bar width, as a percentage of the axes width.
- padfloat, optional
Gap between panel and bar, in inches.
- **kwargs
Passed to
fig.colorbar–extend, for instance.
- Returns:
- matplotlib.colorbar.Colorbar
- oroscope.site_searcher.resolve_settlements(value, bounds=None)[source]
Settlements to mark on a map: a preset name, an explicit list, or
"auto"."auto"picks whichever curated list has points inside the map, which is what a reader wants without having to say so, and marks nothing when neither does rather than guessing at a region the project has no coordinates for.No coordinates are invented here. The lists are the named places already curated as RFI zones; adding more means supplying them, not asking this function to remember them.
- Parameters:
- valuestr, list, or None
"auto", a preset name,"none"/None, or a list of(latitude, longitude, name).- boundstuple, optional
(south, north, west, east)in degrees, used by"auto".
- Returns:
- list
(latitude, longitude, name)triples, possibly empty.
Examples
>>> from oroscope import site_searcher as ss >>> [n for _, _, n in ss.resolve_settlements("arequipa")][:2] ['Arequipa', 'Majes']
Auto picks the list with points on the map, and nothing when none has any:
>>> arequipa = (-17.5, -14.5, -73.6, -70.3) >>> len(ss.resolve_settlements("auto", arequipa)) 5 >>> ss.resolve_settlements("auto", (40.0, 42.0, 0.0, 2.0)) [] >>> ss.resolve_settlements(None) []
- oroscope.site_searcher.add_settlements(ax, settlements, to_axes, fontsize=9, max_labels=6, max_markers=25)[source]
Marks settlements, with their names, wherever they fall inside the axes.
- Parameters:
- axmatplotlib.axes.Axes
Axes to draw on. Its limits must already be final.
- settlementssequence
(latitude, longitude, name)triples.- to_axescallable
to_axes(latitude, longitude) -> (x, y)in the axes’ own units, so this serves both the pixel-coordinate search map and the degree-coordinate overlay.- fontsizeint, optional
Fallback label size, for entries carrying no place class.
- max_labelsint, optional
How many to name.
- max_markersint, optional
How many to mark at all. Entries arrive most-important-first – cities, then towns, then villages, by population within each – so both caps take the ones worth showing. Over the full Arequipa DEM there are 1,268 places, and drawing every one buries the result under a thousand white dots; over one canyon there are sixty, and naming them all is not a map either.
- Returns:
- int
How many were drawn. Points outside the axes are skipped rather than clipped, so a label cannot appear at the edge pointing at nothing.
- oroscope.site_searcher.add_roads(ax, roads, to_axes, colour='#00B33C', alpha=0.9, scale=1.0)[source]
Draws road geometry, as context rather than as a result.
Access is the question a site count cannot answer: a canyon wall that takes a hundred detectors means something different with a road along the rim than with the nearest track forty kilometres away. Roads are drawn in a neutral dark line rather than a colour, because on these maps colour belongs to the categories and a road is not one of them – it is what the reader measures a category against.
Uses one
LineCollectionper class rather than a call per road. The Arequipa DEM carries 8,780 roads and 367,000 vertices; drawn individually that is slow enough to notice, and matplotlib renders the collection in one pass.- Parameters:
- axmatplotlib.axes.Axes
Axes to draw on. Its limits must already be final.
- roadsdict or None
As
fetch_roads.load_roads()returns.Nonedraws nothing, so a missing road file leaves a map without roads rather than without a map.- to_axescallable
to_axes(latitude, longitude) -> (x, y)in the axes’ own units, so this serves both the pixel-coordinate search map and the degree-coordinate overlay.- colourstr, optional
Line colour.
- alphafloat, optional
Line opacity.
- scalefloat, optional
Multiplies every width, for maps drawn at a different size.
- Returns:
- int
How many roads were drawn.
Explaining a run
Turning a results dictionary into an account of what was found and why: which constraint set the size of the answer, what held the surviving sites back, and which of the numbers are assumptions. Pure, so an old results file can be explained months later with no DEM and no pipeline.
Turning a results file into an account of what was found and why.
Everything this module says is already in the results JSON. What is missing there is the story: which constraint did the work, which sites survived and what weakened them, and which of the numbers on the page are choices rather than measurements. A reader who assembles that themselves gets it wrong in predictable ways – most often by reading the reported area as physics-accepted area, which it is not (see Assumptions and limitations, and the 2.35x measured at Colca).
The entry point is explain_results(), which takes the results dictionary and
returns a string. It runs nothing, opens nothing and needs no DEM, so the pipeline,
the library and a test can all use the same words, and the summary can be regenerated
from an old run’s JSON long after the run.
Deliberately plain text: these summaries are meant to be handed to other people, and ANSI colour does not survive a paste into an email.
- oroscope.explain.explain_results(results, provenance=None)[source]
Writes a human-readable account of one search: what was found, and why.
Everything it reports is already in the results dictionary. The value added is the reading: which stage of the funnel actually set the size of the answer, what held the surviving sites back, and which of the numbers are assumptions. Those are the three things a reader gets wrong without help, and a run that is going to be handed to someone else needs all three on the same page as the result.
Pure: it opens no files, runs nothing, and needs no DEM. An old run’s JSON can be explained months later, and the pipeline, the library and the tests all get the same words.
- Parameters:
- resultsdict
A results dictionary as returned by
site_searcher.find_grand_regions_interactive()and written to the run’s results JSON. Missing sections are tolerated: each is reported as absent rather than raising, so a partial or older file still explains.- provenancedict, optional
The matching
provenance.jsoncontents – git commit, DEM checksum, package versions. Adds the reproducibility block when given.
- Returns:
- str
The summary, as plain text with no ANSI colour, ready to print or to save beside the results.
Examples
>>> from oroscope import explain >>> text = explain.explain_results({ ... "funnel": {"DEM pixels": 1000, "slope 3-25 deg": 900, ... "directions accepted": 12}, ... "results": {"total_sites": 0, "total_capacity": 0, "sites": []}}) >>> "WHERE THE CANDIDATES WENT" in text True >>> "directions accepted" in text True
- oroscope.explain.explain_combination(report, runs=None)[source]
Explains an overlay of two or more searches: who can share ground with whom, and why.
The combined report gives a joint area and a Jaccard index. Neither says why the number is what it is, and the reason is usually not about neutrinos at all: a pixel has one slope, and every experiment deployed on it must accept that slope. Measured at Colca, the entire co-location result follows from GRAND’s 3-25 degree deployable band against a canyon’s ~40 degree walls.
So where the runs’ own parameters are available, this compares their screening bands and names the one that limits the sharing.
- Parameters:
- reportdict
A
combined_report.json, ascombine_experimentswrites it.- runsdict, optional
Label to results dictionary, for the runs being combined. Supplying them adds the constraint comparison; without them only the areas are explained.
- Returns:
- str
The summary, as plain text.
Examples
>>> from oroscope import explain >>> text = explain.explain_combination({ ... "runs": [{"label": "A", "area_km2": 100.0, "pixels": 10, ... "area_in_joint_km2": 20.0, "fraction_of_own_area_in_joint": 0.2, ... "reported_sites": 1, "reported_capacity": 500}], ... "joint": {"area_km2": 20.0}, "union": {"area_km2": 100.0}, ... "joint_requires": ["A"], "pairwise_overlap": {}}) >>> "WHERE THESE EXPERIMENTS CAN SHARE GROUND" in text True
- oroscope.explain.binding_constraint(funnel)[source]
Finds the funnel stage that removed the largest share of what reached it.
This is the single most useful sentence a summary can offer, and it matters most when a search returns little or nothing: the stage where the survivor count collapses is the constraint responsible, and every other explanation is a guess.
Two stages are excluded by construction.
kept by strideis a deliberate subsample whose acceptance is unbiased, so calling it a constraint would name the same answer on nearly every run;after gap closingadds pixels rather than removing them.- Parameters:
- funneldict
Ordered stage-name to survivor-count mapping, as written to
results["funnel"].
- Returns:
- dict or None
{"stage", "survivors", "before", "kept_fraction", "knob", "fatal"}, orNonewhen the funnel has fewer than two stages.fatalis True when the stage left nothing at all.
Examples
>>> from oroscope import explain >>> f = {"DEM pixels": 1000, "slope 3-25 deg": 800, "directions accepted": 40} >>> b = explain.binding_constraint(f) >>> b["stage"], round(b["kept_fraction"], 3), b["fatal"] ('directions accepted', 0.05, False) >>> explain.binding_constraint({"DEM pixels": 1000}) is None True
- oroscope.explain.weakest_component(arrival_scan, statistic='p50')[source]
Names the score component that held a site back, and its value.
The score is a product of components each in [0, 1] and each named, so a low total can be attributed rather than merely reported. The lowest median component is the one to look at first: under a product composition it bounds the total from above.
- Parameters:
- arrival_scandict
A site’s
arrival_scanrecord.- statisticstr, optional
Which per-site statistic to compare,
"mean","p50"or"p90".
- Returns:
- tuple or None
(name, value)for the lowest-scoring component, orNonewhen the record carries no components – which is the case for runs made before they were stored.
Examples
>>> from oroscope import explain >>> rec = {"score_p50": 0.2, "score_decay_p50": 0.9, "score_shower_p50": 0.22} >>> explain.weakest_component(rec) ('shower', 0.22) >>> explain.weakest_component({"score_p50": 0.2}) is None True
- oroscope.explain.site_strengths(arrival_scan, statistic='p50', threshold=0.75)[source]
Why a site is good: the criteria it satisfies well, and the measurement behind each.
The mirror of
weakest_component(), and the more useful half when a site has been selected. “Site 3555 scored 0.55” says nothing a reader can act on; “it sees 0.36 sr of usable sky across a 3.1 km gap with 780,000 g/cm² of rock behind it, and every criterion but the accepted solid angle is satisfied outright” says what the ground is actually like.- Parameters:
- arrival_scandict
A site’s
arrival_scanrecord.- statisticstr, optional
Which per-site statistic to read,
"mean","p50"or"p90".- thresholdfloat, optional
Score at or above which a component counts as satisfied. 0.75 rather than 1.0 because a band score falls off smoothly either side of its plateau, so insisting on exactly 1 would report nothing on most real sites.
- Returns:
- list of dict
One entry per satisfied component, strongest first, each with
name,label,score,meansand – where the record carries the observable behind it –evidence. Empty when the record has no components.
Examples
>>> from oroscope import explain >>> rec = {"score_solid_angle_p50": 0.9, "score_depth_p50": 1.0, ... "solid_angle_sr_p50": 1.08, "max_depth_gcm2_p50": 784440.0} >>> [s["name"] for s in explain.site_strengths(rec)] ['depth', 'solid_angle'] >>> explain.site_strengths(rec)[1]["evidence"] '1.08 sr'
- oroscope.explain.constraint_overlap(params_a, params_b, bands=None)[source]
Where two experiments’ screening bands agree, and by how little.
This is what decides whether two experiments can share ground, and it is decided before any arrival geometry is considered: a pixel has one slope, and both experiments must accept it. Measured at Colca, that is the whole story – GRAND’s 3-25 degree deployable band against a canyon’s ~40 degree walls leaves a 20-25 degree sliver, and the joint area follows from that rather than from anything about neutrinos.
- Parameters:
- params_a, params_bdict
The two runs’ recorded
parametersblocks.- bandssequence, optional
Which bands to compare, as
(label, low key, high key, unit). Defaults to the properties of the ground itself – slope, altitude, aspect – which are the only ones both experiments must agree on. Pass_VIEWING_BANDSto compare what each asks of the view instead, which need not agree at all.
- Returns:
- list of dict
One entry per band both runs recorded:
label,a,b,overlap(a(low, high)pair orNone),width, andshare_of_narrower– the fraction of the tighter of the two bands that the overlap covers, which is the number that says how much room there is to share.
Examples
>>> from oroscope import explain >>> a = {"min_slope_deg": 3.0, "max_slope_deg": 25.0} >>> b = {"min_slope_deg": 20.0, "max_slope_deg": 60.0} >>> band = explain.constraint_overlap(a, b)[0] >>> band["overlap"], round(band["share_of_narrower"], 3) ((20.0, 25.0), 0.227)
- oroscope.explain.closing_inflation(funnel, candidate_stride=1)[source]
How much morphological closing grew this run’s mask, measured from its own funnel.
The 2.35x quoted from Colca is a property of that terrain and a 1 km element, not a constant. This run has the number in it: the stage before closing counts the accepted candidates, closing counts the pixels after, and the only correction needed between them is the stride – which samples one candidate in
candidate_strideand was measured unbiased.A ratio below 1 is not a bug and is worth reading carefully: it means the closing element was too small to bridge the gaps striding left, so the mask under-reports the accepted set rather than inflating it. That happens when the element is a few pixels across, as it is at a 100 m detector spacing.
- Parameters:
- funneldict
Ordered stage-name to survivor-count mapping.
- candidate_strideint, optional
The run’s
candidate_stride, to scale the accepted count back to full resolution.
- Returns:
- float or None
Closed pixels divided by estimated accepted pixels, or
Nonewhen the funnel does not record both.
Examples
>>> from oroscope import explain >>> f = {"directions accepted": 100, "after gap closing": 450} >>> round(explain.closing_inflation(f, candidate_stride=5), 2) 0.9
- oroscope.explain.selected_sites(results)[source]
The sites actually in the result, separated from the ones that merely qualified.
results["results"]["sites"]lists every site that cleared the area and capacity thresholds, which withstop_at_targetis more than were selected: selection walks the capacity-sorted list until the target is met and stops.total_sites,total_capacityand the exported mask all cover the selection only, so summing the list over-reports area and site count against every other number in the file.- Parameters:
- resultsdict
A results dictionary.
- Returns:
- selectedlist of dict
Sites in the result.
- rejectedlist of dict
Sites that qualified but were not selected. Usually empty.
Notes
Prefers each record’s
selectedflag. Files written before that flag existed fall back to the firsttotal_sitesentries, which is exact: the list is sorted by capacity and selection takes it in order.Examples
>>> from oroscope import explain >>> r = {"results": {"total_sites": 1, "sites": [ ... {"site_id": 2, "capacity_exact": 252, "selected": True}, ... {"site_id": 1, "capacity_exact": 36, "selected": False}]}} >>> chosen, rest = explain.selected_sites(r) >>> [s["site_id"] for s in chosen], [s["site_id"] for s in rest] ([2], [1])
Tools
Cuts a lat/lon window out of a DEM into a smaller GeoTIFF.
Two reasons this exists. A regional study wants one canyon or one plateau rather than the whole 10204x12603 tile the download gives you. And comparing experiments requires them to cover identical ground: the combiner overlays masks pixel for pixel, so GRAND and TAMBO have to be run against the same crop, not two crops that happen to overlap.
The window is snapped outward to whole pixels and the tiepoint recomputed for the crop’s own corner, so the output is georeferenced in its own right.
python crop_dem.py ../input/dem/arequipa_SRTMGL1.tif ../input/dem/colca.tif –north -15.30 –south -15.80 –west -72.40 –east -71.60
- oroscope.crop_dem.crop(src: str, dst: str, north: float, south: float, west: float, east: float) dict[source]
Cuts a geographic window out of a DEM, writing a GeoTIFF that stands on its own.
The point of cropping is not disk space, it is sampling: a department at
downsample_factor4 andcandidate_stride5 costs area and fragments a thin mask, while a crop small enough to run at 1 and 1 does neither. Every unbiased number this project quotes comes from a crop made here.The window is snapped outward to whole pixels, so the result contains the requested box rather than approximating it, and is clipped to the DEM. A window that misses the DEM entirely raises rather than writing an empty file.
The crop carries its own tiepoint, not the parent’s. That is what lets it be searched with no reference back to where it came from — and it is why a crop’s
origin_lat/origin_londiffer from the parent’s, which is correct and not drift.- Parameters:
- srcstr
The GeoTIFF to cut from.
- dststr
Where to write the crop.
- north, southfloat
Latitude bounds in degrees.
northis the larger (less negative) value.- west, eastfloat
Longitude bounds in degrees.
- Returns:
- dict
path, the grid asrowsandcols, the crop’s ownorigin_latandorigin_lonwith the matchingsouthandeastedges,cell_size_deg, and the elevation range asz_minandz_max.
- Raises:
- SystemExit
If the requested window does not overlap the DEM, reporting what the DEM actually covers.
Examples
Cutting the Colca crop out of the Arequipa department DEM, which is how
input/dem/colca.tifwas made:from oroscope import crop_dem info = crop_dem.crop("input/dem/arequipa_SRTMGL1.tif", "input/dem/colca.tif", north=-15.30, south=-15.85, west=-72.40, east=-71.55) print(info["rows"], info["cols"], info["z_min"], info["z_max"])
- oroscope.crop_dem.read_geo(path: str) tuple[float, float, float, float, int, int][source]
Pixel size in degrees and the north-west corner, from the GeoTIFF tags.
- Parameters:
- pathstr
Path to a geographic GeoTIFF carrying
ModelPixelScaleTagandModelTiepointTag.
- Returns:
- tuple
(cell_x_deg, cell_y_deg, lon0, lat0, rows, cols), withlon0/lat0the north-west corner.
Combines the results of two or more experiment searches over the same ground.
GRAND and TAMBO ask the same structural question – from this patch of ground, is there a target surface at the right range, in the right direction, with the right matter behind it? – and differ in their numbers rather than their structure. So each experiment is one run of the searcher with its own configuration, and combining them is an overlay of the masks those runs produce.
Three questions get different answers, and all three are worth reporting:
- joint terrain that satisfies every experiment at once. This is the co-location
case: one site, one road, one power feed, two experiments.
- union terrain that satisfies any of them. This is the coverage case: how much of
the region is useful to the programme as a whole.
- each what each experiment gets on its own, and what it would lose by being
confined to the joint area.
The inputs must be pixel-aligned: same shape, same pixel size, same corner. That is not a detail to paper over – two runs on differently-cropped DEMs would silently overlay the wrong ground – so it is checked and refused rather than resampled.
- python combine_experiments.py ../output/grand_colca_config ../output/tambo_colca_config
–labels GRAND TAMBO –out ../output/combined_colca
- oroscope.combine_experiments.load_run(run_dir: str) dict[source]
Loads one search’s mask, georeferencing and results JSON.
The searcher writes the mask as a downsampled GeoTIFF beside a world file and a results JSON, all sharing a base name.
- Parameters:
- run_dirstr
A run’s output directory.
- Returns:
- dict
dir,tif,mask,worldandresults.resultsisNonewhen no results JSON is present.
- Raises:
- SystemExit
If no mask GeoTIFF is found, or if the world file beside it is missing – without which alignment cannot be confirmed.
- oroscope.combine_experiments.check_alignment(runs: list[dict]) None[source]
Refuses to overlay masks that do not describe the same ground.
Comparing shapes is not enough: two crops of the same size taken from different corners would overlay cleanly and mean nothing. The world file’s pixel size and upper-left corner are what actually pin the ground down.
- Parameters:
- runslist of dict
Loaded runs, from
load_run(). The first is taken as the reference.
- Raises:
- SystemExit
If any run differs from the reference in shape, pixel size or corner. Refusing is deliberate: resampling would silently compare the wrong terrain.
- oroscope.combine_experiments.read_world_file(tfw_path: str) tuple[float, ...][source]
Reads the six affine terms of an ESRI world file.
- Parameters:
- tfw_pathstr
Path to the
.tfwfile.
- Returns:
- tuple of float
(pixel_size_x, rot_y, rot_x, pixel_size_y, upper_left_x, upper_left_y).
- Raises:
- ValueError
If the file does not hold exactly six terms.
- oroscope.combine_experiments.pixel_area_km2(world: tuple[float, ...], reference_latitude_deg: float) float[source]
Ground area of one mask pixel, in km^2.
The world file is in degrees, so the east-west size shrinks with the cosine of the latitude. This uses the same convention as the searcher’s own grid geometry.
- Parameters:
- worldtuple of float
The six affine terms, from
read_world_file().- reference_latitude_degfloat
Latitude at which to evaluate the east-west pixel size, in degrees. Normally the centre of the map.
- Returns:
- float
Area of one pixel, in km^2.
- oroscope.combine_experiments.capacity_of(results)[source]
The capacity a run reported, or
Nonewhen it did not report one.search_mode: singlewrites the string'N/A'rather than a number, soint()raisesValueError– which was not caught, and took the whole combination down with it. A run that does not report a capacity is an ordinary case here, not an error: the overlay is about ground, not detectors.- Parameters:
- resultsdict or None
A run’s parsed results JSON, or
Nonewhen the directory held none.
- Returns:
- int or None
The reported capacity, or
Nonewhen the run did not report one.
- oroscope.combine_experiments.colocation_capacity(partner_mask, world, centre_lat, centre_lon, radii_km=(5.0, 10.0, 20.0, 40.0), spacing_km=1.0, grid_type='hex')[source]
How much partner ground lies within reach of a site, and what it could hold.
The question this answers is the deployment one rather than the site-finding one: given a site chosen for one experiment, is there enough ground nearby for the other? It is deliberately not an intersection. The joint mask is the ground both experiments accept at the same pixel, and a partner array does not have to stand there – measured on the Cajatambo crop, the GRAND-viable ground inside the joint mask was 22,577 fragments of which exactly one was large enough for a single 1 km lattice cell, while 976 km2 of perfectly good GRAND ground lay within 20 km (roadmap 6.52). An optimiser pointed at the intersection reports the partner array impossible; the site is fine.
What couples two arrays is a shared line of sight to the same massif, not a shared footprint. GRAND’s own targets are 10-40 km away, so a partner antenna 20 km from a TAMBO strip is still watching the same wall.
- Parameters:
- partner_maskndarray
2-D boolean mask of the other experiment’s viable ground, on the mask grid.
- worldtuple of float
The six affine terms of that mask’s world file, from
read_world_file().- centre_lat, centre_lonfloat
The site to measure from, in degrees. A site’s
center_lat/center_lonfrom a results JSON is the usual source.- radii_kmiterable of float, optional
Distances to report, in km.
- spacing_kmfloat, optional
Detector spacing of the partner array, in km. GRAND’s is 1.0, TAMBO’s 0.1.
- grid_type{‘hex’, ‘square’}, optional
Lattice the partner array uses.
- Returns:
- list of dict
One entry per radius, ascending, with
radius_km,area_km2andcapacity.capacitycounts positions on an anchored lattice, ascount_grid_capacity()does, so it is an estimate for an arbitrarily placed array rather than the best achievable packing.
Notes
Distances are great-circle-free: a local flat approximation with the east-west degree shrunk by the cosine of the site’s latitude, which is what the rest of this project uses and is good to much better than a pixel over the tens of kilometres involved.
Examples
>>> import numpy as np >>> from oroscope import combine_experiments as ce >>> mask = np.ones((200, 200), dtype=bool) >>> world = (1/3600, 0.0, 0.0, -1/3600, -77.0, -10.0) >>> rows = ce.colocation_capacity(mask, world, -10.02, -76.98, radii_km=(2.0,)) >>> rows[0]["radius_km"] 2.0 >>> rows[0]["capacity"] > 0 True
- oroscope.combine_experiments.smallest_radius_for(partner_mask, world, centre_lat, centre_lon, wanted, spacing_km=1.0, grid_type='hex', radii_km=(2.0, 5.0, 10.0, 20.0, 30.0, 40.0, 60.0, 80.0))[source]
The nearest radius at which a partner array of
wanteddetectors fits.A convenience over
colocation_capacity()for the question actually asked of a candidate site — how far would the partner array have to spread? — rather than the table.- Parameters:
- partner_maskndarray
- worldtuple of float
- centre_lat, centre_lonfloat
The site to measure from, in degrees.
- wantedint
Detectors the partner array needs.
- spacing_kmfloat, optional
Partner detector spacing, in km.
- grid_type{‘hex’, ‘square’}, optional
Partner lattice.
- radii_kmiterable of float, optional
Radii to try, ascending.
- Returns:
- float or None
The smallest radius tried at which the capacity reaches
wanted, orNoneif none of them does.Nonemeans “not within the largest radius tried”, not “impossible”.
Examples
>>> import numpy as np >>> from oroscope import combine_experiments as ce >>> mask = np.ones((400, 400), dtype=bool) >>> world = (1/3600, 0.0, 0.0, -1/3600, -77.0, -10.0) >>> ce.smallest_radius_for(mask, world, -10.05, -76.95, 4, radii_km=(2.0, 5.0)) 2.0
- oroscope.combine_experiments.dem_for_run(run: dict) str | None[source]
The DEM a run searched, if it can still be found.
The combiner works from the masks the runs wrote, so it never needed the DEM – which is why the overview lost its terrain. The path is recorded twice, in the results parameters and in
provenance.json, so it can be recovered when the file is still there and reported as absent when it is not.- Parameters:
- rundict
A run, as
load_run()returns.
- Returns:
- str or None
Absolute path to the DEM, or
Nonewhen it is not recorded or not present.
- oroscope.combine_experiments.relief_for_mask(dem_path: str, shape: tuple[int, int]) ndarray | None[source]
Hillshaded relief for the mask grid, as a greyscale array in [0, 1].
Shaded relief rather than a colour elevation ramp, deliberately: the overlay needs the colour for its categories, and a terrain colourmap underneath would compete with them for the reader’s attention. Relief carries the shape of the ground without spending any hue on it.
The mask is the DEM downsampled, so the DEM is read strided to match rather than resampled. The float32
.npycache the searcher leaves beside the DEM is memory-mapped when present, which makes a strided read of a 129 Mpx DEM cost almost nothing; otherwise the GeoTIFF is read whole.- Parameters:
- dem_pathstr
Path to the DEM the run searched.
- shapetuple of int
(rows, cols)of the mask to match.
- Returns:
- ndarray or None
Relief in [0, 1] with the given shape, or
Noneif the DEM could not be read or does not divide onto the mask grid.
- oroscope.combine_experiments.elevation_for_mask(dem_path: str, shape: tuple[int, int]) ndarray | None[source]
The run’s DEM, read strided onto the mask grid.
The mask is the DEM downsampled, so it is read strided to match rather than resampled. The float32
.npycache the searcher leaves beside the DEM is memory-mapped when present, which makes a strided read of a 129 Mpx DEM cost almost nothing; otherwise the GeoTIFF is read whole.- Parameters:
- dem_pathstr
Path to the DEM the run searched.
- shapetuple of int
(rows, cols)of the mask to match.
- Returns:
- ndarray or None
Elevation in metres with the given shape, or
Noneif the DEM could not be read or is smaller than the mask.
- oroscope.combine_experiments.geographic_extent(world: tuple[float, ...], shape: tuple[int, int]) tuple[source]
The
(left, right, bottom, top)an overlay needs to sit in degrees.Without it
imshowputs a map in pixel coordinates, which is why the overview used to carry no axes at all: pixel indices are not worth labelling, so the ticks were switched off rather than made meaningful.The world file’s terms are the centre of the top-left pixel, so half a pixel is added back at each edge to give the outer bounds of the raster.
- Parameters:
- worldtuple of float
The six affine terms, from
read_world_file().- shapetuple of int
(rows, cols)of the raster.
- Returns:
- tuple of float
(left, right, bottom, top)in degrees, forimshow(extent=...).
Examples
>>> from oroscope import combine_experiments as ce >>> world = (0.01, 0.0, 0.0, -0.01, -72.0, -15.0) # 0.01 deg pixels >>> left, right, bottom, top = ce.geographic_extent(world, (100, 200)) >>> round(left, 3), round(right, 3) (-72.005, -70.005) >>> round(bottom, 3), round(top, 3) (-15.995, -14.995)
The extent spans exactly
rows * celldegrees: 100 rows of 0.01 is 1.0, from -14.995 to -15.995. This example read-16.005until it was first executed, which would have made the raster 1.01 degrees tall.
One-at-a-time sensitivity sweep over a search’s parameters.
A single site-search result is only as firm as the assumptions behind it, and several of the criteria are choices rather than measurements: what fraction of shower maximum still counts as a usable shower, how steep the far wall must be, which energy stands in for a spectrum in the tau-decay term, where the score cut sits. This runs the pipeline repeatedly, varying one parameter at a time about a baseline, and tabulates how much each one moves the answer.
One-at-a-time rather than a full grid on purpose: the question here is “which assumption is this result most sensitive to”, which OAT answers directly and cheaply. It does not capture interactions between parameters, and a result that hinges on an interaction will not show up – for that, sweep the pair explicitly.
- python sensitivity.py ../config/tambo_colca_config.json
–sweep decay_energy_pev 3 10 55 100 1000 –sweep min_score 0.0 0.2 0.35 0.5
- oroscope.sensitivity.run_once(config, out_dir, verbose=False, max_memory_gb=None, timeout=3600)[source]
Runs the pipeline once, in a subprocess.
A subprocess rather than a function call, for two reasons that a sweep makes unavoidable. Memory is reclaimed completely between points: running ten searches in one process took 6.9 GB and was killed by the kernel, because matplotlib retains every figure it is not explicitly asked to close and the leak compounded. And one point that fails – an impossible parameter, or a genuine out-of-memory – reports a failed row rather than ending the sweep.
The cost is a few seconds of Numba compilation per point, which is the right trade for a sweep that otherwise cannot finish.
- Parameters:
- configdict
Parameters as they appear in a config file. Keys beginning with
_are treated as comments and dropped.- out_dirstr
Directory for this run’s outputs.
- verbosebool, optional
Let the child’s output through rather than capturing it.
- max_memory_gbfloat, optional
Address-space ceiling for the child.
- timeoutfloat, optional
Seconds before the child is killed and the point reported as failed.
- Returns:
- dict or None
The parsed results JSON, or
Nonewhen the run produced nothing. Read back from disk rather than returned directly: the pipeline hands its results to its own caller, and that caller is in another process here – which is the whole point of running each point in one.
- oroscope.sensitivity.summarise(results: dict | None) dict[source]
Reduces a results JSON to the few numbers a sensitivity table compares.
- Parameters:
- resultsdict or None
A parsed results JSON, or
Nonewhen the run produced nothing.
- Returns:
- dict
sites,capacity,area_km2,accepted,keptandacceptance. All zero for a run that produced nothing, so a sweep row still appears rather than vanishing.
Figures
The schematics used throughout this documentation, as importable functions so they can be restyled and reused.
Publication-quality schematics of the geometry the search computes.
Each routine returns a matplotlib.figure.Figure built from the same physics
the code uses, so a diagram cannot drift away from the implementation it illustrates.
They are here rather than in the documentation tree so they can be imported, restyled
and reused — in a talk, a proposal, or a paper — without copying code out of an
.rst file.
Styling is applied per-figure through a context manager rather than by mutating global
rcParams, so importing this module does not change the appearance of anybody
else’s plots.
Examples
>>> from oroscope import figures
>>> fig = figures.walk_mechanism()
>>> fig.savefig("walk.pdf", bbox_inches="tight")
- oroscope.figures.walk_mechanism(earth_radius_km=6371.0, detector_elevation_km=1.05, bin_edges_deg=(-1.2, -0.4, 0.4, 1.2), figsize=(10.2, 4.15))[source]
One profile walk fills every elevation bin at once.
The central algorithmic claim, drawn. Panel (a) is the terrain profile with the first terrain met for each elevation bin; panel (b) is the quantity that makes it work, the apparent elevation angle
\[\theta_{\rm terrain}(d) = \arctan\!\left( \frac{z(d) - d^2/2R - z_0}{d}\right),\]together with its running maximum. Because that maximum only increases, each new value claims a contiguous band of bins, so one pass fills them all — which is why the elevation binning is nearly free and the azimuth count sets the cost.
- Parameters:
- earth_radius_kmfloat, optional
Earth radius used for the \(d^2/2R\) curvature drop. The true radius, not the inflated radio one: the particle trajectory is not refracted.
- detector_elevation_kmfloat, optional
Elevation of the candidate pixel the walk starts from.
- bin_edges_degsequence of float, optional
Elevation-bin edges to trace, in degrees. Drawn in the order given, coloured low to high.
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
The finished two-panel figure.
Examples
>>> from oroscope import figures >>> fig = figures.walk_mechanism() >>> len(fig.axes) 2
- oroscope.figures.canyon_geometry(depth_m=1500.0, floor_width_m=1000.0, wall_slope_deg=40.6, figsize=(7.6, 4.0))[source]
The canyon-crossing geometry a particle array such as TAMBO selects on.
Drawn to scale, unlike the long-range figure: a canyon is a few kilometres across and one and a half deep, so no exaggeration is needed and none is applied. That is itself worth showing — the arrival directions really do span tens of degrees here, where GRAND’s span three.
Two criteria are marked because they are separate and are easy to conflate. The near wall is the ground the array stands on and must be deployable; the far wall is where the tau exits and must be steep. A single slope band cannot express both.
- Parameters:
- depth_mfloat, optional
Rim-to-floor depth. Colca is about 1500 m.
- floor_width_mfloat, optional
Width of the flat valley floor.
- wall_slope_degfloat, optional
Slope of both walls. Colca’s published depth and ~4.5 km rim separation imply about 40.6 degrees, which is far outside GRAND’s 3-25 degree deployable band.
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.canyon_geometry() >>> round(float(fig.get_figwidth()), 1) 7.6
- oroscope.figures.decay_and_shower(energies_pev=(3.0, 10.0, 55.0, 100.0, 1000.0), crossing_m=3000.0, figsize=(7.4, 3.9))[source]
Why a single energy cannot stand in for a spectrum.
The tau must decay inside the gap for a shower to reach the detector, with probability \(1 - \exp(-d/L)\) for a boosted decay length \(L\). Across a canyon that probability runs from essentially one to a few per cent over a single experiment’s energy reach, which is why a capacity computed at one representative energy is an artefact of the energy chosen rather than a property of the terrain.
- Parameters:
- energies_pevsequence of float, optional
Energies to mark, in PeV.
- crossing_mfloat, optional
Gap the tau must decay within, in metres.
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.decay_and_shower() >>> len(fig.axes) 1
- oroscope.figures.pipeline_stages(figsize=(9.2, 5.4))[source]
How a DEM becomes a list of sites: the stages, and what each one removes.
The vocabulary this project uses — screening, striding, the arrival scan, scoring, closing, pruning — is introduced nowhere in one place, and the terms are not guessable. This is that place, drawn.
The widths are proportional to the survivors at each stage, on a logarithmic scale because the range is six orders of magnitude and a linear funnel would show one visible bar and six slivers. The numbers are a real run: TAMBO over the full Ancash DEM, 68.6 Mpx.
Read it as two halves. Everything down to the arrival scan removes candidates; everything below rebuilds a map from them, which is why the count rises again at closing. Confusing those two halves is the single commonest way to misread a funnel table.
The arrival scan and the scoring are two bars, which they could not be before. The pre-fix funnel recorded the post-cut count under both names — the defect fixed in
run_arrival_scan()— so the two were drawn merged, as a single bar reading 1,022,530, and the arrival window carried the blame for a cut it had not made. Separated on a post-fix run, the scan keeps 82% of what striding hands it andmin_scoretakes 8.4x. That is the reverse of what the merged bar implied, and it is the whole reason the stage is worth drawing on its own.- Parameters:
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.pipeline_stages() >>> len(fig.axes) 1
- oroscope.figures.striding_and_closing(stride=5, element_px=(3, 5), figsize=(10.4, 3.2))[source]
Why the closing element has to outrun the gap that striding leaves.
Striding keeps one surviving pixel in
stride, so the accepted set becomes a lattice of isolated marks. Morphological closing is what turns that back into a region — but only if its structuring element is larger than the gap. Below the gap the marks never touch and the mask stays a scatter; above it the region reappears almost intact.The transition is at the gap and it is abrupt, not gradual. A second, far smaller step follows at twice the gap, where the element grows wide enough to bridge second-neighbour marks as well. That is the whole content of the figure, and it is the mechanism behind a real 1.51x under-report of TAMBO’s area at Colca — and a 23.0x one on the steeper ground of the Callejon de Huaylas, where the accepted strips are narrower still. Both were far larger at TAMBO’s old 100 m element, 4.75x and 291x, which is the cliff drawn here: 100 m is three pixels against a five-pixel gap and 150 m is five.
- Parameters:
- strideint, optional
Keeps every Nth surviving pixel; also the gap it leaves, in pixels.
- element_pxtuple of int, optional
Closing element sizes to draw, in pixels. One below the gap, one at it, one above.
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.striding_and_closing() >>> len(fig.axes) 5
- oroscope.figures.score_composition(cut=0.35, figsize=(7.8, 3.4))[source]
Why a threshold on a product of components sits on a cliff.
Each component scores a candidate in [0, 1] against one named criterion — depth, accepted solid angle, exit distance, and so on. They are combined by multiplication, so a candidate has to be good at everything, and the composed score of several components piles up near zero however good the terrain is.
The curves are synthetic. Six independent draws from a Beta(5, 2) stand in for six components; no search, terrain or stored result is involved. The distribution is deliberately generous — mean 0.714, mode 0.80 — so that the collapse cannot be blamed on poor terrain. This figure demonstrates the mechanism; it is not a measurement, and the page that publishes it prints a measured sentence directly beneath, which is exactly the confusion the note on the axes exists to prevent.
A cut placed in the middle of that pile is therefore not a mild preference: it is a cliff, and where it lands depends on how many components happen to be enabled. Adding a component moves every score down and so silently tightens the cut.
- Parameters:
- cutfloat, optional
Where
min_scoreis placed, for illustration.- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.score_composition() >>> len(fig.axes) 2
- oroscope.figures.score_composition_measured(cut=0.35, figsize=(7.8, 3.4))[source]
What the product does to real candidates, rather than to a model of them.
The companion to
score_composition(), which demonstrates the mechanism with invented components. This one is measured: each curve is the running product over the same 360,939 geometrically accepted candidates of one Colca TAMBO run, with one more component multiplied in. No candidate is removed between curves — the population is fixed and only its scores move left. What survives the cut at the end is 64,152 of them.The result does not resemble the mechanism figure, and that is the point.
depthanddistanceare exactly 1.0 for every candidate andshowerfor 92.3% of them, so three of the six components do nothing whatever. Five of the six together still leave 96.3% above the shipped cut. The sixth,solid_angle, takes it to 17.8%.So
min_scoreis not really a threshold on a product of six criteria. It is a cut onsolid_anglewearing a product as a disguise — the measured form of the caveat thatsolid_angleis the weakest component at every selected site in every region.Components enter least-restrictive first, so the collapse is attributable to a named component rather than to the number of them.
- Parameters:
- cutfloat, optional
Where
min_scoreis drawn. The stored percentages are those of the shipped 0.35 and are not recomputed, so another value moves the line without moving the curve beside it.- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.score_composition_measured() >>> len(fig.axes) 2
- oroscope.figures.grand_and_tambo_scales(xmax_km=50.0, figsize=(10.4, 5.8))[source]
The same question, asked ten kilometres apart and four hundred metres apart.
canyon_geometry()draws what TAMBO asks of the ground. This draws what both experiments ask, on one shared horizontal axis, because the comparison is the point and a reader should not have to do it in their head.The upper row is GRAND: antennas on ground inside its 3–25° band, watching a massif through a window 3° about the horizon. The lower row is the Colca cross-section at the same scale — the notch in the first 4.5 km — with a magnified detail tied back to its true position, so it cannot be mistaken for a second canyon further out.
The window stops where the highest ray first meets ground, which is the acceptance test itself rather than a decoration: shading past the massif would claim sight lines the massif blocks.
One asymmetry is deliberate. The lower edge of GRAND’s window is invisible because the ground beyond the detector is level, so a ray 3° below the horizon meets it at once. That is the honest picture of a detector on a plain.
- Parameters:
- xmax_kmfloat, optional
Ground distance spanned by both rows, in km. The comparison only works while both rows share it.
- figsizetuple of float, optional
Figure size in inches.
- Returns:
- matplotlib.figure.Figure
Examples
>>> from oroscope import figures >>> fig = figures.grand_and_tambo_scales() >>> len(fig.axes) 2
Fetching data
The one-shot tools that bring a region’s inputs onto disk are setup rather than library,
which is why import oroscope does not re-export them and why their command-line side
is documented on The command line instead. Only the names other pages link into are listed
here: oroscope.fetch_dem.REGIONS is cross-referenced from Getting the data, and a
cross-reference with no target renders as plain text rather than failing the build, so it
sat there unresolved and unnoticed.
Downloads the elevation models a search needs, and writes configurations for them.
Fetches the bundled regions from OpenTopography into input/dem/ and generates a
ready-to-run JSON config for each in config/:
oroscope-fetch-dem --region arequipa --open_topography_api_key KEY
Four regions are defined. Three departments at 1 arc-second from the same dataset,
so that runs over them are comparable – arequipa (129 Mpx), lima (105 Mpx) and
ancash (69 Mpx), all SRTMGL1 – and peru, the whole country, at 3 arc-seconds
(SRTMGL3) because that is the only resolution that fits either a desktop’s memory or the
API’s own area limit. Omit --region to fetch all of them.
Getting a key. It is free and takes a minute. Register at
https://portal.opentopography.org/myopentopo, sign in, then open myOpenTopo
Authorizations and API Key from the account menu and copy the key. Pass it as
--open_topography_api_key, or set OPENTOPOGRAPHY_API_KEY in the environment,
which keeps it out of your shell history and out of any file that might be committed.
Requests are capped by area, and the cap is per dataset: 450,000 km² for every 30 m
dataset, 4,050,000 km² for the 90 m ones. That is why peru is SRTMGL3 – its
bounding box is about 2.86 million km², six times over the 30 m limit.
For any other region, download the tiles from the OpenTopography portal, merge them
into one GeoTIFF if the area spans several, and cut the window you want with
crop_dem.
This was setup.py, whose name made pip install run the downloader instead of
building the package.
- oroscope.fetch_dem.REGIONS = {'ancash': {'demtype': 'SRTMGL1', 'east': -76.7257441, 'filename': 'ancash_SRTMGL1.tif', 'north': -8.049709, 'preset': 'default', 'south': -10.7873076, 'west': -78.6584805}, 'arequipa': {'demtype': 'SRTMGL1', 'east': -70.0852632522583, 'filename': 'arequipa_SRTMGL1.tif', 'north': -14.555380967667489, 'preset': 'arequipa', 'south': -17.38995824658555, 'west': -73.58612537384033}, 'lima': {'demtype': 'SRTMGL1', 'east': -75.39955615997313, 'filename': 'lima_SRTMGL1.tif', 'north': -10.228479499469358, 'preset': 'lima', 'south': -13.252477566131276, 'west': -78.07665824890137}, 'peru': {'demtype': 'SRTMGL3', 'east': -68.6, 'filename': 'peru_SRTMGL3.tif', 'north': 0.0, 'preset': 'default', 'south': -18.4, 'west': -81.4}}
The regions
oroscope-fetch-dem --regionknows, each mapping to its dataset and its bounding box in degrees. A#:comment rather than a plain one so that autodoc picks it up: Getting the data cross-references this name, and module-level data with only an ordinary comment above it is documented nowhere, leaving the reference to render as unlinked text.Bounds are given as (West, East, South, North).
- oroscope.fetch_dem.download_dem(region_name, bounds, api_key, output_dir)[source]
Fetches one region’s GeoTIFF from OpenTopography.
- Parameters:
- region_namestr
A key of
REGIONS, naming the box and dataset to fetch.- boundsdict
That region’s entry:
demtypeand thesouth/north/west/eastbounds in degrees, plus thefilenameto write.- api_keystr
An OpenTopography key. Free, from https://portal.opentopography.org/myopentopo.
- output_dirstr
Directory to write the
.tifinto. Created if absent.
- Returns:
- str or None
The path written, or
Noneif the request failed.
- oroscope.fetch_dem.generate_and_patch_config(region_name, preset, dem_filepath, config_dir=None)[source]
Writes a default config for the region and points it at the DEM just downloaded.
This used to shell out to
site_searcher.pyin the current directory, which was right when the modules were flat and is wrong now that they are a package: an installed copy has no such file anywhere, so the config step failed for every user who was not standing insrc/.site_searcher.generate_config()is the same code path the--generate_configflag takes.- Parameters:
- region_namestr
Key in
REGIONS; names the config file.- presetstr
Config preset to seed from, one of
site_searcher.CONFIG_PRESETS.- dem_filepathstr
The DEM to point
dem_pathat.- config_dirstr, optional
Where to write. Defaults to
../configfor continuity with the old layout.