mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-19 22:02:57 +02:00
Path analysis: own ground height ASL and highest point of the remote locator square (#133)
* Path analysis: own ground height ASL and highest point of the remote square Two new settings in the path-analysis section of the Station tab: - Own ground height ASL (stn_pathAnalysisOwnHeightAslMeters). Empty keeps the elevation model value at the own locator centre; a value from -500 to 9000 m replaces the elevation of the first profile sample. The antenna height AGL is still added on top. The field commits on Enter or focus loss into the settings draft and reports a refused entry; it keeps the operator's spelling while that names the drafted number, because the draft is Compose state and would otherwise swap "25" for "25.0" under the cursor right after Enter. - Target station on the highest point of its locator square (stn_pathAnalysisDxHighestPointInSquare, on by default). The remote square is sampled on a 5 x 5 grid of cell centres in one batched request against the Open-Meteo provider, the highest sample becomes the path end with its position and elevation. LocatorHighestPointResolver caches the result per locator for the session and backs off five minutes after a failure (failure list pruned and bounded); the analysis then falls back to the square centre and that result is not kept. Both settings are drafted like the other path-analysis settings and take effect with Apply/Close or Save; the reachability service reads the stored values only. The lookup runs on the reachability thread only. Building a request peeks the resolver cache without blocking, so a cached square moves the endpoint right away. The calculation key now includes the own height and the remote ground elevation at full precision, and the switch, so changing either recalculates. Also assign the result of sanitizeEffectiveEarthRadiusFactor in the PathAnalysisRequest constructor; it was called and dropped. No behaviour change: the sanitizer is the identity for valid factors, and the analysis service sanitizes again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document own ground height and highest-point setting of the path analysis Changelog v1.50 (New), configuration and features pages in English and German, and the terrain section of PROJECT_CONTEXT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee4f1fe02e
commit
92c2576a81
29 files changed
+1789
-23
No files matched your search
@@ -13,7 +13,9 @@ import java.util.function.Consumer;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.view.map.GeometryOnlyPathAnalysisService;
|
||||
import kst4contest.view.map.LocatorHighestPointResolver;
|
||||
|
||||
import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
|
||||
import kst4contest.view.map.PathAnalysisRequest;
|
||||
@@ -27,7 +29,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Central service for tropo/path reachability calculations.
|
||||
@@ -50,6 +52,7 @@ public final class ReachabilityService {
|
||||
|
||||
private final ChatController chatController;
|
||||
private final PathAnalysisService pathAnalysisService;
|
||||
private final LocatorHighestPointResolver highestPointResolver;
|
||||
private final ExecutorService executor;
|
||||
|
||||
/**
|
||||
@@ -80,7 +83,10 @@ public final class ReachabilityService {
|
||||
|
||||
public ReachabilityService(ChatController chatController) {
|
||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
|
||||
OpenMeteoTerrainProfileProvider terrainProvider = new OpenMeteoTerrainProfileProvider();
|
||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(terrainProvider);
|
||||
// One provider for both, so profile and grid lookups share its rate limiter.
|
||||
this.highestPointResolver = new LocatorHighestPointResolver(terrainProvider);
|
||||
this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
|
||||
}
|
||||
|
||||
@@ -118,7 +124,8 @@ public final class ReachabilityService {
|
||||
return;
|
||||
}
|
||||
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(
|
||||
buildCalculationKey(request, isHighestPointInSquareEnabled()));
|
||||
if (cachedResult != null) {
|
||||
storePathAnalysisResultInChatMembers(member, band, cachedResult);
|
||||
}
|
||||
@@ -316,7 +323,8 @@ public final class ReachabilityService {
|
||||
return;
|
||||
}
|
||||
|
||||
String calculationKey = buildCalculationKey(request);
|
||||
boolean highestPointInSquare = isHighestPointInSquareEnabled();
|
||||
String calculationKey = buildCalculationKey(request, highestPointInSquare);
|
||||
|
||||
PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
|
||||
if (cachedResult != null) {
|
||||
@@ -342,9 +350,30 @@ public final class ReachabilityService {
|
||||
|
||||
executor.submit(() -> {
|
||||
PathAnalysisResult result;
|
||||
PathAnalysisRequest effectiveRequest = request;
|
||||
boolean fellBackToSquareCentre = false;
|
||||
|
||||
try {
|
||||
result = pathAnalysisService.analyze(request);
|
||||
/*
|
||||
* The highest point of the remote square may need the elevation service,
|
||||
* so it is resolved here on the reachability thread and never while the
|
||||
* request is built on the UI thread.
|
||||
*/
|
||||
if (highestPointInSquare && needsHighestPointLookup(request)) {
|
||||
LocatorHighestPointResolver.HighestPoint highestPoint =
|
||||
highestPointResolver.resolve(request.toLocator6());
|
||||
if (highestPoint != null) {
|
||||
effectiveRequest = request.withTargetEndpoint(
|
||||
highestPoint.latitudeDeg(),
|
||||
highestPoint.longitudeDeg(),
|
||||
highestPoint.elevationMeters()
|
||||
);
|
||||
} else {
|
||||
fellBackToSquareCentre = true;
|
||||
}
|
||||
}
|
||||
|
||||
result = pathAnalysisService.analyze(effectiveRequest);
|
||||
if (result == null) {
|
||||
result = createNoProfileResult(request, "Path analysis returned no result.");
|
||||
}
|
||||
@@ -357,8 +386,22 @@ public final class ReachabilityService {
|
||||
|
||||
boolean usableBudget = hasUsableLinkBudget(result);
|
||||
|
||||
if (usableBudget) {
|
||||
if (usableBudget && fellBackToSquareCentre) {
|
||||
/*
|
||||
* A centre fallback answers this request but is not what the setting asks
|
||||
* for, so it is not kept: the next request tries the square again once the
|
||||
* resolver's own retry delay has passed. The terrain profile itself stays
|
||||
* cached in the provider, so this costs no repeated profile download.
|
||||
*/
|
||||
failedCalculationRetryAfterEpochMs.remove(calculationKey);
|
||||
} else if (usableBudget) {
|
||||
pathAnalysisResultCache.put(calculationKey, result);
|
||||
/*
|
||||
* Also file it under the key of the resolved request: once the square is
|
||||
* cached, buildRequest places the endpoint there directly, and that request
|
||||
* then finds this result instead of computing it again.
|
||||
*/
|
||||
pathAnalysisResultCache.put(buildCalculationKey(effectiveRequest, highestPointInSquare), result);
|
||||
failedCalculationRetryAfterEpochMs.remove(calculationKey);
|
||||
} else {
|
||||
failedCalculationRetryAfterEpochMs.put(
|
||||
@@ -422,9 +465,42 @@ public final class ReachabilityService {
|
||||
double targetLongitudeDeg,
|
||||
double analysisFrequencyMHz) {
|
||||
|
||||
return buildRequest(
|
||||
chatController.getChatPreferences(),
|
||||
ownLocator6,
|
||||
targetCallsignRaw,
|
||||
targetLocator6,
|
||||
targetLatitudeDeg,
|
||||
targetLongitudeDeg,
|
||||
analysisFrequencyMHz,
|
||||
highestPointResolver::peekCached
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a path-analysis request from the preferences.
|
||||
*
|
||||
* <p>The own ground elevation override comes from the preferences as entered. When
|
||||
* the highest point of the remote square is enabled and already known, the remote
|
||||
* endpoint is placed there; when it is not known yet, the request keeps the given
|
||||
* position and the background job resolves it. Looking it up here would block the
|
||||
* caller, which may be the UI thread.</p>
|
||||
*
|
||||
* @param preferences preferences to read the station values from
|
||||
* @param cachedHighestPoint non-blocking lookup of an already resolved square
|
||||
*/
|
||||
static PathAnalysisRequest buildRequest(ChatPreferences preferences,
|
||||
String ownLocator6,
|
||||
String targetCallsignRaw,
|
||||
String targetLocator6,
|
||||
double targetLatitudeDeg,
|
||||
double targetLongitudeDeg,
|
||||
double analysisFrequencyMHz,
|
||||
Function<String, LocatorHighestPointResolver.HighestPoint> cachedHighestPoint) {
|
||||
|
||||
Location homeLocation = new Location(ownLocator6);
|
||||
|
||||
return new PathAnalysisRequest(
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
ownLocator6,
|
||||
homeLocation.getLatitude().toDegrees(),
|
||||
homeLocation.getLongitude().toDegrees(),
|
||||
@@ -433,11 +509,39 @@ public final class ReachabilityService {
|
||||
targetLatitudeDeg,
|
||||
targetLongitudeDeg,
|
||||
analysisFrequencyMHz,
|
||||
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
|
||||
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
||||
preferences.getStn_pathAnalysisOwnAntennaHeightMeters(),
|
||||
preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
chatController.getChatPreferences().buildPathLinkBudgetSettings()
|
||||
preferences.buildPathLinkBudgetSettings(),
|
||||
preferences.getStn_pathAnalysisOwnHeightAslMeters(),
|
||||
Double.NaN
|
||||
);
|
||||
|
||||
if (preferences.isStn_pathAnalysisDxHighestPointInSquare() && cachedHighestPoint != null) {
|
||||
LocatorHighestPointResolver.HighestPoint highestPoint = cachedHighestPoint.apply(targetLocator6);
|
||||
if (highestPoint != null) {
|
||||
request = request.withTargetEndpoint(
|
||||
highestPoint.latitudeDeg(),
|
||||
highestPoint.longitudeDeg(),
|
||||
highestPoint.elevationMeters()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the background job still has to look up the highest point of the remote
|
||||
* square. A request that already carries the remote ground elevation was placed on
|
||||
* the cached point by {@link #buildRequest}.
|
||||
*/
|
||||
static boolean needsHighestPointLookup(PathAnalysisRequest request) {
|
||||
return !Double.isFinite(request.targetGroundElevationOverrideMeters());
|
||||
}
|
||||
|
||||
private boolean isHighestPointInSquareEnabled() {
|
||||
return chatController.getChatPreferences().isStn_pathAnalysisDxHighestPointInSquare();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -626,10 +730,18 @@ public final class ReachabilityService {
|
||||
/**
|
||||
* Builds a stable calculation key.
|
||||
*
|
||||
* <p>The key includes station, locators, frequency, antenna heights and link
|
||||
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
|
||||
* <p>The key includes station, locators, remote position, frequency, antenna heights,
|
||||
* link budget settings, the own ground elevation override, the remote ground
|
||||
* elevation and whether the highest point of the remote square is used. If any
|
||||
* relevant input changes, a new calculation is allowed. The remote position is the
|
||||
* one in the request: the square centre while the highest point is still unresolved,
|
||||
* the highest point itself once it is cached. The switch is part of the key because
|
||||
* before the lookup both settings produce the same centre request.</p>
|
||||
*
|
||||
* @param request the request as built
|
||||
* @param highestPointInSquare whether the remote endpoint is the highest point of its square
|
||||
*/
|
||||
private String buildCalculationKey(PathAnalysisRequest request) {
|
||||
static String buildCalculationKey(PathAnalysisRequest request, boolean highestPointInSquare) {
|
||||
return normalizeLocator6(request.fromLocator6())
|
||||
+ "|"
|
||||
+ normalizeCallsignRaw(request.toCallsignRaw())
|
||||
@@ -646,14 +758,28 @@ public final class ReachabilityService {
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ request.linkBudgetSettings();
|
||||
+ request.linkBudgetSettings()
|
||||
+ "|homeAsl="
|
||||
+ formatOptionalMeters(request.homeGroundElevationOverrideMeters())
|
||||
+ "|targetAsl="
|
||||
+ formatOptionalMeters(request.targetGroundElevationOverrideMeters())
|
||||
+ "|highestPoint="
|
||||
+ highestPointInSquare;
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
/*
|
||||
* Full precision on purpose: rounded to one decimal, 123.04 m and 123.00 m would share
|
||||
* a key and the second would be answered with the first one's result.
|
||||
*/
|
||||
private static String formatOptionalMeters(double meters) {
|
||||
return Double.isFinite(meters) ? Double.toString(meters) : "model";
|
||||
}
|
||||
|
||||
private static String normalizeCallsignRaw(String callSignRaw) {
|
||||
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
private static String normalizeLocator6(String locator) {
|
||||
return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,17 @@ public class ChatPreferences {
|
||||
|
||||
double stn_pathAnalysisOwnAntennaHeightMeters = 10.0;
|
||||
double stn_pathAnalysisDefaultTargetAntennaHeightMeters = 10.0;
|
||||
/**
|
||||
* Ground elevation of the own site above mean sea level, entered by the operator.
|
||||
* NaN (the default, and an empty field) leaves it to the elevation model at the
|
||||
* locator centre.
|
||||
*/
|
||||
double stn_pathAnalysisOwnHeightAslMeters = Double.NaN;
|
||||
/**
|
||||
* Whether the path analysis ends at the highest sampled point of the remote station's
|
||||
* locator square instead of at the square centre.
|
||||
*/
|
||||
boolean stn_pathAnalysisDxHighestPointInSquare = true;
|
||||
String stn_pathAnalysisDemRootDirectory = "";
|
||||
String stn_pathAnalysisDemDatasetId = "copernicus_glo_30";
|
||||
double stn_pathAnalysisOwnTxPowerWatts = 750.0;
|
||||
@@ -495,6 +506,48 @@ public class ChatPreferences {
|
||||
this.stn_pathAnalysisOwnAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisOwnAntennaHeightMeters);
|
||||
}
|
||||
|
||||
/** Lowest own ground elevation accepted, metres: below the Dead Sea shore. */
|
||||
public static final double PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS = -500.0;
|
||||
/** Highest own ground elevation accepted, metres: above the highest summit. */
|
||||
public static final double PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS = 9000.0;
|
||||
|
||||
/**
|
||||
* @return own ground elevation above mean sea level in metres, or NaN when the
|
||||
* elevation model decides
|
||||
*/
|
||||
public double getStn_pathAnalysisOwnHeightAslMeters() {
|
||||
return stn_pathAnalysisOwnHeightAslMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the own ground elevation above mean sea level. NaN, or a value outside
|
||||
* {@link #PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS} ..
|
||||
* {@link #PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS}, clears it, so a broken value in
|
||||
* the XML file falls back to the elevation model instead of distorting every path.
|
||||
*/
|
||||
public void setStn_pathAnalysisOwnHeightAslMeters(double stn_pathAnalysisOwnHeightAslMeters) {
|
||||
this.stn_pathAnalysisOwnHeightAslMeters = isValidOwnHeightAslMeters(stn_pathAnalysisOwnHeightAslMeters)
|
||||
? stn_pathAnalysisOwnHeightAslMeters
|
||||
: Double.NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the value is a usable own ground elevation (finite and in range)
|
||||
*/
|
||||
public static boolean isValidOwnHeightAslMeters(double meters) {
|
||||
return Double.isFinite(meters)
|
||||
&& meters >= PATH_ANALYSIS_OWN_HEIGHT_ASL_MIN_METERS
|
||||
&& meters <= PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS;
|
||||
}
|
||||
|
||||
public boolean isStn_pathAnalysisDxHighestPointInSquare() {
|
||||
return stn_pathAnalysisDxHighestPointInSquare;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDxHighestPointInSquare(boolean stn_pathAnalysisDxHighestPointInSquare) {
|
||||
this.stn_pathAnalysisDxHighestPointInSquare = stn_pathAnalysisDxHighestPointInSquare;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetAntennaHeightMeters() {
|
||||
return stn_pathAnalysisDefaultTargetAntennaHeightMeters;
|
||||
}
|
||||
@@ -1621,6 +1674,17 @@ public class ChatPreferences {
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaHeightMeters + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetAntennaHeightMeters);
|
||||
|
||||
// Empty when unset: reading an empty element keeps the NaN default.
|
||||
Element stn_pathAnalysisOwnHeightAslMeters = doc.createElement("stn_pathAnalysisOwnHeightAslMeters");
|
||||
stn_pathAnalysisOwnHeightAslMeters.setTextContent(Double.isFinite(this.stn_pathAnalysisOwnHeightAslMeters)
|
||||
? this.stn_pathAnalysisOwnHeightAslMeters + ""
|
||||
: "");
|
||||
station.appendChild(stn_pathAnalysisOwnHeightAslMeters);
|
||||
|
||||
Element stn_pathAnalysisDxHighestPointInSquare = doc.createElement("stn_pathAnalysisDxHighestPointInSquare");
|
||||
stn_pathAnalysisDxHighestPointInSquare.setTextContent(this.stn_pathAnalysisDxHighestPointInSquare + "");
|
||||
station.appendChild(stn_pathAnalysisDxHighestPointInSquare);
|
||||
|
||||
Element stn_pathAnalysisDemRootDirectory = doc.createElement("stn_pathAnalysisDemRootDirectory");
|
||||
stn_pathAnalysisDemRootDirectory.setTextContent(this.stn_pathAnalysisDemRootDirectory);
|
||||
station.appendChild(stn_pathAnalysisDemRootDirectory);
|
||||
@@ -2467,6 +2531,18 @@ public class ChatPreferences {
|
||||
"stn_pathAnalysisDefaultTargetAntennaHeightMeters"
|
||||
);
|
||||
|
||||
setStn_pathAnalysisOwnHeightAslMeters(getDouble(
|
||||
stationEl,
|
||||
Double.NaN,
|
||||
"stn_pathAnalysisOwnHeightAslMeters"
|
||||
));
|
||||
|
||||
stn_pathAnalysisDxHighestPointInSquare = getBooleanOrDefault(
|
||||
stationEl,
|
||||
stn_pathAnalysisDxHighestPointInSquare,
|
||||
"stn_pathAnalysisDxHighestPointInSquare"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDemRootDirectory = getText(
|
||||
stationEl,
|
||||
stn_pathAnalysisDemRootDirectory,
|
||||
|
||||
@@ -114,8 +114,14 @@ public final class GeometryOnlyPathAnalysisService implements PathAnalysisServic
|
||||
);
|
||||
}
|
||||
|
||||
List<PathProfilePoint> enrichedProfilePoints = buildEnrichedProfilePoints(
|
||||
List<PathProfilePoint> rawProfilePoints = applyGroundElevationOverrides(
|
||||
terrainProfileData.profilePoints(),
|
||||
request.homeGroundElevationOverrideMeters(),
|
||||
request.targetGroundElevationOverrideMeters()
|
||||
);
|
||||
|
||||
List<PathProfilePoint> enrichedProfilePoints = buildEnrichedProfilePoints(
|
||||
rawProfilePoints,
|
||||
distanceKm,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
@@ -203,6 +209,52 @@ public final class GeometryOnlyPathAnalysisService implements PathAnalysisServic
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the terrain elevation of the first and/or last raw sample by a known
|
||||
* ground elevation.
|
||||
*
|
||||
* <p>The endpoints are where the antennas stand, so their elevation decides the
|
||||
* height of the line of sight. An operator who knows the elevation of the own site
|
||||
* better than the elevation model does at the locator centre enters it, and the
|
||||
* highest point of the remote square is known from its own lookup. Replacing the raw
|
||||
* sample, rather than patching the antenna height later, keeps the chart, the horizon
|
||||
* search and the clearance figures on one consistent profile.</p>
|
||||
*
|
||||
* @param rawProfilePoints raw samples from the provider, at least two
|
||||
* @param homeGroundElevationMeters own ground elevation, or {@code NaN} to keep the model value
|
||||
* @param targetGroundElevationMeters remote ground elevation, or {@code NaN} to keep the model value
|
||||
* @return the samples with the overridden endpoints
|
||||
*/
|
||||
static List<PathProfilePoint> applyGroundElevationOverrides(List<PathProfilePoint> rawProfilePoints,
|
||||
double homeGroundElevationMeters,
|
||||
double targetGroundElevationMeters) {
|
||||
boolean overrideHome = Double.isFinite(homeGroundElevationMeters);
|
||||
boolean overrideTarget = Double.isFinite(targetGroundElevationMeters);
|
||||
|
||||
if ((!overrideHome && !overrideTarget) || rawProfilePoints.size() < 2) {
|
||||
return rawProfilePoints;
|
||||
}
|
||||
|
||||
List<PathProfilePoint> points = new ArrayList<>(rawProfilePoints);
|
||||
if (overrideHome) {
|
||||
points.set(0, withElevation(points.get(0), homeGroundElevationMeters));
|
||||
}
|
||||
if (overrideTarget) {
|
||||
int lastIndex = points.size() - 1;
|
||||
points.set(lastIndex, withElevation(points.get(lastIndex), targetGroundElevationMeters));
|
||||
}
|
||||
return List.copyOf(points);
|
||||
}
|
||||
|
||||
private static PathProfilePoint withElevation(PathProfilePoint point, double elevationMeters) {
|
||||
return new PathProfilePoint(
|
||||
point.distanceKm(),
|
||||
point.latitudeDeg(),
|
||||
point.longitudeDeg(),
|
||||
elevationMeters
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts raw terrain profile samples into enriched samples that already contain:
|
||||
* <ul>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Finds the highest terrain point inside a six-character Maidenhead locator square.
|
||||
*
|
||||
* <p>A locator only says which square a station is in, not where in it. VHF/UHF contest
|
||||
* stations tend to operate from the high ground of their square rather than from its
|
||||
* geometric centre, so the path analysis can use the highest sampled point of the square
|
||||
* as the remote endpoint instead.</p>
|
||||
*
|
||||
* <p><b>Grid.</b> A six-character square spans 5' of longitude and 2.5' of latitude,
|
||||
* roughly 5.8 km x 4.6 km at 50 degrees north. It is sampled with a
|
||||
* {@value #GRID_SIZE} x {@value #GRID_SIZE} grid of cell centres, so the samples are
|
||||
* about 1.2 km x 0.9 km apart there and never sit on the border shared with the
|
||||
* neighbouring square. An odd grid size puts one sample exactly on the square centre,
|
||||
* which means the chosen point is never lower than the centre the analysis used before.
|
||||
* The 25 points fit into a single request of the online elevation service (limit 100
|
||||
* coordinates). The grid is a coarse search, not a summit finder: a broad hill usually
|
||||
* gets a sample reasonably close to its top, but a narrow ridge or peak between two
|
||||
* samples can be missed, and the chosen point is then only the highest of the samples.
|
||||
* A denser grid would not cost more requests, but would only refine a position that the
|
||||
* locator itself does not pin down anyway.</p>
|
||||
*
|
||||
* <p><b>Ties</b> are broken towards the square centre, so a flat square resolves to its
|
||||
* centre and the result is deterministic.</p>
|
||||
*
|
||||
* <p><b>Caching.</b> A resolved square is pure geometry and is kept in memory for the
|
||||
* running session (least recently used, at most {@value #MAX_CACHED_SQUARES} squares).
|
||||
* A failed lookup is remembered as well and not retried for
|
||||
* {@link #FAILURE_RETRY_DELAY_MS} milliseconds, so an unreachable service is not asked
|
||||
* again for every station of the same square. The caller falls back to the square centre
|
||||
* in the meantime. Expired failures are dropped whenever a failure is recorded, and the
|
||||
* failure list is bounded like the result cache.</p>
|
||||
*
|
||||
* <p>{@link #resolve(String)} may block on the network and must run on a background
|
||||
* thread; {@link #peekCached(String)} never blocks on it and is safe anywhere.</p>
|
||||
*/
|
||||
public final class LocatorHighestPointResolver {
|
||||
|
||||
/** Samples per axis; see the class comment for why five. */
|
||||
public static final int GRID_SIZE = 5;
|
||||
|
||||
static final int MAX_CACHED_SQUARES = 4096;
|
||||
|
||||
/** Same delay the reachability service uses before it retries a failed analysis. */
|
||||
static final long FAILURE_RETRY_DELAY_MS = 5L * 60L * 1000L;
|
||||
|
||||
private static final double SQUARE_HEIGHT_DEG = 2.5 / 60.0;
|
||||
private static final double SQUARE_WIDTH_DEG = 5.0 / 60.0;
|
||||
|
||||
private static final Pattern LOCATOR6 = Pattern.compile("[A-R]{2}[0-9]{2}[A-X]{2}");
|
||||
|
||||
private final TerrainElevationProvider elevationProvider;
|
||||
private final LongSupplier clock;
|
||||
|
||||
private final Map<String, HighestPoint> resolvedSquares =
|
||||
new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, HighestPoint> eldest) {
|
||||
return size() > MAX_CACHED_SQUARES;
|
||||
}
|
||||
};
|
||||
|
||||
private final Map<String, Long> retryAfterEpochMsBySquare =
|
||||
new LinkedHashMap<>(16, 0.75f, false) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
|
||||
return size() > MAX_CACHED_SQUARES;
|
||||
}
|
||||
};
|
||||
|
||||
public LocatorHighestPointResolver(TerrainElevationProvider elevationProvider) {
|
||||
this(elevationProvider, System::currentTimeMillis);
|
||||
}
|
||||
|
||||
LocatorHighestPointResolver(TerrainElevationProvider elevationProvider, LongSupplier clock) {
|
||||
this.elevationProvider = Objects.requireNonNull(elevationProvider, "elevationProvider");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest sampled point of one locator square.
|
||||
*
|
||||
* @param locator6 the square, upper case
|
||||
* @param latitudeDeg latitude of the chosen sample
|
||||
* @param longitudeDeg longitude of the chosen sample
|
||||
* @param elevationMeters terrain elevation there, metres above mean sea level
|
||||
*/
|
||||
public record HighestPoint(String locator6, double latitudeDeg, double longitudeDeg, double elevationMeters) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached highest point of a square without touching the elevation service.
|
||||
*
|
||||
* @param locator6 six-character locator
|
||||
* @return the cached point, or {@code null} when the square has not been resolved yet,
|
||||
* its lookup failed, or the locator is not a valid six-character locator
|
||||
*/
|
||||
public HighestPoint peekCached(String locator6) {
|
||||
String square = normalize(locator6);
|
||||
if (square == null) {
|
||||
return null;
|
||||
}
|
||||
synchronized (resolvedSquares) {
|
||||
return resolvedSquares.get(square);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the highest sampled point of a square, asking the elevation service once per
|
||||
* square and session. Blocks while the service answers.
|
||||
*
|
||||
* @param locator6 six-character locator
|
||||
* @return the highest point, or {@code null} when the locator is invalid or the lookup
|
||||
* failed (now or within the retry delay); the caller then uses the square centre
|
||||
*/
|
||||
public HighestPoint resolve(String locator6) {
|
||||
String square = normalize(locator6);
|
||||
if (square == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
synchronized (resolvedSquares) {
|
||||
HighestPoint cached = resolvedSquares.get(square);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Long retryAfter = retryAfterEpochMsBySquare.get(square);
|
||||
if (retryAfter != null) {
|
||||
if (clock.getAsLong() < retryAfter) {
|
||||
return null;
|
||||
}
|
||||
retryAfterEpochMsBySquare.remove(square);
|
||||
}
|
||||
}
|
||||
|
||||
// The service is asked outside the lock so that peekCached never waits for it.
|
||||
HighestPoint resolved = lookUp(square);
|
||||
|
||||
synchronized (resolvedSquares) {
|
||||
if (resolved == null) {
|
||||
long now = clock.getAsLong();
|
||||
retryAfterEpochMsBySquare.values().removeIf(retryAfter -> retryAfter <= now);
|
||||
retryAfterEpochMsBySquare.put(square, now + FAILURE_RETRY_DELAY_MS);
|
||||
} else {
|
||||
retryAfterEpochMsBySquare.remove(square);
|
||||
resolvedSquares.put(square, resolved);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Number of squares currently held back after a failure; for tests. */
|
||||
int failedSquareCount() {
|
||||
synchronized (resolvedSquares) {
|
||||
return retryAfterEpochMsBySquare.size();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sample grid of one square, row by row from south-west to north-east.
|
||||
*
|
||||
* @param locator6 six-character locator
|
||||
* @return {@value #GRID_SIZE} x {@value #GRID_SIZE} cell centres, or an empty list for
|
||||
* an invalid locator
|
||||
*/
|
||||
static List<PathGeometryUtils.GeoPoint> buildSampleGrid(String locator6) {
|
||||
String square = normalize(locator6);
|
||||
if (square == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
double southLatitudeDeg = Location.extractLat(square).toDegrees() - SQUARE_HEIGHT_DEG / 2.0;
|
||||
double westLongitudeDeg = Location.extractLon(square).toDegrees() - SQUARE_WIDTH_DEG / 2.0;
|
||||
|
||||
List<PathGeometryUtils.GeoPoint> grid = new ArrayList<>(GRID_SIZE * GRID_SIZE);
|
||||
for (int row = 0; row < GRID_SIZE; row++) {
|
||||
double latitudeDeg = southLatitudeDeg + SQUARE_HEIGHT_DEG * (row + 0.5) / GRID_SIZE;
|
||||
for (int column = 0; column < GRID_SIZE; column++) {
|
||||
double longitudeDeg = westLongitudeDeg + SQUARE_WIDTH_DEG * (column + 0.5) / GRID_SIZE;
|
||||
grid.add(new PathGeometryUtils.GeoPoint(latitudeDeg, longitudeDeg));
|
||||
}
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
private HighestPoint lookUp(String square) {
|
||||
List<PathGeometryUtils.GeoPoint> grid = buildSampleGrid(square);
|
||||
|
||||
final List<Double> elevations;
|
||||
try {
|
||||
elevations = elevationProvider.loadElevations(grid);
|
||||
} catch (RuntimeException exception) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (elevations == null || elevations.size() != grid.size()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int centreIndex = (GRID_SIZE / 2) * GRID_SIZE + GRID_SIZE / 2;
|
||||
PathGeometryUtils.GeoPoint centre = grid.get(centreIndex);
|
||||
|
||||
int bestIndex = -1;
|
||||
double bestElevation = Double.NEGATIVE_INFINITY;
|
||||
double bestDistanceToCentre = Double.POSITIVE_INFINITY;
|
||||
|
||||
for (int i = 0; i < grid.size(); i++) {
|
||||
Double elevation = elevations.get(i);
|
||||
if (elevation == null || !Double.isFinite(elevation)) {
|
||||
continue;
|
||||
}
|
||||
double distanceToCentre = squaredDistanceDeg(grid.get(i), centre);
|
||||
if (elevation > bestElevation
|
||||
|| (elevation == bestElevation && distanceToCentre < bestDistanceToCentre)) {
|
||||
bestIndex = i;
|
||||
bestElevation = elevation;
|
||||
bestDistanceToCentre = distanceToCentre;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PathGeometryUtils.GeoPoint best = grid.get(bestIndex);
|
||||
return new HighestPoint(square, best.latitudeDeg(), best.longitudeDeg(), bestElevation);
|
||||
}
|
||||
|
||||
private static double squaredDistanceDeg(PathGeometryUtils.GeoPoint a, PathGeometryUtils.GeoPoint b) {
|
||||
// Only used to order samples of one small square, so a flat approximation with the
|
||||
// longitude scaled by the square's own aspect is enough.
|
||||
double latitudeDelta = (a.latitudeDeg() - b.latitudeDeg()) / SQUARE_HEIGHT_DEG;
|
||||
double longitudeDelta = (a.longitudeDeg() - b.longitudeDeg()) / SQUARE_WIDTH_DEG;
|
||||
return latitudeDelta * latitudeDelta + longitudeDelta * longitudeDelta;
|
||||
}
|
||||
|
||||
private static String normalize(String locator6) {
|
||||
if (locator6 == null) {
|
||||
return null;
|
||||
}
|
||||
String square = locator6.trim().toUpperCase(Locale.ROOT);
|
||||
return LOCATOR6.matcher(square).matches() ? square : null;
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,14 @@ import java.util.stream.Collectors;
|
||||
* deliberately local and lightweight: it avoids repeated API calls when the user
|
||||
* re-selects the same station, but it does not replace the existing long-term
|
||||
* offline DEM/download architecture.</p>
|
||||
*
|
||||
* <p>The same endpoint also answers {@link TerrainElevationProvider} lookups for
|
||||
* arbitrary point sets, such as the grid used to find the highest point of a locator
|
||||
* square. Those go through the same rate limiter and are packed into as few requests
|
||||
* as the 100-coordinate limit allows; caching them is left to the caller, which knows
|
||||
* what the points stand for.</p>
|
||||
*/
|
||||
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider {
|
||||
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider, TerrainElevationProvider {
|
||||
|
||||
private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90";
|
||||
private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation";
|
||||
@@ -113,6 +119,44 @@ public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProv
|
||||
return profileData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Double> loadElevations(List<PathGeometryUtils.GeoPoint> points) {
|
||||
if (points == null || points.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<Double> elevations = new ArrayList<>(points.size());
|
||||
|
||||
try {
|
||||
for (int start = 0; start < points.size(); start += MAX_COORDINATES_PER_REQUEST) {
|
||||
int end = Math.min(points.size(), start + MAX_COORDINATES_PER_REQUEST);
|
||||
List<SamplePoint> chunk = new ArrayList<>(end - start);
|
||||
|
||||
for (PathGeometryUtils.GeoPoint point : points.subList(start, end)) {
|
||||
if (point == null
|
||||
|| !Double.isFinite(point.latitudeDeg())
|
||||
|| !Double.isFinite(point.longitudeDeg())) {
|
||||
return List.of();
|
||||
}
|
||||
chunk.add(new SamplePoint(0.0, point.latitudeDeg(), point.longitudeDeg()));
|
||||
}
|
||||
|
||||
List<Double> chunkElevations = fetchElevations(chunk);
|
||||
if (chunkElevations.size() != chunk.size()) {
|
||||
return List.of();
|
||||
}
|
||||
elevations.addAll(chunkElevations);
|
||||
}
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
return List.of();
|
||||
} catch (Exception exception) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return elevations;
|
||||
}
|
||||
|
||||
private int resolveOnlineSampleCount(int requestedSampleCount) {
|
||||
return Math.max(
|
||||
MIN_ONLINE_PROFILE_SAMPLE_COUNT,
|
||||
@@ -236,6 +280,7 @@ public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProv
|
||||
return parseElevationArray(response.body());
|
||||
}
|
||||
|
||||
// Sleeps while holding the lock: must never be called from the UI thread.
|
||||
private synchronized void waitForRateLimitSlot() throws InterruptedException {
|
||||
long now = System.currentTimeMillis();
|
||||
long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis);
|
||||
|
||||
@@ -4,6 +4,15 @@ import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable input for one path analysis run.
|
||||
*
|
||||
* <p>The two ground elevation overrides replace the terrain elevation the profile
|
||||
* provider reports at the respective endpoint. {@code NaN} means "no override": the
|
||||
* elevation model decides, as it always did.</p>
|
||||
*
|
||||
* @param homeGroundElevationOverrideMeters own ground elevation above mean sea level
|
||||
* entered by the operator, or {@code NaN}
|
||||
* @param targetGroundElevationOverrideMeters remote ground elevation already known to the
|
||||
* caller (the highest point of its locator square), or {@code NaN}
|
||||
*/
|
||||
public record PathAnalysisRequest(
|
||||
String fromLocator6,
|
||||
@@ -17,9 +26,44 @@ public record PathAnalysisRequest(
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathLinkBudgetSettings linkBudgetSettings
|
||||
PathLinkBudgetSettings linkBudgetSettings,
|
||||
double homeGroundElevationOverrideMeters,
|
||||
double targetGroundElevationOverrideMeters
|
||||
) {
|
||||
|
||||
/**
|
||||
* A request without ground elevation overrides.
|
||||
*/
|
||||
public PathAnalysisRequest(String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
String toCallsignRaw,
|
||||
String toLocator6,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double frequencyMHz,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathLinkBudgetSettings linkBudgetSettings) {
|
||||
this(
|
||||
fromLocator6,
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toCallsignRaw,
|
||||
toLocator6,
|
||||
toLatitudeDeg,
|
||||
toLongitudeDeg,
|
||||
frequencyMHz,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
effectiveEarthRadiusFactor,
|
||||
linkBudgetSettings,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
public PathAnalysisRequest(String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
@@ -63,7 +107,15 @@ public record PathAnalysisRequest(
|
||||
targetAntennaHeightMeters = Double.NaN;
|
||||
}
|
||||
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
effectiveEarthRadiusFactor = PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
|
||||
if (!Double.isFinite(homeGroundElevationOverrideMeters)) {
|
||||
homeGroundElevationOverrideMeters = Double.NaN;
|
||||
}
|
||||
|
||||
if (!Double.isFinite(targetGroundElevationOverrideMeters)) {
|
||||
targetGroundElevationOverrideMeters = Double.NaN;
|
||||
}
|
||||
|
||||
linkBudgetSettings = linkBudgetSettings == null
|
||||
? PathLinkBudgetSettings.defaults()
|
||||
@@ -96,6 +148,36 @@ public record PathAnalysisRequest(
|
||||
&& Double.isFinite(toLongitudeDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy whose remote endpoint is moved to the given position and ground
|
||||
* elevation. Locator and callsign stay, because the station is still the same.
|
||||
*
|
||||
* @param latitudeDeg new remote latitude
|
||||
* @param longitudeDeg new remote longitude
|
||||
* @param groundElevationMeters ground elevation there, or {@code NaN} for "ask the model"
|
||||
* @return the moved request
|
||||
*/
|
||||
public PathAnalysisRequest withTargetEndpoint(double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
double groundElevationMeters) {
|
||||
return new PathAnalysisRequest(
|
||||
fromLocator6,
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toCallsignRaw,
|
||||
toLocator6,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
frequencyMHz,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
effectiveEarthRadiusFactor,
|
||||
linkBudgetSettings,
|
||||
homeGroundElevationOverrideMeters,
|
||||
groundElevationMeters
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasUsableFrequency() {
|
||||
return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Abstraction for looking up terrain elevations at arbitrary points.
|
||||
*
|
||||
* <p>{@link TerrainProfileProvider} only samples along a great circle between two
|
||||
* endpoints. Some questions need a set of points that is not a path, such as a grid
|
||||
* over a locator square, and a provider that can answer many points in one request
|
||||
* should be asked for them together instead of point by point.</p>
|
||||
*/
|
||||
public interface TerrainElevationProvider {
|
||||
|
||||
/**
|
||||
* Loads the terrain elevation for every point.
|
||||
*
|
||||
* <p>Implementations may block on network or disk access, so this must not be called
|
||||
* on the user-interface thread.</p>
|
||||
*
|
||||
* @param points points to look up
|
||||
* @return elevations in metres above mean sea level, in the order of {@code points};
|
||||
* {@code NaN} for a point without data. An empty list means the lookup failed
|
||||
* as a whole.
|
||||
*/
|
||||
List<Double> loadElevations(List<PathGeometryUtils.GeoPoint> points);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
import kst4contest.view.map.LocatorHighestPointResolver;
|
||||
import kst4contest.view.map.PathAnalysisRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* How {@link ReachabilityService} turns the station preferences into a path-analysis
|
||||
* request, and which inputs reach the key that decides whether a cached result is reused.
|
||||
*/
|
||||
class ReachabilityServiceRequestTest {
|
||||
|
||||
private static final String OWN = "JN49FK";
|
||||
private static final String TARGET = "JO50AE";
|
||||
private static final double TARGET_CENTRE_LAT = Location.extractLat(TARGET).toDegrees();
|
||||
private static final double TARGET_CENTRE_LON = Location.extractLon(TARGET).toDegrees();
|
||||
|
||||
private static final LocatorHighestPointResolver.HighestPoint PEAK =
|
||||
new LocatorHighestPointResolver.HighestPoint(TARGET, TARGET_CENTRE_LAT + 0.01, TARGET_CENTRE_LON - 0.02, 640.0);
|
||||
|
||||
private static final Function<String, LocatorHighestPointResolver.HighestPoint> NOTHING_CACHED = locator -> null;
|
||||
private static final Function<String, LocatorHighestPointResolver.HighestPoint> PEAK_CACHED =
|
||||
locator -> TARGET.equals(locator) ? PEAK : null;
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
private ChatPreferences preferences() {
|
||||
return new ChatPreferences(directory.resolve("preferences.xml"));
|
||||
}
|
||||
|
||||
private static PathAnalysisRequest build(ChatPreferences preferences,
|
||||
Function<String, LocatorHighestPointResolver.HighestPoint> cache) {
|
||||
return ReachabilityService.buildRequest(
|
||||
preferences, OWN, "DL0TEST", TARGET, TARGET_CENTRE_LAT, TARGET_CENTRE_LON, 144.3, cache);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnOwnHeightTheElevationModelDecides() {
|
||||
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
|
||||
|
||||
assertTrue(Double.isNaN(request.homeGroundElevationOverrideMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnHeightAboveSeaLevelReachesTheRequest() {
|
||||
ChatPreferences preferences = preferences();
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
|
||||
|
||||
PathAnalysisRequest request = build(preferences, NOTHING_CACHED);
|
||||
|
||||
assertEquals(312.0, request.homeGroundElevationOverrideMeters(), 1e-12);
|
||||
assertEquals(Location.extractLat(OWN).toDegrees(), request.fromLatitudeDeg(), 1e-12,
|
||||
"the own position stays the locator centre");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCachedHighestPointMovesTheTarget() {
|
||||
PathAnalysisRequest request = build(preferences(), PEAK_CACHED);
|
||||
|
||||
assertEquals(PEAK.latitudeDeg(), request.toLatitudeDeg(), 1e-12);
|
||||
assertEquals(PEAK.longitudeDeg(), request.toLongitudeDeg(), 1e-12);
|
||||
assertEquals(640.0, request.targetGroundElevationOverrideMeters(), 1e-12);
|
||||
assertEquals(TARGET, request.toLocator6());
|
||||
assertFalse(ReachabilityService.needsHighestPointLookup(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnresolvedSquareKeepsTheCentreForTheBackgroundLookup() {
|
||||
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
|
||||
|
||||
assertEquals(TARGET_CENTRE_LAT, request.toLatitudeDeg(), 1e-12);
|
||||
assertEquals(TARGET_CENTRE_LON, request.toLongitudeDeg(), 1e-12);
|
||||
assertTrue(ReachabilityService.needsHighestPointLookup(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchedOffTheTargetStaysOnTheCentreEvenWhenThePeakIsKnown() {
|
||||
ChatPreferences preferences = preferences();
|
||||
preferences.setStn_pathAnalysisDxHighestPointInSquare(false);
|
||||
|
||||
PathAnalysisRequest request = build(preferences, PEAK_CACHED);
|
||||
|
||||
assertEquals(TARGET_CENTRE_LAT, request.toLatitudeDeg(), 1e-12);
|
||||
assertEquals(TARGET_CENTRE_LON, request.toLongitudeDeg(), 1e-12);
|
||||
assertTrue(Double.isNaN(request.targetGroundElevationOverrideMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theHighestPointIsOnByDefault() {
|
||||
assertTrue(preferences().isStn_pathAnalysisDxHighestPointInSquare());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnHeightChangesTheKey() {
|
||||
ChatPreferences preferences = preferences();
|
||||
String withoutHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
|
||||
String withHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(313.0);
|
||||
String withOtherHeight = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
|
||||
assertNotEquals(withoutHeight, withHeight);
|
||||
assertNotEquals(withHeight, withOtherHeight);
|
||||
}
|
||||
|
||||
/* One decimal used to be the key precision, so 123.04 and 123.00 m shared a result. */
|
||||
@Test
|
||||
void theOwnHeightIsKeyedAtFullPrecision() {
|
||||
ChatPreferences preferences = preferences();
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(123.00);
|
||||
String even = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(123.04);
|
||||
String odd = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
|
||||
assertNotEquals(even, odd);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTargetGroundElevationIsKeyedAtFullPrecision() {
|
||||
LocatorHighestPointResolver.HighestPoint lower =
|
||||
new LocatorHighestPointResolver.HighestPoint(TARGET, PEAK.latitudeDeg(), PEAK.longitudeDeg(), 640.00);
|
||||
LocatorHighestPointResolver.HighestPoint higher =
|
||||
new LocatorHighestPointResolver.HighestPoint(TARGET, PEAK.latitudeDeg(), PEAK.longitudeDeg(), 640.04);
|
||||
|
||||
assertNotEquals(
|
||||
ReachabilityService.buildCalculationKey(build(preferences(), locator -> lower), true),
|
||||
ReachabilityService.buildCalculationKey(build(preferences(), locator -> higher), true));
|
||||
}
|
||||
|
||||
/*
|
||||
* Before its square is resolved, a request looks the same with the switch on or off:
|
||||
* both stand on the centre. Only the switch in the key keeps a centre result from
|
||||
* answering a request that asks for the highest point, and the other way round.
|
||||
*/
|
||||
@Test
|
||||
void theSwitchChangesTheKeyEvenForTheSameCentreRequest() {
|
||||
PathAnalysisRequest request = build(preferences(), NOTHING_CACHED);
|
||||
|
||||
assertNotEquals(
|
||||
ReachabilityService.buildCalculationKey(request, true),
|
||||
ReachabilityService.buildCalculationKey(request, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theResolvedTargetChangesTheKey() {
|
||||
ChatPreferences preferences = preferences();
|
||||
|
||||
String centre = ReachabilityService.buildCalculationKey(build(preferences, NOTHING_CACHED), true);
|
||||
String peak = ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true);
|
||||
|
||||
assertNotEquals(centre, peak);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameInputsGiveTheSameKey() {
|
||||
ChatPreferences preferences = preferences();
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(312.0);
|
||||
|
||||
assertEquals(
|
||||
ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true),
|
||||
ReachabilityService.buildCalculationKey(build(preferences, PEAK_CACHED), true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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 own ground elevation above sea level: unset by default, limited to a sensible range,
|
||||
* and an unset value survives the XML file as unset rather than as a number.
|
||||
*/
|
||||
class ChatPreferencesOwnHeightAslTest {
|
||||
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
private ChatPreferences preferences() {
|
||||
return new ChatPreferences(directory.resolve("preferences.xml"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void itIsUnsetByDefault() {
|
||||
assertTrue(Double.isNaN(preferences().getStn_pathAnalysisOwnHeightAslMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void valuesInsideTheRangeAreKept() {
|
||||
ChatPreferences preferences = preferences();
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(-430.0);
|
||||
assertEquals(-430.0, preferences.getStn_pathAnalysisOwnHeightAslMeters(), 1e-12);
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(ChatPreferences.PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS);
|
||||
assertEquals(ChatPreferences.PATH_ANALYSIS_OWN_HEIGHT_ASL_MAX_METERS,
|
||||
preferences.getStn_pathAnalysisOwnHeightAslMeters(), 1e-12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void valuesOutsideTheRangeClearIt() {
|
||||
ChatPreferences preferences = preferences();
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(300.0);
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(9000.1);
|
||||
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(300.0);
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(-500.1);
|
||||
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
|
||||
|
||||
preferences.setStn_pathAnalysisOwnHeightAslMeters(Double.POSITIVE_INFINITY);
|
||||
assertTrue(Double.isNaN(preferences.getStn_pathAnalysisOwnHeightAslMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnsetValueIsWrittenEmptyAndReadBackUnset() throws Exception {
|
||||
Path file = directory.resolve("preferences.xml");
|
||||
ChatPreferences written = new ChatPreferences(file);
|
||||
written.setStn_pathAnalysisOwnHeightAslMeters(Double.NaN);
|
||||
assertTrue(written.writePreferencesToXmlFile());
|
||||
|
||||
String xml = Files.readString(file);
|
||||
assertTrue(xml.contains("<stn_pathAnalysisOwnHeightAslMeters/>")
|
||||
|| xml.contains("<stn_pathAnalysisOwnHeightAslMeters></stn_pathAnalysisOwnHeightAslMeters>"),
|
||||
"unset is written as an empty element");
|
||||
assertFalse(xml.contains(">NaN<"), "NaN must not reach the file");
|
||||
|
||||
ChatPreferences restored = new ChatPreferences(file);
|
||||
restored.setStn_pathAnalysisOwnHeightAslMeters(123.0);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertTrue(Double.isNaN(restored.getStn_pathAnalysisOwnHeightAslMeters()),
|
||||
"an empty element clears a value held before reading");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOutOfRangeValueInTheFileIsReadAsUnset() throws Exception {
|
||||
Path file = directory.resolve("preferences.xml");
|
||||
ChatPreferences written = new ChatPreferences(file);
|
||||
written.setStn_pathAnalysisOwnHeightAslMeters(250.0);
|
||||
assertTrue(written.writePreferencesToXmlFile());
|
||||
|
||||
String xml = Files.readString(file).replace(
|
||||
"<stn_pathAnalysisOwnHeightAslMeters>250.0</stn_pathAnalysisOwnHeightAslMeters>",
|
||||
"<stn_pathAnalysisOwnHeightAslMeters>25000</stn_pathAnalysisOwnHeightAslMeters>");
|
||||
Files.writeString(file, xml);
|
||||
|
||||
ChatPreferences restored = new ChatPreferences(file);
|
||||
assertTrue(restored.readPreferencesFromXmlFile());
|
||||
assertTrue(Double.isNaN(restored.getStn_pathAnalysisOwnHeightAslMeters()));
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ class ChatPreferencesRoundTripTest {
|
||||
Map.entry("setGuiOptions_language", "de"),
|
||||
Map.entry("setMYQRGFirstCat", "144.321.00"),
|
||||
Map.entry("setMYQRGSecondCat", "432.321.00"),
|
||||
Map.entry("setAirScout_asBandString", "4320000")
|
||||
Map.entry("setAirScout_asBandString", "4320000"),
|
||||
// Defaults to NaN ("elevation model decides"), and NaN + 7.5 is still NaN.
|
||||
Map.entry("setStn_pathAnalysisOwnHeightAslMeters", 312.5)
|
||||
);
|
||||
|
||||
@TempDir
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* The ground elevation overrides of {@link PathAnalysisRequest} replace the terrain the
|
||||
* profile reports at the endpoints, and only there.
|
||||
*
|
||||
* <p>Scenario: 20 km due north from 50.0 N 10.0 E over terrain of 0 m, both antennas
|
||||
* 10 m above ground. The curvature correction is zero at both endpoints, so the line of
|
||||
* sight runs from ground + 10 m at the start to ground + 10 m at the end and is linear in
|
||||
* between.</p>
|
||||
*/
|
||||
class GeometryOnlyPathAnalysisServiceGroundOverrideTest {
|
||||
|
||||
private static final double EARTH_RADIUS_KM = 6371.009;
|
||||
private static final double ANTENNA_HEIGHT_M = 10.0;
|
||||
|
||||
/** Flat 0 m terrain with the requested number of samples along 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);
|
||||
points.add(new PathProfilePoint(
|
||||
request.totalDistanceKm() * fraction,
|
||||
request.fromLatitudeDeg() + (request.toLatitudeDeg() - request.fromLatitudeDeg()) * fraction,
|
||||
request.fromLongitudeDeg(),
|
||||
0.0));
|
||||
}
|
||||
return new TerrainProfileData(points, "Flat test terrain", false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PathAnalysisResult analyze(double homeGroundMeters, double targetGroundMeters) {
|
||||
double toLatitudeDeg = 50.0 + Math.toDegrees(20.0 / EARTH_RADIUS_KM);
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
"JO50AA", 50.0, 10.0,
|
||||
"DL0TEST", "JO50AE", toLatitudeDeg, 10.0,
|
||||
144.3, ANTENNA_HEIGHT_M, ANTENNA_HEIGHT_M,
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
PathLinkBudgetSettings.defaults(),
|
||||
homeGroundMeters,
|
||||
targetGroundMeters
|
||||
);
|
||||
return new GeometryOnlyPathAnalysisService(new FlatTerrainProfileProvider()).analyze(request);
|
||||
}
|
||||
|
||||
private static PathProfilePoint first(PathAnalysisResult result) {
|
||||
return result.profilePoints().get(0);
|
||||
}
|
||||
|
||||
private static PathProfilePoint last(PathAnalysisResult result) {
|
||||
return result.profilePoints().get(result.profilePoints().size() - 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutOverridesTheModelTerrainIsUsed() {
|
||||
PathAnalysisResult result = analyze(Double.NaN, Double.NaN);
|
||||
|
||||
assertEquals(0.0, first(result).elevationMeters(), 1e-9);
|
||||
assertEquals(ANTENNA_HEIGHT_M, first(result).lineOfSightHeightMeters(), 1e-9);
|
||||
assertEquals(ANTENNA_HEIGHT_M, last(result).lineOfSightHeightMeters(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOwnHeightAboveSeaLevelReplacesTheStartTerrain() {
|
||||
PathAnalysisResult result = analyze(100.0, Double.NaN);
|
||||
|
||||
assertEquals(100.0, first(result).elevationMeters(), 1e-9);
|
||||
assertEquals(100.0, first(result).curvatureAdjustedElevationMeters(), 1e-9);
|
||||
assertEquals(110.0, first(result).lineOfSightHeightMeters(), 1e-9, "own antenna on the entered ground");
|
||||
assertEquals(0.0, last(result).elevationMeters(), 1e-9, "the far end stays on the model");
|
||||
assertEquals(ANTENNA_HEIGHT_M, last(result).lineOfSightHeightMeters(), 1e-9);
|
||||
|
||||
PathProfilePoint midpoint = result.profilePoints().get(result.profilePoints().size() / 2);
|
||||
assertEquals(0.0, midpoint.elevationMeters(), 1e-9, "the path between stays on the model");
|
||||
assertEquals(60.0, midpoint.lineOfSightHeightMeters(), 1e-6, "LOS is linear between the antennas");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTargetGroundElevationReplacesTheEndTerrain() {
|
||||
PathAnalysisResult result = analyze(Double.NaN, 50.0);
|
||||
|
||||
assertEquals(0.0, first(result).elevationMeters(), 1e-9);
|
||||
assertEquals(50.0, last(result).elevationMeters(), 1e-9);
|
||||
assertEquals(60.0, last(result).lineOfSightHeightMeters(), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* Grid sampling, selection, fallback and caching of {@link LocatorHighestPointResolver},
|
||||
* with an in-memory elevation provider that counts its calls instead of going online.
|
||||
*/
|
||||
class LocatorHighestPointResolverTest {
|
||||
|
||||
private static final String SQUARE = "JO50AE";
|
||||
private static final int GRID_POINTS = LocatorHighestPointResolver.GRID_SIZE * LocatorHighestPointResolver.GRID_SIZE;
|
||||
private static final int CENTRE_INDEX = GRID_POINTS / 2;
|
||||
|
||||
/** Answers from a function of the point index and records every request. */
|
||||
private static final class FakeElevationProvider implements TerrainElevationProvider {
|
||||
final List<List<PathGeometryUtils.GeoPoint>> calls = new ArrayList<>();
|
||||
Function<List<PathGeometryUtils.GeoPoint>, List<Double>> answer;
|
||||
|
||||
FakeElevationProvider(Function<List<PathGeometryUtils.GeoPoint>, List<Double>> answer) {
|
||||
this.answer = answer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Double> loadElevations(List<PathGeometryUtils.GeoPoint> points) {
|
||||
calls.add(List.copyOf(points));
|
||||
return answer.apply(points);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Double> flat(int size, double elevation) {
|
||||
return new ArrayList<>(Collections.nCopies(size, elevation));
|
||||
}
|
||||
|
||||
private static List<Double> flatWithPeak(int size, int peakIndex, double peak) {
|
||||
List<Double> elevations = flat(size, 100.0);
|
||||
elevations.set(peakIndex, peak);
|
||||
return elevations;
|
||||
}
|
||||
|
||||
@Test
|
||||
void theGridHasTwentyFiveCellCentresInsideTheSquareWithTheCentreInTheMiddle() {
|
||||
List<PathGeometryUtils.GeoPoint> grid = LocatorHighestPointResolver.buildSampleGrid(SQUARE);
|
||||
|
||||
assertEquals(GRID_POINTS, grid.size());
|
||||
for (PathGeometryUtils.GeoPoint point : grid) {
|
||||
assertEquals(SQUARE, Location.toMaidenhead(point.latitudeDeg(), point.longitudeDeg())
|
||||
.toUpperCase(Locale.ROOT), "sample " + point + " left the square");
|
||||
}
|
||||
|
||||
PathGeometryUtils.GeoPoint centre = grid.get(CENTRE_INDEX);
|
||||
assertEquals(Location.extractLat(SQUARE).toDegrees(), centre.latitudeDeg(), 1e-9);
|
||||
assertEquals(Location.extractLon(SQUARE).toDegrees(), centre.longitudeDeg(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInvalidLocatorHasNoGridAndAsksNothing() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size(), 0.0));
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider);
|
||||
|
||||
assertEquals(List.of(), LocatorHighestPointResolver.buildSampleGrid("JO50"));
|
||||
assertNull(resolver.resolve("JO50"));
|
||||
assertNull(resolver.resolve("ZZ99ZZ"));
|
||||
assertNull(resolver.resolve(null));
|
||||
assertEquals(0, provider.calls.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theHighestSampleIsChosenWithItsPositionAndElevation() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flatWithPeak(points.size(), 7, 512.0));
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider);
|
||||
|
||||
LocatorHighestPointResolver.HighestPoint highest = resolver.resolve("jo50ae");
|
||||
|
||||
PathGeometryUtils.GeoPoint expected = LocatorHighestPointResolver.buildSampleGrid(SQUARE).get(7);
|
||||
assertNotNull(highest);
|
||||
assertEquals(SQUARE, highest.locator6());
|
||||
assertEquals(expected.latitudeDeg(), highest.latitudeDeg(), 1e-12);
|
||||
assertEquals(expected.longitudeDeg(), highest.longitudeDeg(), 1e-12);
|
||||
assertEquals(512.0, highest.elevationMeters(), 1e-12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void allSamplesGoOutInOneBatch() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size(), 0.0));
|
||||
|
||||
new LocatorHighestPointResolver(provider).resolve(SQUARE);
|
||||
|
||||
assertEquals(1, provider.calls.size());
|
||||
assertEquals(GRID_POINTS, provider.calls.get(0).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFlatSquareResolvesToItsCentre() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size(), 250.0));
|
||||
|
||||
LocatorHighestPointResolver.HighestPoint highest = new LocatorHighestPointResolver(provider).resolve(SQUARE);
|
||||
|
||||
assertNotNull(highest);
|
||||
assertEquals(Location.extractLat(SQUARE).toDegrees(), highest.latitudeDeg(), 1e-9);
|
||||
assertEquals(Location.extractLon(SQUARE).toDegrees(), highest.longitudeDeg(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void samplesWithoutDataAreSkipped() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> {
|
||||
List<Double> elevations = flatWithPeak(points.size(), 3, 300.0);
|
||||
elevations.set(20, Double.NaN);
|
||||
elevations.set(21, null);
|
||||
return elevations;
|
||||
});
|
||||
|
||||
LocatorHighestPointResolver.HighestPoint highest = new LocatorHighestPointResolver(provider).resolve(SQUARE);
|
||||
|
||||
assertNotNull(highest);
|
||||
assertEquals(300.0, highest.elevationMeters(), 1e-12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSquareWithoutAnyDataFails() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size(), Double.NaN));
|
||||
|
||||
assertNull(new LocatorHighestPointResolver(provider).resolve(SQUARE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aResolvedSquareIsCachedAndPeekable() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flatWithPeak(points.size(), 0, 400.0));
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider);
|
||||
|
||||
assertNull(resolver.peekCached(SQUARE), "nothing cached before the first lookup");
|
||||
|
||||
LocatorHighestPointResolver.HighestPoint first = resolver.resolve(SQUARE);
|
||||
LocatorHighestPointResolver.HighestPoint second = resolver.resolve(SQUARE);
|
||||
|
||||
assertSame(first, second);
|
||||
assertSame(first, resolver.peekCached("jo50ae"));
|
||||
assertEquals(1, provider.calls.size(), "the square was asked for twice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void peekingNeverAsksTheProvider() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size(), 0.0));
|
||||
|
||||
assertNull(new LocatorHighestPointResolver(provider).peekCached(SQUARE));
|
||||
assertEquals(0, provider.calls.size());
|
||||
}
|
||||
|
||||
/*
|
||||
* A failed lookup falls back to the centre at the caller, and the square is not asked
|
||||
* again until the retry delay has passed, so an unreachable service is not hammered.
|
||||
*/
|
||||
@Test
|
||||
void aFailedLookupIsNotRetriedBeforeTheDelay() {
|
||||
AtomicLong now = new AtomicLong(1_000_000L);
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> List.of());
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider, now::get);
|
||||
|
||||
assertNull(resolver.resolve(SQUARE));
|
||||
assertNull(resolver.peekCached(SQUARE), "a failure is not a cached point");
|
||||
|
||||
now.addAndGet(LocatorHighestPointResolver.FAILURE_RETRY_DELAY_MS - 1);
|
||||
assertNull(resolver.resolve(SQUARE));
|
||||
assertEquals(1, provider.calls.size(), "retried inside the delay");
|
||||
|
||||
provider.answer = points -> flatWithPeak(points.size(), 24, 700.0);
|
||||
now.addAndGet(1);
|
||||
LocatorHighestPointResolver.HighestPoint retried = resolver.resolve(SQUARE);
|
||||
|
||||
assertEquals(2, provider.calls.size());
|
||||
assertNotNull(retried);
|
||||
assertEquals(700.0, retried.elevationMeters(), 1e-12);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredFailuresAreDroppedWhenTheNextOneIsRecorded() {
|
||||
AtomicLong now = new AtomicLong(1_000_000L);
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> List.of());
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider, now::get);
|
||||
|
||||
resolver.resolve("JO50AE");
|
||||
resolver.resolve("JO50AF");
|
||||
assertEquals(2, resolver.failedSquareCount());
|
||||
|
||||
now.addAndGet(LocatorHighestPointResolver.FAILURE_RETRY_DELAY_MS);
|
||||
resolver.resolve("JO50AG");
|
||||
|
||||
assertEquals(1, resolver.failedSquareCount(), "expired failures were kept");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFailureListIsBounded() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> List.of());
|
||||
LocatorHighestPointResolver resolver = new LocatorHighestPointResolver(provider, () -> 1_000_000L);
|
||||
|
||||
int squares = 0;
|
||||
outer:
|
||||
for (char field = 'A'; field <= 'R'; field++) {
|
||||
for (int digits = 0; digits < 100; digits++) {
|
||||
for (char sub = 'A'; sub <= 'X'; sub++) {
|
||||
String locator = String.format(Locale.ROOT, "J%c%02dA%c", field, digits, sub);
|
||||
resolver.resolve(locator);
|
||||
if (++squares > LocatorHighestPointResolver.MAX_CACHED_SQUARES + 10) {
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(LocatorHighestPointResolver.MAX_CACHED_SQUARES, resolver.failedSquareCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aThrowingProviderCountsAsAFailure() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> {
|
||||
throw new IllegalStateException("offline");
|
||||
});
|
||||
|
||||
assertNull(new LocatorHighestPointResolver(provider).resolve(SQUARE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAnswerOfTheWrongLengthCountsAsAFailure() {
|
||||
FakeElevationProvider provider = new FakeElevationProvider(points -> flat(points.size() - 1, 900.0));
|
||||
|
||||
assertNull(new LocatorHighestPointResolver(provider).resolve(SQUARE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Normalisation in the {@link PathAnalysisRequest} constructor and the endpoint copy.
|
||||
*/
|
||||
class PathAnalysisRequestTest {
|
||||
|
||||
private static PathAnalysisRequest withFactor(double effectiveEarthRadiusFactor) {
|
||||
return new PathAnalysisRequest(
|
||||
"JO50AA", 50.0, 10.0,
|
||||
"DL0TEST", "JO50AE", 50.2, 10.0,
|
||||
144.3, 10.0, 10.0,
|
||||
effectiveEarthRadiusFactor,
|
||||
PathLinkBudgetSettings.defaults()
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The constructor called the sanitizer and dropped its result, so a broken k-factor
|
||||
* stayed in the request. Nothing computed with it, because the analysis service
|
||||
* sanitizes once more, but the request itself said something else than what was used.
|
||||
*/
|
||||
@Test
|
||||
void anUnusableEarthRadiusFactorIsReplacedByTheDefault() {
|
||||
assertEquals(PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
withFactor(99.0).effectiveEarthRadiusFactor(), 1e-12);
|
||||
assertEquals(PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
withFactor(Double.NaN).effectiveEarthRadiusFactor(), 1e-12);
|
||||
assertEquals(PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
withFactor(0.1).effectiveEarthRadiusFactor(), 1e-12);
|
||||
}
|
||||
|
||||
/*
|
||||
* Valid factors pass the sanitizer unchanged (it is the identity on 0.5 .. 10), so
|
||||
* assigning its result changes nothing for valid input.
|
||||
*/
|
||||
@Test
|
||||
void aUsableEarthRadiusFactorIsKept() {
|
||||
for (double factor : new double[] {0.5, 1.0, 4.0 / 3.0, 2.5, 10.0}) {
|
||||
assertEquals(factor, withFactor(factor).effectiveEarthRadiusFactor(), 0.0, "k = " + factor);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutOverridesBothGroundElevationsAreLeftToTheModel() {
|
||||
PathAnalysisRequest request = withFactor(4.0 / 3.0);
|
||||
|
||||
assertTrue(Double.isNaN(request.homeGroundElevationOverrideMeters()));
|
||||
assertTrue(Double.isNaN(request.targetGroundElevationOverrideMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInfiniteOverrideCountsAsNone() {
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
"JO50AA", 50.0, 10.0,
|
||||
"DL0TEST", "JO50AE", 50.2, 10.0,
|
||||
144.3, 10.0, 10.0,
|
||||
4.0 / 3.0,
|
||||
PathLinkBudgetSettings.defaults(),
|
||||
Double.POSITIVE_INFINITY,
|
||||
Double.NEGATIVE_INFINITY
|
||||
);
|
||||
|
||||
assertTrue(Double.isNaN(request.homeGroundElevationOverrideMeters()));
|
||||
assertTrue(Double.isNaN(request.targetGroundElevationOverrideMeters()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void movingTheTargetKeepsEverythingElse() {
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
"JO50AA", 50.0, 10.0,
|
||||
"DL0TEST", "JO50AE", 50.2, 10.0,
|
||||
144.3, 12.0, 8.0,
|
||||
1.0,
|
||||
PathLinkBudgetSettings.defaults(),
|
||||
312.0,
|
||||
Double.NaN
|
||||
);
|
||||
|
||||
PathAnalysisRequest moved = request.withTargetEndpoint(50.21, 10.01, 480.0);
|
||||
|
||||
assertEquals(50.21, moved.toLatitudeDeg(), 1e-12);
|
||||
assertEquals(10.01, moved.toLongitudeDeg(), 1e-12);
|
||||
assertEquals(480.0, moved.targetGroundElevationOverrideMeters(), 1e-12);
|
||||
assertEquals("JO50AE", moved.toLocator6());
|
||||
assertEquals("DL0TEST", moved.toCallsignRaw());
|
||||
assertEquals(312.0, moved.homeGroundElevationOverrideMeters(), 1e-12);
|
||||
assertEquals(12.0, moved.homeAntennaHeightMeters(), 1e-12);
|
||||
assertEquals(8.0, moved.targetAntennaHeightMeters(), 1e-12);
|
||||
assertEquals(1.0, moved.effectiveEarthRadiusFactor(), 1e-12);
|
||||
assertEquals(144.3, moved.frequencyMHz(), 1e-12);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user