Reference cases for the path-analysis mathematics (#108) (#117)

* test: pin the Maidenhead and great-circle maths of Location against definitions (#108)

Subsquare centres from the Maidenhead definition, round trip through
toMaidenhead (lower-case subsquare pinned as is), distance and bearing
between locators against an independent spherical computation on
R = 6371.009 km, consistency with PathGeometryUtils, and the NaN bearing
for identical points pinned as current behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin PathGeometryUtils against ITU-R P.525/P.526 and textbook values (#108)

Great circle against the Veness worked example, Fresnel radius against
P.526-15 eq. (2) and (3), earth bulge against the 8500 km effective
radius, radio horizon against 4.12*sqrt(h), knife-edge loss J(v) against
eq. (31), the v parameter against eq. (26), free-space loss pinned at the
project's 32.44 constant with the P.525 and exact values documented,
plus wattsToDbm, the sample-count rule and the k-factor sanitiser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: pin the path-analysis pipeline on a flat profile (#108)

A flat 20 km profile with 10 m antennas at 144.3 MHz through an
in-memory terrain provider: bulge, line-of-sight clearance, first
Fresnel radius and intrusion, no knife edge and 0 dB diffraction. Pins
that a partial Fresnel intrusion without a blocked line of sight
contributes 0 dB to the link budget; P.526 would give 0 to 6 dB there,
which is tracked separately.

Fixes #108

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude Fable 5.1 authored and GitHub committed 2026-10-11 18:39:48 +02:00
1 parent 91863fee1f
commit 7b44eb0c2e
3 files changed
+618

No files matched your search

@@ -0,0 +1,168 @@
package kst4contest.locatorUtils;
import kst4contest.view.map.PathGeometryUtils;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Reference cases for the Maidenhead and great-circle maths in {@link Location}.
*
* <p>None of the expected values below was derived from the code under test. The
* Maidenhead centres follow from the definition of the locator system, the distances
* and bearings from an independent spherical computation (haversine and atan2, see
* {@link #expectedDistanceAndBearingOfLocatorPairs}), both on the IUGG mean radius
* R = 6371.009 km that {@code Location} declares. Behaviour that is merely pinned
* (not endorsed) is marked as such in the test method's Javadoc.</p>
*
* <p>Context: issue #108.</p>
*/
class LocationMaidenheadReferenceTest {
/** Tolerance for a subsquare centre: well below the 1e-7 degree print precision. */
private static final double CENTRE_TOLERANCE_DEG = 1e-6;
/** Tolerance for values that the code rounds to 0.01 km / 0.01 degrees. */
private static final double ROUNDED_TOLERANCE = 0.005;
/**
* Locator to subsquare centre, by definition.
*
* <p>Source: Wikipedia, "Maidenhead Locator System". A field spans 20 degrees of
* longitude and 10 degrees of latitude, a square 2 by 1 degrees, a subsquare 5 by
* 2.5 arc minutes. The centre of a subsquare is therefore</p>
* <pre>
* lon = -180 + 20 * F1 + 2 * S1 + (5/60) * X1 + (5/60)/2
* lat = -90 + 10 * F2 + S2 + (2.5/60) * X2 + (2.5/60)/2
* </pre>
* <p>with F = field letter index (A = 0), S = square digit, X = subsquare letter
* index. Tolerance: 1e-6 degrees.</p>
*/
@Test
void locatorIsResolvedToTheCentreOfItsSubsquare() {
assertCentre("JO62QM", 52.5208333, 13.3750000);
assertCentre("JN49FK", 49.4375000, 8.4583333);
assertCentre("JO50JP", 50.6458333, 10.7916667);
assertCentre("AA00AA", -89.9791667, -179.9583333);
assertCentre("RR99XX", 89.9791667, 179.9583333);
}
/**
* Round trip from the subsquare centre back to the locator.
*
* <p>The coordinates are the JO62QM centre of the previous test. The expected string
* uses a lower-case subsquare: that is what {@link Location#toMaidenhead(double, double)}
* produces today and is pinned here, not endorsed. The locator system itself is
* case-insensitive; the ON4KST convention is upper case.</p>
*/
@Test
void centreCoordinatesRoundTripToTheLocatorWithLowerCaseSubsquare() {
assertEquals("JO62qm", Location.toMaidenhead(52.5208333, 13.375));
}
/**
* Distance and bearing between locator centres against an independent spherical
* computation.
*
* <p>Expected values were computed with Python 3 ({@code python3 -I}) on the subsquare
* centres of the previous test, R = 6371.009 km, using</p>
* <pre>
* a = sin^2(dphi/2) + cos(phi1) * cos(phi2) * sin^2(dlambda/2)
* d = 2 * R * atan2(sqrt(a), sqrt(1 - a)) (haversine)
* theta = atan2(sin(dlambda) * cos(phi2),
* cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dlambda))
* bearing = (deg(theta) + 360) mod 360
* </pre>
* <p>giving JO50JP to JO31AA 336.1207 km / 278.963 deg, JO50JP to JN49FK
* 214.0343 km / 232.018 deg, JO62QM to JN58TD 502.0447 km / 195.002 deg.
* {@link Location#getDistanceKmByTwoLocatorStrings} and {@link Location#getBearing}
* round to two decimals, so the rounded values are pinned with a tolerance of
* 0.005 km and 0.005 degrees.</p>
*/
@Test
void expectedDistanceAndBearingOfLocatorPairs() {
assertDistanceAndBearing("JO50JP", "JO31AA", 336.12, 278.96);
assertDistanceAndBearing("JO50JP", "JN49FK", 214.03, 232.02);
assertDistanceAndBearing("JO62QM", "JN58TD", 502.04, 195.00);
}
/**
* The two distance implementations in the project agree.
*
* <p>{@link Location} uses the spherical law of cosines, {@link PathGeometryUtils}
* the haversine formula; both declare R = 6371.009 km. On the same pair of subsquare
* centres the unrounded results must agree within 0.005 km (the law of cosines loses
* precision only for distances far below one kilometre).</p>
*/
@Test
void lawOfCosinesAndHaversineAgreeOnTheSamePair() {
Location from = new Location("JO50JP");
Location to = new Location("JN58TD");
double lawOfCosinesKm = from.getDistanceKm(to);
double haversineKm = PathGeometryUtils.calculateGreatCircleDistanceKm(
from.getLatitude().toDegrees(),
from.getLongitude().toDegrees(),
to.getLatitude().toDegrees(),
to.getLongitude().toDegrees()
);
assertEquals(haversineKm, lawOfCosinesKm, ROUNDED_TOLERANCE,
"law of cosines vs haversine on JO50JP -> JN58TD");
}
/**
* Bearing between identical points in {@link Location}: NaN.
*
* <p>Pinned, not endorsed. The bearing between a point and itself is undefined;
* {@code Location.getBearing} answers {@code Double.NaN}.</p>
*/
@Test
void locationBearingOfIdenticalPointsIsNaN() {
Location point = new Location("JO62QM");
assertTrue(Double.isNaN(Location.getBearing(point, new Location("JO62QM"))),
"Location.getBearing for identical points");
}
/**
* Bearing between identical points in {@link PathGeometryUtils}: 0 degrees.
*
* <p>Pinned, not endorsed. For identical coordinates both atan2 arguments are zero
* and {@code atan2(0, 0)} is 0, so {@code calculateInitialBearingDeg} answers 0 where
* {@code Location.getBearing} answers NaN. The two implementations disagree on the
* undefined case; this test records the current answer.</p>
*/
@Test
void pathGeometryBearingOfIdenticalPointsIsZero() {
double bearingDeg = PathGeometryUtils.calculateInitialBearingDeg(
52.5208333, 13.375, 52.5208333, 13.375);
assertEquals(0.0, bearingDeg, 1e-12, "PathGeometryUtils bearing for identical points");
}
private static void assertCentre(String locator, double expectedLatDeg, double expectedLonDeg) {
Location location = new Location(locator);
assertEquals(expectedLatDeg, location.getLatitude().toDegrees(), CENTRE_TOLERANCE_DEG,
locator + " latitude");
assertEquals(expectedLonDeg, location.getLongitude().toDegrees(), CENTRE_TOLERANCE_DEG,
locator + " longitude");
}
private static void assertDistanceAndBearing(String fromLocator,
String toLocator,
double expectedRoundedKm,
double expectedRoundedBearingDeg) {
Location helper = new Location();
assertEquals(expectedRoundedKm,
helper.getDistanceKmByTwoLocatorStrings(fromLocator, toLocator),
ROUNDED_TOLERANCE,
fromLocator + " -> " + toLocator + " distance");
assertEquals(expectedRoundedBearingDeg,
helper.getBearingOfTwoLocatorStrings(fromLocator, toLocator),
ROUNDED_TOLERANCE,
fromLocator + " -> " + toLocator + " bearing");
}
}
@@ -0,0 +1,197 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The path-analysis pipeline of {@link GeometryOnlyPathAnalysisService} on a flat
* terrain profile, with every expected value computed from the underlying formulas
* rather than from the code.
*
* <p>Scenario: 20.000 km due north from 50.0 N 10.0 E, both antennas 10 m above a
* terrain of 0 m everywhere, 144.3 MHz, k = 4/3, default link-budget settings. A path
* along a meridian makes the haversine distance exactly R times the latitude difference,
* so the 20 km are hit to floating-point precision. The in-memory terrain provider hands
* back the requested number of samples (121 for 20 km, so the midpoint is sample 60 at
* exactly 10.000 km) with elevation 0.</p>
*
* <p>Expected midpoint values (R = 6 371 009 m, c = 299 792 458 m/s):</p>
* <pre>
* bulge = d1 d2 / (2 k R) = 1e4 * 1e4 / (2 * 4/3 * 6371009) = 5.886 m
* LOS height = 10 m (both endpoints have bulge 0 and antenna 10 m)
* clearance = 10 - 5.886 = 4.114 m
* lambda = c / 144.3e6 = 2.0776 m
* R1 = sqrt(lambda d1 d2 / (d1 + d2)) = sqrt(2.0776 * 5000) = 101.921 m
* intrusion = bulge - (LOS - R1) = 5.886 - (10 - 101.921) = 97.807 m
* </pre>
* <p>Tolerances: 0.05 m for bulge and clearance, 0.1 m for Fresnel radius and intrusion,
* 0.01 dB for losses.</p>
*
* <p>Context: issue #108.</p>
*/
class GeometryOnlyPathAnalysisServiceProfileTest {
private static final double EARTH_RADIUS_KM = 6371.009;
private static final double TOTAL_DISTANCE_KM = 20.0;
private static final double ANTENNA_HEIGHT_M = 10.0;
private static final double FREQUENCY_MHZ = 144.3;
private static final int MIDPOINT_INDEX = 60;
private static final double EXPECTED_MIDPOINT_BULGE_M = 5.886;
private static final double EXPECTED_MIDPOINT_LOS_CLEARANCE_M = 4.114;
private static final double EXPECTED_MIDPOINT_FRESNEL_RADIUS_M = 101.921;
private static final double EXPECTED_MIDPOINT_INTRUSION_M = 97.807;
/**
* Terrain provider that answers a flat profile of the requested sample count.
* Points are spaced uniformly along the path; latitude is interpolated linearly,
* which is exact for a meridian.
*/
private static final class FlatTerrainProfileProvider implements TerrainProfileProvider {
@Override
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
int sampleCount = request.requestedSampleCount();
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
for (int i = 0; i < sampleCount; i++) {
double fraction = (double) i / (sampleCount - 1);
double distanceKm = request.totalDistanceKm() * fraction;
double latitudeDeg = request.fromLatitudeDeg()
+ (request.toLatitudeDeg() - request.fromLatitudeDeg()) * fraction;
points.add(new PathProfilePoint(distanceKm, latitudeDeg, request.fromLongitudeDeg(), 0.0));
}
return new TerrainProfileData(points, "Flat test terrain", false);
}
}
private static PathAnalysisResult analyzeFlatPath() {
double fromLatitudeDeg = 50.0;
double longitudeDeg = 10.0;
double toLatitudeDeg = fromLatitudeDeg + Math.toDegrees(TOTAL_DISTANCE_KM / EARTH_RADIUS_KM);
PathAnalysisRequest request = new PathAnalysisRequest(
"JO50AA",
fromLatitudeDeg,
longitudeDeg,
"DL0TEST",
"JO50AE",
toLatitudeDeg,
longitudeDeg,
FREQUENCY_MHZ,
ANTENNA_HEIGHT_M,
ANTENNA_HEIGHT_M,
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
PathLinkBudgetSettings.defaults()
);
return new GeometryOnlyPathAnalysisService(new FlatTerrainProfileProvider()).analyze(request);
}
/**
* The scenario is what the Javadoc says: 20.000 km, 121 samples, midpoint at 10 km.
*
* <p>Sample count: ceil(20 / 0.5) + 1 = 41, clamped to the minimum of 121.
* Tolerance: 1e-6 km for the distance.</p>
*/
@Test
void scenarioIsTwentyKilometresWithTheMidpointAtSampleSixty() {
PathAnalysisResult result = analyzeFlatPath();
assertTrue(result.hasUsableProfile(), "profile usable");
assertEquals(TOTAL_DISTANCE_KM, result.distanceKm(), 1e-6, "path distance");
assertEquals(121, result.profilePoints().size(), "sample count");
assertEquals(10.0, result.profilePoints().get(MIDPOINT_INDEX).distanceKm(), 1e-9, "midpoint distance");
}
/**
* Earth bulge at the midpoint: d1 d2 / (2 k R) = 5.886 m at k = 4/3.
*
* <p>On a flat profile the curvature-adjusted elevation is the bulge itself.
* Tolerance: 0.05 m.</p>
*/
@Test
void midpointBulgeIsFivePointNineMetres() {
PathProfilePoint midpoint = analyzeFlatPath().profilePoints().get(MIDPOINT_INDEX);
assertEquals(EXPECTED_MIDPOINT_BULGE_M, midpoint.curvatureAdjustedElevationMeters(), 0.05,
"curvature-adjusted elevation at 10 km");
}
/**
* Line of sight: 10 m antenna height minus 5.886 m bulge leaves 4.114 m, so the
* direct path is free and the minimum clearance of the whole profile is the midpoint
* value. Tolerance: 0.05 m.
*/
@Test
void lineOfSightIsFreeWithFourMetresClearanceAtTheMidpoint() {
PathAnalysisResult result = analyzeFlatPath();
PathProfilePoint midpoint = result.profilePoints().get(MIDPOINT_INDEX);
assertEquals(ANTENNA_HEIGHT_M, midpoint.lineOfSightHeightMeters(), 1e-9, "LOS height");
assertEquals(EXPECTED_MIDPOINT_LOS_CLEARANCE_M, midpoint.lineOfSightClearanceMeters(), 0.05,
"LOS clearance at 10 km");
assertTrue(result.lineOfSightClear(), "line of sight clear");
assertEquals(EXPECTED_MIDPOINT_LOS_CLEARANCE_M, result.minimumLineOfSightClearanceMeters(), 0.05,
"minimum LOS clearance");
}
/**
* First Fresnel zone: R1 = sqrt(lambda d1 d2 / (d1 + d2)) = 101.921 m at the midpoint,
* far more than the 4.114 m of clearance, so the zone is intruded by
* 5.886 - (10 - 101.921) = 97.807 m at 10 km. Tolerance: 0.1 m.
*/
@Test
void fresnelZoneIsIntrudedByNinetyEightMetresAtTheMidpoint() {
PathAnalysisResult result = analyzeFlatPath();
PathProfilePoint midpoint = result.profilePoints().get(MIDPOINT_INDEX);
double localFresnelRadiusM = midpoint.lineOfSightHeightMeters() - midpoint.fresnelLowerHeightMeters();
assertEquals(EXPECTED_MIDPOINT_FRESNEL_RADIUS_M, localFresnelRadiusM, 0.1, "R1 at 10 km");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, midpoint.fresnelIntrusionMeters(), 0.1, "intrusion at 10 km");
assertFalse(result.fresnelClear(), "Fresnel zone intruded");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, result.worstFresnelIntrusionMeters(), 0.1, "worst intrusion");
assertEquals(10.0, result.worstFresnelDistanceKm(), 1e-9, "worst intrusion distance");
assertEquals(MIDPOINT_INDEX, result.worstFresnelSampleIndex(), "worst intrusion sample");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M / EXPECTED_MIDPOINT_FRESNEL_RADIUS_M,
result.worstFresnelIntrusionRatio(), 1e-3, "worst intrusion ratio");
}
/**
* No knife edge: the obstruction search only considers samples that rise above the
* line of sight (h greater than 0). On a flat profile with free LOS there is none, so
* there is no dominant obstruction and no knife-edge loss estimate.
*/
@Test
void noKnifeEdgeCandidateOnAFreeLineOfSight() {
PathObstructionSummary obstruction = analyzeFlatPath().obstructionSummary();
assertFalse(obstruction.hasDominantLosObstruction(), "dominant obstruction");
assertEquals(-1, obstruction.dominantObstructionSampleIndex(), "obstruction sample index");
assertTrue(Double.isNaN(obstruction.estimatedKnifeEdgeLossDb()), "knife-edge loss is NaN");
assertEquals(EXPECTED_MIDPOINT_INTRUSION_M, obstruction.worstFresnelIntrusionMeters(), 0.1,
"obstruction summary carries the worst Fresnel intrusion");
}
/**
* Pinned, not endorsed: a partial Fresnel intrusion without LOS blockage adds 0 dB to
* the link budget.
*
* <p>The link budget takes its diffraction term only from a dominant LOS obstruction.
* Here the first Fresnel zone is intruded by 96 percent of its radius yet the direct
* path is open, so the code books 0 dB. ITU-R P.526 eq. (31) would still give a loss
* for negative nu down to -0.78 (here nu = -0.057, about 5.5 dB; in general 0 .. 6 dB
* for an open but Fresnel-obstructed path). Tracked as a separate issue; this test
* records the current behaviour. Tolerance: 0.01 dB.</p>
*/
@Test
void partialFresnelIntrusionWithoutLosBlockageContributesZeroDiffractionLoss() {
PathLinkBudgetSummary budget = analyzeFlatPath().linkBudgetSummary();
assertEquals(0.0, budget.diffractionLossDb(), 0.01, "diffraction loss in the link budget");
}
}
@@ -0,0 +1,253 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Reference cases for {@link PathGeometryUtils} against ITU-R P.525 / P.526 and
* textbook values.
*
* <p>Every expected value is stated in the test method's Javadoc together with its
* source, the equation and the tolerance. Where the project deliberately deviates from
* the standard (the 32.44 constant of the free-space loss) the code's value is pinned
* and the standard's value is documented next to it. Nothing here was derived by
* running the code under test.</p>
*
* <p>Context: issue #108.</p>
*/
class PathGeometryUtilsReferenceTest {
private static final double SPEED_OF_LIGHT_M_PER_S = 299_792_458.0;
/**
* Great-circle distance and initial bearing, Veness worked example.
*
* <p>Source: Chris Veness, "Calculate distance, bearing and more between
* Latitude/Longitude points" (movable-type.co.uk/scripts/latlong.html): from
* 50 03 59 N, 005 42 53 W to 58 38 38 N, 003 04 12 W the haversine distance is
* 968.9 km on R = 6371 km and the initial bearing is 9.12 degrees. Recomputed on the
* project's R = 6371.009 km the distance is 968.855 km. Tolerance: 0.1 km, 0.01 deg.</p>
*/
@Test
void greatCircleDistanceAndBearingMatchTheVenessWorkedExample() {
double fromLat = 50.0 + 3.0 / 60.0 + 59.0 / 3600.0;
double fromLon = -(5.0 + 42.0 / 60.0 + 53.0 / 3600.0);
double toLat = 58.0 + 38.0 / 60.0 + 38.0 / 3600.0;
double toLon = -(3.0 + 4.0 / 60.0 + 12.0 / 3600.0);
assertEquals(968.855,
PathGeometryUtils.calculateGreatCircleDistanceKm(fromLat, fromLon, toLat, toLon),
0.1, "Veness distance on R = 6371.009 km");
assertEquals(9.1198,
PathGeometryUtils.calculateInitialBearingDeg(fromLat, fromLon, toLat, toLon),
0.01, "Veness initial bearing");
}
/**
* First Fresnel zone radius, ITU-R P.526-15 section 2.1 eq. (2).
*
* <p>R_n = sqrt(n * lambda * d1 * d2 / (d1 + d2)) with n = 1 and lambda = c / f,
* c = 299 792 458 m/s. For d1 = d2 = 50 km at 144.3 MHz this is 227.9015 m, for
* d1 = 10 km, d2 = 40 km at 432.2 MHz it is 74.4926 m. Tolerance: 1e-3 m against the
* literal, relative 1e-6 against the equation evaluated in the test.</p>
*/
@Test
void firstFresnelRadiusMatchesP526Equation2() {
double symmetric = PathGeometryUtils.calculateFirstFresnelRadiusMeters(50.0, 100.0, 144.3);
double asymmetric = PathGeometryUtils.calculateFirstFresnelRadiusMeters(10.0, 50.0, 432.2);
assertEquals(227.9015, symmetric, 1e-3, "R1 for d1 = d2 = 50 km at 144.3 MHz");
assertEquals(74.4926, asymmetric, 1e-3, "R1 for d1 = 10 km, d2 = 40 km at 432.2 MHz");
double expectedSymmetric = fresnelRadiusEq2(50_000.0, 50_000.0, 144.3);
double expectedAsymmetric = fresnelRadiusEq2(10_000.0, 40_000.0, 432.2);
assertEquals(expectedSymmetric, symmetric, expectedSymmetric * 1e-6, "R1 vs eq. (2), symmetric");
assertEquals(expectedAsymmetric, asymmetric, expectedAsymmetric * 1e-6, "R1 vs eq. (2), asymmetric");
}
/**
* First Fresnel zone radius against the practical form, ITU-R P.526-15 eq. (3).
*
* <p>R_1 = 550 * sqrt(d1 * d2 / ((d1 + d2) * f)) with d in km and f in MHz. The
* constant 550 carries the rounding of sqrt(c) in these units, so the two forms
* differ by about 0.45 percent (228.928 m vs 227.902 m, 74.828 m vs 74.493 m).
* Tolerance: 0.5 percent.</p>
*/
@Test
void firstFresnelRadiusAgreesWithP526Equation3WithinHalfAPercent() {
assertRelative(fresnelRadiusEq3(50.0, 50.0, 144.3),
PathGeometryUtils.calculateFirstFresnelRadiusMeters(50.0, 100.0, 144.3),
0.005, "R1 vs eq. (3), symmetric");
assertRelative(fresnelRadiusEq3(10.0, 40.0, 432.2),
PathGeometryUtils.calculateFirstFresnelRadiusMeters(10.0, 50.0, 432.2),
0.005, "R1 vs eq. (3), asymmetric");
}
/**
* Recommended clearance is 60 percent of the first Fresnel radius.
*
* <p>Source: the usual planning rule (ITU-R P.530, "0.6 F1 clearance"). Expected
* 0.6 * 227.9015 m = 136.7409 m. Tolerance: 1e-3 m.</p>
*/
@Test
void recommendedClearanceIsSixtyPercentOfTheFirstFresnelRadius() {
assertEquals(136.7409,
PathGeometryUtils.calculateRecommendedFresnelClearanceMeters(50.0, 100.0, 144.3),
1e-3, "0.6 * R1 for d1 = d2 = 50 km at 144.3 MHz");
}
/**
* Earth bulge below the straight line between the endpoints.
*
* <p>h = d1 * d2 / (2 * k * R) with R = 6 371 009 m. For d1 = d2 = 50 km: 147.1509 m
* at k = 4/3 and 196.2013 m at k = 1. Tolerance: 1e-3 m. The k = 4/3 value is also
* checked against the effective Earth radius of 8500 km that ITU-R P.834 and the IEV
* quote for the standard atmosphere (147.0588 m), within 0.1 percent.</p>
*/
@Test
void earthBulgeMatchesTheGeometricFormulaAndThe8500KmEffectiveRadius() {
double bulgeK43 = PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0, 4.0 / 3.0);
double bulgeK1 = PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0, 1.0);
assertEquals(147.1509, bulgeK43, 1e-3, "bulge at k = 4/3");
assertEquals(196.2013, bulgeK1, 1e-3, "bulge at k = 1");
assertEquals(bulgeK43, PathGeometryUtils.calculateEarthBulgeMeters(50.0, 100.0), 1e-9,
"two-argument overload uses k = 4/3");
double bulge8500Km = 50_000.0 * 50_000.0 / (2.0 * 8_500_000.0);
assertRelative(bulge8500Km, bulgeK43, 0.001, "bulge vs 8500 km effective radius");
}
/**
* Radio horizon distance for an antenna height of 100 m.
*
* <p>d = sqrt(2 * k * R * h) with R = 6 371 009 m: 41.2181 km at k = 4/3 and
* 35.6960 km at k = 1. Tolerance: 1e-3 km. The textbook approximations
* d = 4.12 * sqrt(h) (radio, k = 4/3) and d = 3.57 * sqrt(h) (optical, k = 1), d in
* km and h in m, are met within 0.1 percent.</p>
*/
@Test
void radioHorizonMatchesSqrtTwoKRHAndTheTextbookApproximations() {
double horizonK43 = PathGeometryUtils.calculateRadioHorizonDistanceKm(100.0, 4.0 / 3.0);
double horizonK1 = PathGeometryUtils.calculateRadioHorizonDistanceKm(100.0, 1.0);
assertEquals(41.2181, horizonK43, 1e-3, "horizon at k = 4/3");
assertEquals(35.6960, horizonK1, 1e-3, "horizon at k = 1");
assertRelative(4.12 * Math.sqrt(100.0), horizonK43, 0.001, "horizon vs 4.12 sqrt(h)");
assertRelative(3.57 * Math.sqrt(100.0), horizonK1, 0.001, "horizon vs 3.57 sqrt(h)");
}
/**
* Single knife-edge diffraction loss, ITU-R P.526-15 eq. (31).
*
* <p>J(nu) = 6.9 + 20 log10(sqrt((nu - 0.1)^2 + 1) + nu - 0.1) for nu greater than
* -0.78, otherwise 0. Expected: J(0) = 6.033, J(1) = 13.926, J(2.4) = 20.539,
* J(-0.5) = 1.959, J(-1) = 0. Tolerance: 0.01 dB.</p>
*
* <p>At exactly nu = -0.78 the formula gives 0.004 dB; the code compares with
* {@code v <= -0.78} and answers 0 there. That is pinned here, not endorsed: the step
* is 0.004 dB and of no practical consequence, but the boundary belongs to the
* zero branch in this implementation.</p>
*/
@Test
void knifeEdgeLossMatchesP526Equation31() {
assertEquals(6.033, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(0.0), 0.01, "J(0)");
assertEquals(13.926, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(1.0), 0.01, "J(1)");
assertEquals(20.539, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(2.4), 0.01, "J(2.4)");
assertEquals(1.959, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-0.5), 0.01, "J(-0.5)");
assertEquals(0.0, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-1.0), 0.0, "J(-1)");
// Pinned: the boundary nu = -0.78 falls into the zero branch (v <= -0.78).
assertEquals(0.0, PathGeometryUtils.calculateSingleKnifeEdgeLossDb(-0.78), 0.0, "J(-0.78)");
}
/**
* Fresnel-Kirchhoff diffraction parameter, ITU-R P.526-15 eq. (26) in SI units.
*
* <p>nu = h * sqrt(2 * (d1 + d2) / (lambda * d1 * d2)). For h = 50 m, d1 = d2 = 25 km
* at 144.3 MHz (lambda = 2.0776 m): nu = 0.4388. Tolerance: 1e-4.</p>
*/
@Test
void knifeEdgeVParameterMatchesP526Equation26() {
assertEquals(0.4388,
PathGeometryUtils.calculateKnifeEdgeVParameter(50.0, 25.0, 25.0, 144.3),
1e-4, "nu for h = 50 m, d1 = d2 = 25 km, 144.3 MHz");
}
/**
* Free-space basic transmission loss for 100 km at 144.3 MHz.
*
* <p>The code uses L = 32.44 + 20 log10(d/km) + 20 log10(f/MHz), which gives
* 115.625 dB; that value is pinned (tolerance 0.001 dB) as decided in #108.</p>
*
* <p>For the record: ITU-R P.525-5 eq. (6) writes the constant as 32.4 (115.585 dB
* here), and the exact constant 20 log10(4 pi * 1e9 / c) = 32.4478 gives 115.633 dB.
* The project keeps 32.44; the spread of 0.05 dB is far below the uncertainty of
* the rest of the link budget.</p>
*/
@Test
void freeSpacePathLossUsesTheProjectConstant32Point44() {
double lossDb = PathGeometryUtils.calculateFreeSpacePathLossDb(100.0, 144.3);
assertEquals(115.625, lossDb, 0.001, "FSPL 100 km / 144.3 MHz with 32.44");
// Documented, not asserted as the project's answer: the standard's variants.
double p525Variant = 32.4 + 20.0 * Math.log10(100.0) + 20.0 * Math.log10(144.3);
double exactConstant = 20.0 * Math.log10(4.0 * Math.PI * 1e9 / SPEED_OF_LIGHT_M_PER_S);
double exactVariant = exactConstant + 20.0 * Math.log10(100.0) + 20.0 * Math.log10(144.3);
assertEquals(115.585, p525Variant, 0.001, "P.525-5 eq. (6) with 32.4");
assertEquals(115.633, exactVariant, 0.001, "exact constant 32.4478");
}
/**
* Watts to dBm: P_dBm = 10 log10(P_W * 1000).
*
* <p>750 W = 58.751 dBm, 100 W = 50.000 dBm. Tolerance: 1e-3 dB.</p>
*/
@Test
void wattsToDbmMatchesTheDefinition() {
assertEquals(58.751, PathGeometryUtils.wattsToDbm(750.0), 1e-3, "750 W");
assertEquals(50.0, PathGeometryUtils.wattsToDbm(100.0), 1e-3, "100 W");
}
/**
* Adaptive sample count: ceil(d / 0.5 km) + 1, clamped to 121 .. 1201.
*
* <p>0 km (non-positive) yields the minimum 121, 50 km computes 101 and is clamped
* to 121, 100 km gives 201, 1000 km computes 2001 and is clamped to 1201.</p>
*/
@Test
void adaptiveSampleCountFollowsTheHalfKilometreRuleWithClamping() {
assertEquals(121, PathGeometryUtils.resolveAdaptiveSampleCount(0.0), "0 km");
assertEquals(121, PathGeometryUtils.resolveAdaptiveSampleCount(50.0), "50 km");
assertEquals(201, PathGeometryUtils.resolveAdaptiveSampleCount(100.0), "100 km");
assertEquals(1201, PathGeometryUtils.resolveAdaptiveSampleCount(1000.0), "1000 km");
}
/**
* Effective Earth radius factor sanitising: values outside 0.5 .. 10 fall back to 4/3.
*
* <p>0.4 and 11 are replaced by 4/3, 1.0 is kept. Tolerance: 1e-12.</p>
*/
@Test
void effectiveEarthRadiusFactorOutsideTheAcceptedRangeFallsBackToFourThirds() {
assertEquals(4.0 / 3.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(0.4), 1e-12, "0.4");
assertEquals(4.0 / 3.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(11.0), 1e-12, "11");
assertEquals(1.0, PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(1.0), 1e-12, "1.0");
}
/** ITU-R P.526-15 eq. (2) with n = 1, distances in metres. */
private static double fresnelRadiusEq2(double d1Meters, double d2Meters, double frequencyMHz) {
double wavelengthMeters = SPEED_OF_LIGHT_M_PER_S / (frequencyMHz * 1e6);
return Math.sqrt(wavelengthMeters * d1Meters * d2Meters / (d1Meters + d2Meters));
}
/** ITU-R P.526-15 eq. (3): distances in km, frequency in MHz, result in metres. */
private static double fresnelRadiusEq3(double d1Km, double d2Km, double frequencyMHz) {
return 550.0 * Math.sqrt(d1Km * d2Km / ((d1Km + d2Km) * frequencyMHz));
}
private static void assertRelative(double expected, double actual, double relativeTolerance, String message) {
assertEquals(expected, actual, Math.abs(expected) * relativeTolerance, message);
}
}