Link Budget and Visibility¶
Purpose¶
This specification defines the mathematical contract for LuPNT’s GNSS
line-of-sight visibility test and its carrier-to-noise-density
(\(C/N_0\)) link budget. Together these decide which GNSS channels a
lunar receiver can track and how noisy each observable is. The equations
here feed the C/N0 threshold and the tracking-loop noise
(\(\sigma_P, \sigma_D, \sigma_\Phi\)) referenced by
docs/math/gnss_measurements.rst. The concrete code lives in
cpp/lupnt/measurements/comms_utils.{h,cc} (path loss, tracking-loop noise,
visibility), cpp/lupnt/measurements/antenna.{h,cc} (gain patterns),
cpp/lupnt/measurements/gnss_measurement.cc (the LinkBudget /
ComputeCN0 glue), and cpp/lupnt/attitude/gnss_attitude.* /
gnss_yaw_steering.* (transmitter boresight). The reference model is the
author’s PhD thesis, Chapter 6.4.3 (link budget) and Chapter 3.3 (EIRP); the
corresponding equations are cited inline.
Visibility Geometry¶
Line-of-sight visibility between two points \(r_1\) and \(r_2\) in a
common frame, in the presence of one spherical occulting body of radius
\(R_B\) centered at \(r_B\), is evaluated by
ComputeVisibility (cpp/lupnt/measurements/comms_utils.cc ::
ComputeVisibility). It is invoked per transmitter channel from
GNSSMeasurements::BuildChannels when options.apply_visibility is set,
once per configured GnssOccludingBody (typically the Moon and the Earth).
bool ComputeVisibility(const Vec3& r1, const Vec3& r2, Real R_body,
const Vec3& r_body = Vec3::Zero(),
const Real min_alt = 10e3,
const Real min_elev_deg = 5.0);
The test distinguishes two regimes based on whether either endpoint is within
\(R_B + h_\mathrm{min}\) of the body center
(\(h_\mathrm{min} =\) min_alt).
Elevation-mask regime (near-surface endpoint). If \(r_1\) is a surface point, the local elevation of the link toward \(r_2\) is
and the link is rejected when \(e_1 < e_\mathrm{min}\) with
\(e_\mathrm{min} =\) min_elev_deg (default \(5^\circ\)). The same
test is applied symmetrically if \(r_2\) is a surface point.
Horizon-occlusion regime (both endpoints elevated). For a space-to-space link the body is treated as a hard sphere. With \(r = r_2 - r_1\), \(r_{1B} = r_1 - r_B\),
The line of sight is blocked (returns false) when the transmitter lies
behind the body’s limb and beyond the tangent point:
Note
ComputeVisibility handles a single occluding body per call;
BuildChannels loops over all configured GnssOccludingBody entries
and requires every one to pass. The GNSS spec’s near-surface threshold of
\(e > -10^\circ\) is the scenario-level min_elev_deg passed by the
caller; the code default is \(+5^\circ\).
C/N0 Link-Budget Equation¶
The received carrier-to-noise-density ratio for one channel is assembled by
the anonymous-namespace helper LinkBudget and driven by
GNSSMeasurements::ComputeCN0
(cpp/lupnt/measurements/gnss_measurement.cc :: LinkBudget /
ComputeCN0):
Real LinkBudget(Real P_tx_dbw, Real G_tx_db, Real G_rx_db, Real range_m,
GnssFreq freq, const GnssReceiverParams& rx_params) {
constexpr double k_B = 1.38e-23; // [J/K] Boltzmann
Real L_fs = FreeSpacePathLoss(range_m, freq_Hz);
double kb_db = 10.0 * std::log10(k_B);
return P_tx_dbw + G_tx_db + G_rx_db
- rx_params.L_atm - L_fs - rx_params.L_ad
- rx_params.L_pol - kb_db - 10.0 * log10(rx_params.T_eff);
}
In closed form, with all terms in decibels,
where \(P_\mathrm{tx}\) is the transmit power [dBW],
\(G_\mathrm{tx}\)/\(G_\mathrm{rx}\) are the transmit/receive antenna
gains [dBi], \(L_\mathrm{fs}\) is free-space path loss,
\(L_\mathrm{atm}\) is atmospheric loss (0 for the vacuum lunar link),
\(L_\mathrm{ad}\) is A/D-converter / implementation loss,
\(L_\mathrm{pol}\) is polarization loss, \(k_B\) is Boltzmann’s
constant, and \(T_\mathrm{eff}\) is the effective system noise
temperature. These live on GnssReceiverParams
(cpp/lupnt/agents/gnss_constellation.h) with defaults
\(L_\mathrm{ad}=0.6\), \(L_\mathrm{pol}=1.0\),
\(L_\mathrm{atm}=0.0\) dB, \(T_\mathrm{eff}=167.98\) K.
Note
This is thesis Eq. (6.59), \((C/N_0) = P_\mathrm{tx} + G_\mathrm{tx} + G_\mathrm{rx} - 20\log_{10}(4\pi d f / c) - L_\mathrm{pol} - 10\log_{10}(k_B T_\mathrm{sys}) - R_\mathrm{loss}\), with two bookkeeping differences: the code splits the single thesis noise term \(10\log_{10}(k_B T_\mathrm{sys})\) into \(10\log_{10}(k_B) + 10\log_{10}(T_\mathrm{eff})\) (identical value), and it carries an extra \(L_\mathrm{atm}\) term (zero on the lunar link) and names the implementation loss \(L_\mathrm{ad}\) rather than \(R_\mathrm{loss}\). Thesis Table 6.3 gives the reference parameter values (GPS \(P_\mathrm{tx}\!\approx\!16\text{--}19\) dBW, \(G_\mathrm{rx}=14\) dBi, \(T_\mathrm{sys}=162\) K, \(L_\mathrm{pol}=1.0\) dB, \(R_\mathrm{loss}=0.9\) dB).
Free-Space Path Loss¶
Free-space path loss is FreeSpacePathLoss
(cpp/lupnt/measurements/comms_utils.cc):
Real FreeSpacePathLoss(Real dist, Real freq) {
return 20.0 * log10((4.0 * PI * dist) / (C / freq));
}
i.e.
with \(d\) the transmitter-receiver slant range, \(f\) the carrier
frequency (from GNSS_FREQ_MAP), and \(\lambda = c/f\). This is thesis
Eq. (3.18) / the loss term of Eq. (6.59).
EIRP¶
LuPNT carries the transmit power and transmit antenna gain separately:
GnssConstellation::GetTransmitPowerDbw supplies \(P_\mathrm{tx}\) and
GnssConstellation::GetTransmitterAntenna supplies the pattern that yields
\(G_\mathrm{tx}(\theta_\mathrm{tx}, \varphi_\mathrm{tx})\). The
equivalent isotropically radiated power is the sum
For constellations that publish EIRP directly rather than power-plus-pattern (e.g. Galileo), \(P_\mathrm{tx}\) is loaded as the EIRP and the transmit pattern is set to its peak-referenced shape.
The thesis instead poses the required EIRP as a design output (Chapter 3.3, Eq. (3.17)) for a surface user at a fixed received-power floor \(P_\mathrm{rx}\):
with \(L_\mathrm{tx}, L_\mathrm{rx}\) the transmitter/receiver cable losses (thesis Table 3.6, \(f = 2492.028\) MHz, \(P_\mathrm{rx}=-160/-147\) dBW, \(G_\mathrm{rx}=3.0\) dBi). This is the inverse of the link-budget code above: LuPNT propagates a known EIRP forward to \(C/N_0\); the thesis solves for the EIRP that meets a \(C/N_0\) (or received-power) requirement.
Antenna Gain Patterns¶
The gains \(G_\mathrm{tx}\) and \(G_\mathrm{rx}\) are table lookups
into measured patterns, Antenna::ComputeGain
(cpp/lupnt/measurements/antenna.cc :: ComputeGain). A pattern is a
function of the off-boresight (elevation) angle \(\varphi\) and azimuth
\(\theta\) about the boresight:
Real Antenna::ComputeGain(Real theta, Real phi) const {
if (n_dim_ == 0) return 0.0; // omni
theta = WrapToTwoPi(theta) * DEG;
phi = WrapToPi(phi) * DEG;
if (phi > phi_max_ || phi < -phi_max_) return NAN; // outside coverage
if (phi < 0 && phi_(0) >= 0) phi = -phi; // mirror symmetry
if (n_dim_ == 2) gain = LinearInterp2d(phi_, theta_, gain_, phi, theta);
else gain = LinearInterp1d(phi_, gain_, phi);
return gain;
}
Patterns are read from ANTEX/ACE data files by Antenna::LoadAntennaPattern
(1D \(G(\varphi)\) or 2D \(G(\varphi,\theta)\) tables), normalized to
\(\varphi \in [-180,180]^\circ\), \(\theta \in [0,360]^\circ\) by
FormatAntennaPattern. An empty name gives an omni antenna
(\(G \equiv 0\)), and directions beyond the pattern’s phi_max_ return
NaN (used as an implicit sidelobe cutoff). A closed-form parabolic
alternative is available for quick studies,
cpp/lupnt/measurements/comms_utils.cc :: ParabolicAntennaGain:
Transmitter Attitude and Boresight Angles¶
Evaluating \(G_\mathrm{tx}\) requires the transmitter body frame, because
the off-boresight angles \((\theta_\mathrm{tx}, \varphi_\mathrm{tx})\) are
defined in the GNSS satellite’s yaw-steering attitude.
GNSSMeasurements::ComputeCN0 builds that frame with
GnssAttitude::Compute (cpp/lupnt/attitude/gnss_attitude.cc) at the
transmit epoch, then projects the line of sight onto it:
GnssAttitude::Compute(r_tx_gcrf, v_tx_gcrf, r_sun_gcrf, ex, ey, ez);
Real phi_tx = safe_acos(u_tx2rx_gcrf.dot(ez)); // off-boresight
Real theta_tx = atan2(u_tx2rx_gcrf.dot(ey), u_tx2rx_gcrf.dot(ex));
Real phi_rx = safe_acos(u_rx2body.dot(u_rx2tx)); // receiver off-boresight
Real G_tx = constellation->GetTransmitterAntenna(prn, freq)
.ComputeGain(theta_tx, phi_tx);
Real G_rx = rx_antenna_.ComputeGain(0.0, phi_rx);
The orthonormal transmitter triad \((e_x, e_y, e_z)\) is the nominal “yaw-steering” body frame:
The boresight/off-boresight and azimuth angles of the transmitter-to-receiver unit vector \(\hat{u}\) are
For the receiver, BoresightTarget(t) supplies the receive-antenna
boresight aim point (default the frame origin; a provider can point it at the
Earth, per the thesis Earth-pointing receiver), and
\(\varphi_\mathrm{rx}\) is the angle between the boresight direction and
the receiver-to-transmitter line of sight.
Yaw-Steering Attitude Law¶
The velocity-aware overload GnssAttitude::Compute(r, v, r_sun, ...) builds
the same Sun-pointing frame through the documented nominal yaw-steering
law (Kouba 2009; Cheng et al. 2025, Eq. 1), implemented in
cpp/lupnt/attitude/gnss_yaw_steering.cc. The satellite-Sun-Earth geometry
is parameterized by the Sun elevation above the orbital plane \(\beta\)
and the orbit angle \(\mu\) from the midnight point:
Real GnssYawSteering::BetaAngle(...) {
Vec3 n = r_sat.cross(v_sat).normalized(); // orbit normal
return safe_asin(n.dot(r_sun.normalized()));
}
Real GnssYawSteering::NominalYawAngle(Real beta, Real mu) {
return atan2(-tan(beta), sin(mu)); // Eq. (1)
}
ComputeFromYawAngle then rotates the orbital reference axes
(\(e_x^\mathrm{ref}=\hat{v}\), \(e_y^\mathrm{ref}=e_z\times e_x^\mathrm{ref}\))
about the nadir axis by \(\psi\),
which is numerically identical to the direct Sun-vector construction for the nominal case. The class also provides block-specific modeled yaw laws for eclipse/noon/midnight turns (GPS IIF/IIR/III, Galileo IOV/FOC, BDS-3 CAST/SECM; Eqs. 3-16), used to replace \(\psi\) during maneuver windows.
Note
The thesis (Chapter 6.4.3) assumes nominal yaw steering with the
antenna boresight aligned to the spacecraft-Earth direction and the solar
panel axis orthogonal to the Earth-satellite-Sun plane; off-nominal
eclipse-season steering is noted as future work. LuPNT implements the
nominal law used by ComputeCN0 and additionally exposes the maneuver
laws as building blocks, but no maneuver-window state machine is wired into
the C/N0 path yet.
Receiver Tracking-Loop Noise from C/N0¶
Once \(C/N_0\) is known, the per-observable measurement standard
deviations follow from the tracking-loop thermal-noise models. Let
\(\gamma = 10^{(C/N_0)_\mathrm{dB\text{-}Hz}/10}\) be the linear
carrier-to-noise ratio (DecibelToDecimal).
Pseudorange (DLL). GNSSMeasurements::ComputeSigmaRange scales the
dimensionless code-tracking jitter SigmaDll
(cpp/lupnt/measurements/comms_utils.cc) by the chip length in meters
\(c\,T_c\):
return SigmaDll(params, CN0_w) * (Real(C) * Tc); // Tc = 1 / Rc
For the wide-band case (\(d \le 1/(T_c B_\mathrm{fe})\)) the DLL variance is
matching thesis Eq. (6.60) (with \(T_n\) the DLL integration time,
\(B_n\) the DLL bandwidth, \(d\) the early-late spacing,
\(B_\mathrm{fe} = b\,R_c\)). SigmaDll also carries the intermediate-
and wide-correlator cases.
Carrier phase (PLL). GNSSMeasurements::ComputeSigmaCarrierPhase
converts the PLL phase jitter to length via \(\lambda/2\pi\):
Note
The thesis Eq. (6.61) writes
\(\sigma_\phi^2 = (\lambda/2\pi)^2\,[\,B_p/(2\gamma)\,(1 + 1/(2 T_p
\gamma))\,]\). The LuPNT SigmaPll uses \(B_p/\gamma\) (no factor of
two in the denominator), so the code’s carrier-phase variance is larger
than the thesis expression by a factor of two. The DLL term matches the
thesis (both use \(B_n/(2\gamma)\)).
Doppler / range-rate (FLL).
GNSSMeasurements::ComputeSigmaRangeRate uses a frequency-lock-loop model:
with \(B_f\) the FLL bandwidth and \(T\) the integration time.
Model Boundaries¶
Visibility uses spherical occulting bodies and a per-body pass/fail; no atmospheric refraction or terrain horizon beyond the elevation mask.
The C/N0 budget is per-channel and static in loss terms (\(L_\mathrm{atm}, L_\mathrm{ad}, L_\mathrm{pol}, T_\mathrm{eff}\)); only \(L_\mathrm{fs}\) and the two antenna gains vary with geometry.
The transmit gain uses the nominal yaw-steering attitude; maneuver-law attitudes are available in
GnssYawSteeringbut not yet applied inComputeCN0.The PLL noise coefficient differs from the thesis by a factor of two (see note above); the DLL and FLL forms match their reference expressions.