Skip to content

Commit 836eb59

Browse files
mobility: (fixes #1308) Fix elevation angle computation
GeocentricConstantPositionMobilityModel::DoGetElevationAngle selected the "lower" terminal by ECEF z-coordinate, which is the distance from the equatorial plane rather than the altitude, so for many geometries the wrong terminal was chosen as the horizon vertex; it also took std::abs of the dot-product numerator and of the final angle, so a below-horizon terminal reported a positive elevation. Select the lower terminal by distance from the Earth's centre and keep the signs, so the elevation is correct, negative below the horizon, and independent of argument order. Add a unit test covering zenith satellites across a latitude sweep, a GEO satellite seen from 45 N and its southern-hemisphere mirror, oblique north/south mirror geometries at high latitude (which the z-based vertex selection broke), longitude rotational invariance, an exact-horizon (0 degree) terminal, argument-order independence, and a below-horizon terminal.
1 parent eaa52dc commit 836eb59

4 files changed

Lines changed: 174 additions & 8 deletions

File tree

src/mobility/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ build_lib(
6161
test/box-line-intersection-test.cc
6262
test/geo-to-cartesian-test.cc
6363
test/geocentric-topocentric-conversion-test.cc
64+
test/geocentric-elevation-angle-test.cc
6465
test/mobility-test-suite.cc
6566
test/mobility-trace-test-suite.cc
6667
test/ns2-mobility-helper-test-suite.cc

src/mobility/model/geocentric-constant-position-mobility-model.cc

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -195,21 +195,29 @@ GeocentricConstantPositionMobilityModel::DoGetElevationAngle(
195195
Vector me = this->DoGetGeocentricPosition();
196196
Vector them = other->DoGetGeocentricPosition();
197197

198-
// a is assumed to be the terminal with the lowest altitude
199-
Vector& a = (me.z < them.z ? me : them);
200-
Vector& b = (me.z < them.z ? them : me);
198+
// a is the terminal with the lowest altitude, used as the vertex whose local
199+
// vertical (the radial direction a/|a|) defines the horizon. The altitude is
200+
// approximated by the distance from the Earth's centre, not by the ECEF
201+
// z-coordinate, which is the distance from the equatorial plane
202+
// (@issueid{1308}).
203+
Vector& a = (me.GetLength() < them.GetLength() ? me : them);
204+
Vector& b = (me.GetLength() < them.GetLength() ? them : me);
201205

202206
Vector bMinusA = b - a;
203-
double numerator = std::abs(a * bMinusA);
207+
// sin(elevation) = (a . (b - a)) / (|a| |b - a|). The dot product is signed:
208+
// it is negative when the other terminal is below the local horizon, so the
209+
// sign must be preserved.
210+
double numerator = a * bMinusA;
204211
double denominator = a.GetLength() * bMinusA.GetLength();
205212
double x = numerator / denominator;
206213

207214
// Enforce the appropriate domain range ([-1, 1]) for the argument of std::asin.
208215
x = std::min(x, 1.0);
209216
x = std::max(x, -1.0);
210217

211-
// asin returns radians, we convert to degrees
212-
double elevAngle = std::abs((180.0 * M_1_PI) * asin(x));
218+
// asin returns radians, we convert to degrees. A negative result means the
219+
// other terminal is below the local horizon.
220+
double elevAngle = (180.0 * M_1_PI) * asin(x);
213221

214222
return elevAngle;
215223
}

src/mobility/model/geocentric-constant-position-mobility-model.h

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ class GeocentricConstantPositionMobilityModel : public MobilityModel
5050
* After calculating the plane perpendicular to a cartesian position vector,
5151
* the elevation angle is calculated using
5252
* https://www.w3schools.blog/angle-between-a-line-and-a-plane.
53-
* The altitude of the position passed as a parameter must be higher than that of the reference
54-
* point.
53+
* The elevation is measured at the lower of the two terminals (the one closer
54+
* to the Earth's centre); the result is independent of the argument order. A
55+
* negative value indicates that the other terminal is below the local horizon.
5556
* @param other pointer to the HAPS/satellite mobility model
5657
* @return the elevation angle
5758
* in degrees
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) 2026 Centre Tecnologic de Telecomunicacions de Catalunya (CTTC)
3+
*
4+
* SPDX-License-Identifier: GPL-2.0-only
5+
*
6+
* Author: Gabriel Ferreira <gabrielcarvfer@gmail.com>
7+
*/
8+
9+
#include "ns3/geocentric-constant-position-mobility-model.h"
10+
#include "ns3/geographic-positions.h"
11+
#include "ns3/test.h"
12+
13+
#include <cmath>
14+
15+
using namespace ns3;
16+
17+
/**
18+
* @ingroup mobility-test
19+
*
20+
* @brief Tests GeocentricConstantPositionMobilityModel::GetElevationAngle (@issueid{1308}).
21+
*
22+
* The elevation angle is measured at the lower terminal, with the local
23+
* vertical given by the geocentric radial direction (the model uses a perfect
24+
* sphere). The angle must be signed (negative when the other terminal is
25+
* below the local horizon), independent of the argument order, and identical
26+
* for mirrored geometries in either hemisphere. This test pins that behavior.
27+
*/
28+
class GeocentricElevationAngleTestCase : public TestCase
29+
{
30+
public:
31+
GeocentricElevationAngleTestCase()
32+
: TestCase("GeocentricConstantPositionMobilityModel elevation angle")
33+
{
34+
}
35+
36+
private:
37+
void DoRun() override;
38+
39+
/// Build a model at the given geographic position.
40+
/// @param lat latitude (deg)
41+
/// @param lon longitude (deg)
42+
/// @param alt altitude (m)
43+
/// @return the configured mobility model
44+
static Ptr<GeocentricConstantPositionMobilityModel> Make(double lat, double lon, double alt)
45+
{
46+
auto m = CreateObject<GeocentricConstantPositionMobilityModel>();
47+
m->SetGeographicPosition(Vector(lat, lon, alt));
48+
return m;
49+
}
50+
};
51+
52+
void
53+
GeocentricElevationAngleTestCase::DoRun()
54+
{
55+
constexpr double TOL = 1e-2; // degrees
56+
const double geoAlt = 35786e3;
57+
const double leoAlt = 600e3;
58+
59+
// A satellite directly above the ground station is at zenith: 90 degrees.
60+
auto gs = Make(45.0, 0.0, 0.0);
61+
auto overhead = Make(45.0, 0.0, geoAlt);
62+
NS_TEST_ASSERT_MSG_EQ_TOL(gs->GetElevationAngle(overhead),
63+
90.0,
64+
TOL,
65+
"Satellite at zenith should have 90 degrees elevation");
66+
67+
// GEO satellite over the equator seen from a ground station at 45 N.
68+
auto geoSat = Make(0.0, 0.0, geoAlt);
69+
double elevFromGs = gs->GetElevationAngle(geoSat);
70+
NS_TEST_ASSERT_MSG_EQ_TOL(elevFromGs, 38.1771, TOL, "Wrong elevation for GEO over the equator");
71+
// The result must be independent of the argument order.
72+
NS_TEST_ASSERT_MSG_EQ_TOL(geoSat->GetElevationAngle(gs),
73+
elevFromGs,
74+
TOL,
75+
"Elevation angle must not depend on the argument order");
76+
77+
// A terminal below the local horizon must yield a negative elevation.
78+
auto equatorGs = Make(0.0, 0.0, 0.0);
79+
auto farSat = Make(0.0, 120.0, leoAlt);
80+
NS_TEST_ASSERT_MSG_EQ_TOL(equatorGs->GetElevationAngle(farSat),
81+
-58.5127,
82+
TOL,
83+
"Below-horizon terminal should have a negative elevation");
84+
85+
// Southern-hemisphere mirror of the GEO case: the geometry is symmetric,
86+
// so the elevation must match the northern result.
87+
auto southGs = Make(-45.0, 0.0, 0.0);
88+
NS_TEST_ASSERT_MSG_EQ_TOL(southGs->GetElevationAngle(geoSat),
89+
elevFromGs,
90+
TOL,
91+
"Southern-hemisphere GEO elevation must mirror the northern one");
92+
93+
// Oblique LEO geometry at high latitude and its southern mirror: the
94+
// endpoints have genuinely different local verticals, so hemisphere
95+
// symmetry is a strong discriminator for the vertex selection.
96+
auto northHighGs = Make(60.0, 0.0, 0.0);
97+
auto northLeo = Make(50.0, 0.0, leoAlt);
98+
double northElev = northHighGs->GetElevationAngle(northLeo);
99+
NS_TEST_ASSERT_MSG_EQ_TOL(northElev, 22.2040, TOL, "Wrong elevation for oblique LEO geometry");
100+
auto southHighGs = Make(-60.0, 0.0, 0.0);
101+
auto southLeo = Make(-50.0, 0.0, leoAlt);
102+
NS_TEST_ASSERT_MSG_EQ_TOL(southHighGs->GetElevationAngle(southLeo),
103+
northElev,
104+
TOL,
105+
"North and south mirror geometries must have equal elevation");
106+
107+
// Longitude rotational invariance: shifting both terminals by the same
108+
// longitude must not change the (purely relative) geometry.
109+
auto rotatedGs = Make(60.0, 130.0, 0.0);
110+
auto rotatedLeo = Make(50.0, 130.0, leoAlt);
111+
NS_TEST_ASSERT_MSG_EQ_TOL(rotatedGs->GetElevationAngle(rotatedLeo),
112+
northElev,
113+
TOL,
114+
"Elevation must be invariant under a common longitude shift");
115+
116+
// Exact horizon: place the satellite so the line of sight grazes the
117+
// sphere and the ground station sees it at exactly 0 degrees, pinning the
118+
// sign boundary between visible and below-horizon terminals.
119+
const double horizonLon = std::acos(GeographicPositions::EARTH_SPHERE_RADIUS /
120+
(GeographicPositions::EARTH_SPHERE_RADIUS + leoAlt)) *
121+
180.0 / M_PI;
122+
auto horizonSat = Make(0.0, horizonLon, leoAlt);
123+
NS_TEST_ASSERT_MSG_EQ_TOL(equatorGs->GetElevationAngle(horizonSat),
124+
0.0,
125+
TOL,
126+
"Satellite at the geometric horizon should have 0 degrees elevation");
127+
128+
// Zenith sanity sweep across latitudes. Zenith is degenerate (both radial
129+
// directions coincide), so this is an invariant check.
130+
for (double lat : {-80.0, -30.0, 0.0, 30.0, 80.0})
131+
{
132+
NS_TEST_ASSERT_MSG_EQ_TOL(Make(lat, 0.0, 0.0)->GetElevationAngle(Make(lat, 0.0, leoAlt)),
133+
90.0,
134+
TOL,
135+
"Zenith satellite should have 90 degrees elevation at latitude "
136+
<< lat);
137+
}
138+
}
139+
140+
/**
141+
* @ingroup mobility-test
142+
*
143+
* @brief Geocentric elevation angle test suite.
144+
*/
145+
class GeocentricElevationAngleTestSuite : public TestSuite
146+
{
147+
public:
148+
GeocentricElevationAngleTestSuite()
149+
: TestSuite("geocentric-elevation-angle", Type::UNIT)
150+
{
151+
AddTestCase(new GeocentricElevationAngleTestCase, TestCase::Duration::QUICK);
152+
}
153+
};
154+
155+
static GeocentricElevationAngleTestSuite
156+
g_geocentricElevationAngleTestSuite; //!< Static variable for test initialization

0 commit comments

Comments
 (0)