Skyline masks and sky geometry
Until 0.36 every Earth visibility test in the engine was one elevation cut-off, mask_deg: a
satellite counted when it was at or above one number, whatever its direction. A street, a quay, a
fjord or a crane blocks part of the sky and leaves the rest open. A skyline is an elevation
cut that depends on azimuth; the sky-geometry functions turn a site, a time, constellations
and a skyline into the visible set, the dilution of precision (DOP), the geometry covariance, the
horizontal error ellipse, a Monte Carlo cloud and a skyplot. The design is
docs/design/2026-10-12-geometry-and-skyline-design.md (items 1 and 2).
Evidence: the masked visibility decision (geodetic look angles, the table mask, the visible set and its DOP) is VALIDATED against Orekit 12.2 and gnss_lib_py 1.0.4 in a pre-registered comparison, and so are the error ellipse and the per-system cofactor (numpy, pre-registered); the canyon, sector and terrain reductions, the presets and the rest of the sky-geometry functions are MODELLED (Evidence). A skyline removes line-of-sight satellites and nothing more. The presets are REPRESENTATIVE geometries, not surveys.
The skyline#
A skyline is the pointwise maximum of four descriptions. Azimuth is degrees clockwise from true
north in the site's local east-north-up frame; elevations are in [0, 90).
| Description | TOML | What it is |
|---|---|---|
| Table | table = [[az, el], ...] |
Elevations linearly interpolated in azimuth, wrapped at 360°. Azimuths strictly increasing in [0, 360); at most 3600 rows. |
| Street canyon | street_az_deg, width_m, height_left_m, height_right_m, antenna_m, length_m |
Two straight parallel walls (below). |
| Sectors | sectors = [{ az_from, az_to, el_deg }] |
Sharp-edged intervals, clockwise from az_from (included) to az_to (excluded), which may wrap through north; at most 360. |
| Terrain | terrain = [{ az, distance_m, height_m }] |
Points reduced to a table: elevation atan((height_m − d²/(2 R_eff)) / d), with the curvature drop for R_eff = 4/3 × 6371 km (a chosen, stated constant). height_m is above the antenna. |
preset = "<name>" starts from a named geometry; the canyon fields then override the preset's
canyon field by field, and table, sectors and terrain add to it.
The receiver's mask_deg keeps its meaning: it is a flat skyline. The effective cut at azimuth
az is max(mask_deg, skyline(az)), and a satellite is visible when its elevation is at or
above it: the same >= the flat mask always used. A satellite above mask_deg but below the
skyline is masked by the skyline.
The street canyon#
A street runs along azimuth s, w metres between the walls, the antenna a metres up on the
centreline. Along azimuth az, with θ = az − s, the ray reaches a wall after
d(az) = (w/2) / |sin θ|, so a wall of height h cuts at
cut(az) = atan( (h − a) / d(az) ) (the right wall for sin θ > 0, the side at s + 90°)
The open strip lies along the street. With length_m = L (the antenna at the middle) a ray
leaves through an open end, and the wall cuts nothing, when d(az) · |cos θ| > L/2. A wall no
taller than the antenna cuts nothing. antenna_m defaults to 1.5 m (a car roof or a hand-held
receiver, a stated choice); a new canyon needs width_m.
Presets#
Chosen and stated dimensions (the design note's), not surveys of any real place.
kshana skyline --preset <name> prints one with its provenance and its cut every degree.
| Preset | Geometry | Provenance |
|---|---|---|
city-street |
Canyon: street 0°, width 20 m, walls 30 m and 30 m, antenna 1.5 m. 70.7° across the street, open along it. | Height-to-width 1.5, inside the 0.5–2 band of street-canyon aspect ratios in Oke (1988), Street design and urban canopy layer climate, Energy and Buildings 11, 103–113. |
harbour-approach |
Sectors: open to seaward over 090–270 (180°); quay buildings 8° over 315–045 (90°); the breakwater and its light 3° over 045–090 and 270–315. | Stated choices (for example 40 m warehouses about 285 m off). |
fjord |
Terrain: walls 600 m above the antenna at 1500 m either side of a north-south channel, one point per degree (at 1500/|sin az| m), curvature drop applied. 21.8° across, below 1° along the axis. |
The order of the width and relief of the deep west-Norwegian fjords (Holtedahl (1967), Notes on the formation of fjords and fjord-valleys, Geografiska Annaler 49A, 193–203). |
container-terminal |
Sectors: container stacks 12° over the landward half 000–180; crane booms 35° over 245–255 and 25° over 285–295; open to the water elsewhere. | Five ISO 668 high-cube boxes (14.5 m) about 60 m off an antenna 2 m up; a boom about 50 m above the quay at 70 m and 100 m, the order of a post-Panamax crane. A crane sector is opaque. |
In a scenario#
An optional [skyline] section, over the kind's own cut-off:
| Kind | What the skyline changes | Per sample |
|---|---|---|
orbit (with user_site) |
The visibility timeline (GNSS availability) and the DOP summary. | — |
integrity (with user_site) |
The satellites RAIM and ARAIM (araim_dual) use. |
epochs[].masked_by_skyline |
gnss-sim |
The satellites whose pseudoranges are simulated and used by RAIM. | gnss_measurements[].masked_by_skyline |
jamming |
The satellites the receiver can track, before the jammer. | epochs[].masked_by_skyline |
leo-pvt |
Every mode's in-view set over each system's own mask, the polar sweep included (the same skyline at every site). The Doppler-mode envelope and the designed single pass are bounds on what a receiver must handle and keep each system's mask. | joint.epochs[].masked_by_skyline |
leo-pass |
Which epochs count as above the mask, the time above it, the GNSS comparison's satellites. | satellites[].pass.epochs_masked_by_skyline |
nmea-scenario (training NMEA) |
The satellites in GSV and in the fix. Azimuths are true bearings fixed to the ground, not the vessel's heading. | — |
The ground site of orbit and integrity. Both kinds model an orbiting user with a
geocentric elevation and no Earth-fixed azimuth, so they take a skyline only with the design
note's ground-site mode: user_site = { lat_deg, lon_deg, height_m } in place of user (exactly
one of the two). Each sample is one absolute instant, epoch + t: every satellite is propagated
to it (an element set, broadcast ephemeris or SP3 file is advanced from its own epoch, a Walker
satellite counts t from epoch) and rotated to Earth-fixed at it, so the site sees one
consistent sky. epoch is the orbit scenario's own; without one (and always for integrity,
which has none) it is the newest epoch among the satellites that carry one, or J2000 for Walker
patterns only. Elevation and azimuth are geodetic (WGS-84). The
horizontal and vertical split of the integrity kind's ARAIM protection levels and Stanford
vertical error uses the same geodetic up (the WGS-84 ellipsoid normal, about 0.19° from the
geocentric radial at 45° latitude); snapshot RAIM already used it. A user_site without a
skyline is a plain ground user; the orbiting user path is unchanged.
kind = "integrity"
mask_deg = 5.0
# ...
[user_site]
lat_deg = 59.45
lon_deg = 24.75
height_m = 15.0
[skyline]
preset = "harbour-approach"
Backward compatibility. Without a [skyline] section a scenario is byte-identical to what the same engine gave before skyline code existed:
tests/skyline_scenarios.rs checks the JSON, chart and summary of every shipped scenario that
runs one of these kinds, and the NMEA text of every training scenario, against SHA-256 digests
of the "0.36 portable" baseline (the 0.35.0 release commit's digests, re-based when the engine moved to
portable maths and kept in the test as the record: docs/revisions/STUDIO15-portable-math-rebaseline.md). An empty [skyline] section cuts nothing; the result then
equals the run without it except for the added skyline block and the zero masked_by_skyline
counts.
What is refused, with the reason in the message: a skyline on an orbiting user; user and
user_site together; an unknown preset; a canyon without width_m; a table that is not strictly
increasing in [0, 360); an elevation outside [0, 90); an empty sector; a non-positive terrain
distance; input over the caps; any unknown key.
The result gains skyline: { source, open_fraction, provenance } when the section is present:
source is preset:<name>, preset:<name>+overrides or custom; open_fraction is the
fraction of the upper hemisphere's solid angle above max(mask_deg, skyline) (the mean of
1 − sin(cut) over azimuth: 1 for a zero mask, 0.5 for a flat 30° cut); provenance is the
preset's line or the scenario's own. Units and provenance classes are in the units block.
Bundled examples (kshana example <name>):
| Example | What it shows (remove the [skyline] section to compare) |
|---|---|
orbit-urban-canyon-skyline |
GPS + Galileo at the bottom of city-street: 17 of 289 samples keep a fix, median PDOP 70.5 (1.14 under open sky). |
integrity-harbour-approach |
Dual-constellation ARAIM entering a port: 70 satellite-epochs hidden, availability unchanged (145 of 145). |
gnss-sim-container-terminal-skyline |
Pseudoranges and RAIM on a quay: 44 satellite-epochs hidden; RAIM availability 0.59 (0.85 under open sky). |
jamming-harbour-approach-skyline |
A 2 W jammer in a harbour: 83 satellite-epochs hidden, tracking unchanged (the jammer had taken them). |
leo-pvt-fjord-skyline |
GPS + Galileo + a LEO layer in a fjord: median fused PDOP 1.14 (0.93 under open sky). |
leo-vertical-autonomous-vehicle-skyline |
The leo-vertical-autonomous-vehicle canyon as a flat 35° cut and as city-street turned along the car's heading: 8 against 4 to 5 GPS and Galileo satellites; fused mean error 2.1 m in both; the 1.5 m alarm at 51 % and 59 % of epochs. |
Sky geometry#
Five engine functions (src/geometry.rs), the same on every surface:
| Function | Input → output |
|---|---|
sky_geometry(request) |
per satellite id, system, az_deg, el_deg, cut_deg, visible, masked; n_visible, n_masked, open_fraction; dop (GDOP, PDOP, HDOP, VDOP, TDOP, tdop_by_clock); states, cofactor_enu (unit variance), covariance_enu_m2; ellipse_1sigma and ellipse at the requested confidence; label, statement, advisory |
sky_monte_carlo(request, seed, n) |
n (at most 5000, an input cap) least-squares errors under independent Gaussian range errors of σ_UERE from a ChaCha8 stream seeded by seed: samples_enu_m, the empirical covariance, horizontal and 3-D RMS beside σ_UERE × HDOP and σ_UERE × PDOP, the fraction inside the 95 % ellipse |
skyline_presets() |
the presets with label, description, provenance, the cut every degree and open_fraction |
skyline_profile(skyline) |
a skyline section reduced to the 360-entry table the engine uses, with source, provenance, open_fraction |
skyplot_svg(request) |
the skyplot as static SVG (no script, no external reference, every input string escaped) |
A request:
{
"site": {"lat_deg": 59.91, "lon_deg": 10.75, "height_m": 5},
"time_s": 3600,
"systems": ["gps-baseline", "galileo", "beidou-meo", "xona-pulsar"],
"mask_deg": 5,
"skyline": {"preset": "fjord"},
"clock": "per-system",
"sigma_uere_m": 1.5,
"confidence": 0.95
}
systems are the engine's constellation presets (constellation-design's gps-baseline,
galileo, beidou-meo, glonass, ...; the LEO presets xona-pulsar, iridium-stl, ...) or a
full leo-pvt [[system]] table, propagated to time_s (Keplerian with secular J2, the
leo-pvt joint-mode orbits). Their relative phases come from different reference epochs, so the
sky is a representative geometry, not the sky of that date at that place. Satellites may
instead (or as well) be given directly: "satellites": [{"id": "G05", "system": "GPS", "az_deg": 120, "el_deg": 35}, ...], or with "ecef_m": [x, y, z] and a site or
user_ecef_m. Defaults: each system's own mask (5° GNSS, 10° LEO; 5° for satellites given
directly), one receiver clock per system ("common" for one shared clock), σ_UERE 1 m (a
representative dual-frequency budget line), confidence 0.95. Input caps: at most 2000
satellites above the horizon (a system table asking for more is refused before it is built) and
16 receiver clocks.
A dated sky from element sets. "tle" takes two-line element set text (a name line before
each set is optional; at most 4 MiB, an input cap), propagated by the engine's SGP4 (WGS-72, the
propagator orbit-real-tle uses) to "epoch" plus time_s, rotated to Earth-fixed with UT1
taken as UTC and no polar motion, and seen from site. "epoch" is UTC ISO 8601
(2026-06-07T12:00:00Z) and dates only the element sets (the presets carry no date, so an
epoch without tle is refused); "tle_system" labels them for the per-system clock (default
TLE). Every result says what sky it is in sky_source and sky_statement:
sky_source |
The sky |
|---|---|
presets |
the engine's constellation presets: a representative geometry |
tle-at-epoch |
the element sets propagated to the stated epoch: the sky those element sets give at that epoch, not a measurement of it (their own accuracy is SGP4's and the sets' age) |
tle-at-element-epoch |
no epoch: the element sets propagated to the newest set's own epoch plus time_s, a representative sky |
satellites |
satellites given directly, as stated |
A mix joins them with +. epoch_utc is the instant the element sets were propagated to, and
tle_skipped names what of the text was not used, with the reason: malformed or orphaned
element-set lines (with their line numbers), sets the parser refused, and sets SGP4 could not
propagate to that instant (decayed, or past its limits); at most 100 entries in all, then one
last entry counting the rest. An
empty systems list adds no presets.
The skyplot states the basis in its footer.
One kernel. The cofactor matrix is orbit::cofactor_from_los, the design-matrix assembly and
Gauss-Jordan inverse behind orbit::dop. orbit::dop_cov exposes the covariance and orbit::dop
is its reduction, so the VALIDATED scalars are unchanged and still pinned by
tests/dop_reference.rs. There is no second DOP implementation.
The ellipse. Semi-axes k σ √λ for the eigenvalues λ of the east-north block, with
k = √(−2 ln(1 − p)) (√5.991 = 2.448 at 95 %, 1 at 39.35 %), and the major axis's azimuth
½ atan2(2 Q_EN, Q_NN − Q_EE) in [0, 180) from north.
| Surface | Entry points |
|---|---|
| CLI | kshana sky <request.json|toml> or kshana sky --site <lat,lon[,h]> (--systems <a,b> | --tle <file>) [--epoch] [--tle-system] [--time] [--mask] [--skyline-preset] [--uere] [--clock], with --monte-carlo <n> --seed <s> or --svg; kshana skyline --preset <name> | --presets | --profile <file> |
| Rust | kshana::geometry::{sky_geometry, sky_monte_carlo, skyline_presets, skyline_profile, skyplot_svg}, kshana::surface::* of the same names |
| Python | kshana.sky_geometry, sky_monte_carlo, skyline_presets, skyline_profile, skyplot_svg |
| WASM | sky_geometry(request_json), sky_monte_carlo(request_json, seed, n), skyline_presets(), skyline_profile(skyline_json), skyplot_svg(request_json) |
| MCP | sky_geometry (request, or skyline_preset); no Monte Carlo over MCP (bulk samples an agent should summarise, not carry) |
Every result carries label (MODELLED), the statement (geometry under a stated error model:
independent, equal, zero-mean Gaussian range errors; not a prediction of a receiver's real error,
which also has biases, multipath and non-line-of-sight errors; not an integrity bound) and the
advisory: Advisory software, not type-approved navigation equipment (IEC 61108, IEC 61162): the
operator remains responsible for the navigation of the vessel.
Evidence#
Five ledger rows (docs/VERIFICATION-MATRIX.md), as the design note scopes them:
| Row | Status | Oracle |
|---|---|---|
| Azimuth-dependent skyline mask: visible set, azimuth and elevation, and DOP under the mask | VALIDATED | Orekit 12.2 (TopocentricFrame look angles, ElevationMask) with gnss_lib_py 1.0.4 (get_dop and the DOP matrix), pre-registered in xval/orekit-skyline/PREREGISTRATION.md (commit e4b9d912) before either was run: 1173 look angles at six sites and four epochs, six tables, 142 visible sets; worst elevation 1.3e-13°, azimuth 2.3e-13°, mask 1.1e-12° against 1e-9°; identical visible sets, none in the boundary band; DOP 3.3e-13 relative and cofactor 6.6e-13 of the matrix scale against 1e-9. Scope: table masks, geodetic look angles, the masked set and its common-clock DOP and cofactor. |
| Street-canyon, sector and terrain reductions to a table | MODELLED | hand-computed values; the comparison above reads their tables as inputs and does not check them |
| Skyline presets | MODELLED | none: chosen, stated dimensions (checked only against themselves) |
| Horizontal error ellipse and per-system cofactor from the geometry covariance | VALIDATED | numpy 2.5.4 linalg.inv and linalg.eigh (standard P2) on the 142 committed visible sets, common clock and one clock per system, pre-registered in xval/numpy-ellipse/PREREGISTRATION.md (commit 0bbeeaee): 270 cases, axes 5.1e-13 relative, orientation 5.3e-12°, cofactor 1.0e-12 of the matrix scale, against 1e-9 and 1e-6° |
| Sky geometry functions | MODELLED | the composition: preset propagation to a representative sky, the Monte Carlo cloud (checked against the geometry it samples), the surfaces |
The absence of a [skyline] section is checked byte for byte against the 0.35.0 outputs, which
shows backward compatibility and validates nothing. gnss_lib_py applies a single elevation mask
only, so it is the DOP oracle here, not the mask oracle.
What a skyline does not model: diffraction over a wall edge, reflections and non-line-of-sight signals a real receiver may still track (they are dropped, not degraded), partial transparency (a lattice crane is opaque across its sector), terrain beyond the points given, and Earth curvature inside the canyon and sector forms.