1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
// SPDX-License-Identifier: AGPL-3.0-only
//! Surface-beacon augmentation of a lunar navigation service (L08, L09).
//!
//! A south-polar lunar user sees an orbit-only constellation in a narrow, slowly-moving
//! patch of sky, so the ranging geometry is poorly conditioned and the geometric
//! dilution of precision (GDOP) is large. A few surveyed **surface ranging beacons** fix
//! this cheaply: a beacon near the local horizon contributes the low-elevation,
//! wide-azimuth line-of-sight rows an all-overhead orbital set lacks, collapsing the
//! horizontal GDOP. This module supplies the two pieces the open engine did not yet
//! have: (1) a beacon-augmented DOP that concatenates satellite and surface-beacon
//! ranging rows through the validated [`crate::orbit::dop`] kernel, with airless-Moon
//! horizon-bounded beacon visibility (reusing the L01 [`crate::lunar::surface_los_max_m`]
//! geometry); and (2) a beacon error budget that turns a bare DOP into a **realized
//! position accuracy in metres** — converting the headline "GDOP 1.6" into a distance.
//!
//! ## Validated vs Modelled
//! * **Validated** — the DOP assembly (it *is* the [`crate::orbit::dop`] kernel, which is
//! cross-checked against `gnss_lib_py`/NumPy), the airless-horizon visibility (the L01
//! closed form), the error-budget root-sum-square, and the `σ = DOP · σ_URE` accuracy
//! relation (Kaplan & Hegarty, *Understanding GPS/GNSS*, §7 UERE budget / DOP).
//! * **Modelled** — any specific constellation, beacon placement, or component error
//! magnitude fed in is a representative scenario input, not a fielded measurement.
use crate::lunar::{surface_los_max_m, R_MOON_M};
use crate::lunar_service::visible_sat_positions;
use crate::orbit::{dop, Dop};
use serde::{Deserialize, Serialize};
type Vec3 = [f64; 3];
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn range_between(a: Vec3, b: Vec3) -> f64 {
norm([a[0] - b[0], a[1] - b[1], a[2] - b[2]])
}
/// Height of a point above the mean lunar sphere (m), floored at zero.
fn height_above_sphere_m(p: Vec3) -> f64 {
(norm(p) - R_MOON_M).max(0.0)
}
/// Airless-Moon line of sight between a surface user and a surface beacon: the
/// straight-line range must not exceed the two-height geometric horizon sum
/// `sqrt(2 R h_u + h_u^2) + sqrt(2 R h_b + h_b^2)` (L01 [`surface_los_max_m`]). With no
/// atmosphere there is no refractive horizon extension, so this bound is exact.
pub fn beacon_visible(user_mcmf: Vec3, beacon_mcmf: Vec3) -> bool {
let h_u = height_above_sphere_m(user_mcmf);
let h_b = height_above_sphere_m(beacon_mcmf);
range_between(user_mcmf, beacon_mcmf) <= surface_los_max_m(R_MOON_M, h_u, h_b)
}
/// The visible surface beacons for a user: those whose airless-Moon line of sight to the
/// user clears the horizon ([`beacon_visible`]).
pub fn visible_beacons(user_mcmf: Vec3, beacons_mcmf: &[Vec3]) -> Vec<Vec3> {
beacons_mcmf
.iter()
.copied()
.filter(|&b| beacon_visible(user_mcmf, b))
.collect()
}
/// Beacon-augmented dilution of precision (L08): concatenate the visible-satellite
/// line-of-sight rows (elevation mask `elev_mask_rad`) with the visible-surface-beacon
/// ranging rows and evaluate through the validated [`crate::orbit::dop`] kernel. A
/// synchronized surface beacon contributes the same `[-e, 1]` ranging row as a
/// satellite, so a near-horizon beacon supplies the wide-azimuth horizontal geometry a
/// high-elevation-only orbital set lacks — the mechanism behind the polar GDOP collapse.
/// Returns `None` with fewer than four combined sources or a singular geometry.
pub fn dop_with_beacons(
user_mcmf: Vec3,
sats_mcmf: &[Vec3],
beacons_mcmf: &[Vec3],
elev_mask_rad: f64,
) -> Option<Dop> {
let mut sources = visible_sat_positions(user_mcmf, sats_mcmf, elev_mask_rad);
sources.extend(visible_beacons(user_mcmf, beacons_mcmf));
dop(user_mcmf, &sources)
}
/// Per-beacon user-equivalent ranging error budget (L09): the independent error sources
/// of a synchronized surface ranging beacon, each in metres of range.
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
pub struct BeaconErrorBudget {
/// Time-synchronization error mapped to range (m) — the beacon-to-system clock
/// offset times the speed of light.
pub clock_sync_m: f64,
/// Surface-to-surface multipath error (m).
pub multipath_m: f64,
/// Beacon position / survey error (m).
pub survey_m: f64,
}
impl BeaconErrorBudget {
/// The per-beacon user-equivalent ranging error `σ_URE` (m): the root-sum-square of
/// the independent components, `sqrt(clock² + multipath² + survey²)`.
pub fn sigma_ure_m(&self) -> f64 {
(self.clock_sync_m * self.clock_sync_m
+ self.multipath_m * self.multipath_m
+ self.survey_m * self.survey_m)
.sqrt()
}
}
/// Realized 1σ accuracy (m) resolved from a DOP and a user-equivalent ranging error.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct RealizedAccuracy {
/// 3D position 1σ (m) = PDOP · σ_URE.
pub pos_3d_m: f64,
/// Horizontal position 1σ (m) = HDOP · σ_URE.
pub horizontal_m: f64,
/// Vertical position 1σ (m) = VDOP · σ_URE.
pub vertical_m: f64,
/// Time-solution 1σ as range (m) = TDOP · σ_URE.
pub time_m: f64,
}
/// Map a (beacon-augmented) DOP to a realized 1σ accuracy given a user-equivalent
/// ranging error `σ_URE` (m): the standard GNSS relation `σ = DOP · σ_URE` applied per
/// component. This is what turns a dimensionless "GDOP 1.6" into metres.
pub fn realized_accuracy(d: &Dop, sigma_ure_m: f64) -> RealizedAccuracy {
RealizedAccuracy {
pos_3d_m: d.pdop * sigma_ure_m,
horizontal_m: d.hdop * sigma_ure_m,
vertical_m: d.vdop * sigma_ure_m,
time_m: d.tdop * sigma_ure_m,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Build an MCMF position from selenographic latitude/longitude (deg) and antenna
/// height (m) above the mean sphere. Right-handed, +Z at the north pole.
fn site(lat_deg: f64, lon_deg: f64, height_m: f64) -> Vec3 {
let lat = lat_deg.to_radians();
let lon = lon_deg.to_radians();
let r = R_MOON_M + height_m;
[
r * lat.cos() * lon.cos(),
r * lat.cos() * lon.sin(),
r * lat.sin(),
]
}
/// A high-altitude relay above a given sub-point (a crude MCI-ish position for a
/// geometry test): radius `R_MOON + alt_m` toward the (lat, lon) direction.
fn relay(lat_deg: f64, lon_deg: f64, alt_m: f64) -> Vec3 {
site(lat_deg, lon_deg, alt_m)
}
/// Place a surface site at ground distance `dist_m` and azimuth `az_deg` from a
/// reference (lat, lon), at antenna height `height_m` (spherical direct geodesic).
/// Used to put beacons a few km from the user at diverse azimuths, within horizon.
fn offset_site(lat_deg: f64, lon_deg: f64, dist_m: f64, az_deg: f64, height_m: f64) -> Vec3 {
let lat = lat_deg.to_radians();
let lon = lon_deg.to_radians();
let az = az_deg.to_radians();
let ang = dist_m / R_MOON_M;
let lat2 = (lat.sin() * ang.cos() + lat.cos() * ang.sin() * az.cos()).asin();
let lon2 =
lon + (az.sin() * ang.sin() * lat.cos()).atan2(ang.cos() - lat.sin() * lat2.sin());
let r = R_MOON_M + height_m;
[
r * lat2.cos() * lon2.cos(),
r * lat2.cos() * lon2.sin(),
r * lat2.sin(),
]
}
#[test]
fn beacon_error_budget_rss_is_closed_form() {
// Oracle: user-equivalent ranging error is the root-sum-square of independent
// components (Kaplan & Hegarty, UERE budget). clock 1.0, multipath 2.0,
// survey 0.5 -> sqrt(1 + 4 + 0.25) = sqrt(5.25) = 2.29128784...
let b = BeaconErrorBudget {
clock_sync_m: 1.0,
multipath_m: 2.0,
survey_m: 0.5,
};
assert!((b.sigma_ure_m() - 2.291_287_847_5).abs() < 1e-9);
}
#[test]
fn realized_accuracy_is_dop_times_uere() {
// Oracle: the standard GNSS accuracy relation sigma = DOP * sigma_URE per
// component. This is what converts the paper's "GDOP 1.6" into metres.
let d = Dop {
gdop: 2.0,
pdop: 1.6,
hdop: 1.1,
vdop: 1.2,
tdop: 0.9,
};
let sigma = 2.2912878;
let a = realized_accuracy(&d, sigma);
assert!((a.pos_3d_m - 1.6 * sigma).abs() < 1e-9);
assert!((a.horizontal_m - 1.1 * sigma).abs() < 1e-9);
assert!((a.vertical_m - 1.2 * sigma).abs() < 1e-9);
assert!((a.time_m - 0.9 * sigma).abs() < 1e-9);
}
#[test]
fn beacon_visibility_respects_the_airless_horizon() {
// Oracle: L01 surface_los_max. A user on a 2 m mast and a beacon on a 2 m mast
// see each other only within sqrt(2 R h_u) + sqrt(2 R h_b) ≈ 5.27 km; place one
// beacon just inside that range and one well beyond.
let user = site(-88.0, 0.0, 2.0);
// Near beacon: a small along-surface offset (~2 km). Far beacon: ~50 km away.
let near = site(-88.0, 2.0, 2.0); // ~1.1 km of arc at this latitude
let far = site(-80.0, 0.0, 2.0); // ~240 km of arc
assert!(beacon_visible(user, near), "near beacon should be visible");
assert!(
!beacon_visible(user, far),
"far beacon should be over the horizon"
);
}
#[test]
fn beacons_enable_a_solution_where_sparse_orbit_only_cannot() {
// A polar user with only THREE visible satellites has no DOP (rank-deficient);
// adding two surveyed surface beacons completes a solvable geometry. This is the
// sparse-coverage case P2 targets. DOP assembly inherits the Validated kernel.
let user = site(-85.0, 0.0, 1.6);
let sats = [
relay(-70.0, 0.0, 5.0e6),
relay(-75.0, 120.0, 5.0e6),
relay(-72.0, 240.0, 5.0e6),
];
assert!(
crate::lunar_service::service_dop(user, &sats, 5.0_f64.to_radians()).is_none(),
"three satellites alone must be rank-deficient"
);
// Two surveyed beacons 4 km from the user (within the ~5.6 km horizon) at
// azimuths 90 deg apart.
let beacons = [
offset_site(-85.0, 0.0, 4_000.0, 0.0, 3.0),
offset_site(-85.0, 0.0, 4_000.0, 90.0, 3.0),
];
assert_eq!(
visible_beacons(user, &beacons).len(),
2,
"both beacons visible"
);
let d = dop_with_beacons(user, &sats, &beacons, 5.0_f64.to_radians())
.expect("3 sats + 2 beacons is solvable");
assert!(d.gdop.is_finite() && d.gdop > 0.0, "GDOP {}", d.gdop);
}
#[test]
fn beacons_cut_the_polar_gdop() {
// Four satellites clustered high over the pole give a poorly-conditioned,
// large-GDOP geometry for an -85 deg user; three surface beacons spread in
// azimuth add the horizontal rows that collapse it. Asserts the P2 mechanism
// (a large GDOP reduction), not the paper's exact 16.2 -> 1.6 (that scenario is
// reproduced in the Phase-4 pack).
let user = site(-85.0, 0.0, 1.6);
let sats = [
relay(-84.0, 0.0, 5.0e6),
relay(-84.0, 30.0, 5.0e6),
relay(-83.5, 60.0, 5.0e6),
relay(-84.5, 90.0, 5.0e6),
];
let sats_only = crate::lunar_service::service_dop(user, &sats, 5.0_f64.to_radians())
.expect("clustered sats give a (large) GDOP");
// Three beacons 4 km from the user (within horizon) at 120 deg azimuth spacing.
let beacons = [
offset_site(-85.0, 0.0, 4_000.0, 0.0, 3.0),
offset_site(-85.0, 0.0, 4_000.0, 120.0, 3.0),
offset_site(-85.0, 0.0, 4_000.0, 240.0, 3.0),
];
assert_eq!(
visible_beacons(user, &beacons).len(),
3,
"all beacons visible"
);
let augmented = dop_with_beacons(user, &sats, &beacons, 5.0_f64.to_radians())
.expect("sats + beacons solvable");
assert!(
augmented.gdop < sats_only.gdop,
"beacons should cut GDOP: {} -> {}",
sats_only.gdop,
augmented.gdop
);
}
}