mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +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
@@ -106,6 +106,7 @@ station.callsign=Rufzeichen
|
||||
station.chatSection=Chat-Kategorie
|
||||
station.defaultsSection=Vorgaben
|
||||
station.demDirectory=DEM-Stammverzeichnis
|
||||
station.invalidOwnHeightAsl=„{0}" ist keine gültige Höhe. Meter von -500 bis 9000 eingeben oder das Feld leer lassen, dann gilt das Höhenmodell.
|
||||
station.locator=Locator
|
||||
station.mainCategory=Hauptkategorie
|
||||
station.maxQrb=Maximaler QRB
|
||||
@@ -113,6 +114,7 @@ station.nameMain=Name, Hauptkategorie
|
||||
station.nameSecond=Name, zweite Kategorie
|
||||
station.ownAntennaGain=Eigener Antennengewinn, dBi
|
||||
station.ownAntennaHeight=Eigene Antennenhöhe, Meter
|
||||
station.ownHeightAsl=Eigene Standorthöhe über NN, Meter
|
||||
station.ownSection=Eigene Station
|
||||
station.ownTxPower=Eigene Sendeleistung, Watt
|
||||
station.password=Passwort
|
||||
@@ -128,6 +130,7 @@ station.serverHost=Servername
|
||||
station.serverPort=Serverport
|
||||
station.serverSection=ON4KST-Server
|
||||
station.targetAntennaGain=Antennengewinn der Gegenstation, dBi
|
||||
station.targetHighestPoint=Gegenstation auf dem höchsten Punkt ihres Locatorfelds
|
||||
station.targetTxPower=Sendeleistung der Gegenstation, Watt
|
||||
trxSynch.hint=Eine aktivierte Quelle erzeugt von sich aus keine Frequenz. Die eigene QRG wird erst aktualisiert, wenn tatsächlich gültige RadioInfo- oder Win-Test-STATUS-Daten eintreffen; bis dahin bleibt sie so, wie sie von Hand eingetragen wurde.
|
||||
trxSynch.hintSection=Was eine aktivierte Quelle bewirkt
|
||||
|
||||
@@ -115,6 +115,7 @@ station.callsign=Callsign
|
||||
station.chatSection=Chat category
|
||||
station.defaultsSection=Defaults
|
||||
station.demDirectory=DEM root directory
|
||||
station.invalidOwnHeightAsl="{0}" is not a valid height. Enter metres from -500 to 9000, or leave the field empty to use the elevation model.
|
||||
station.locator=Locator
|
||||
station.mainCategory=Main category
|
||||
station.maxQrb=Maximum QRB
|
||||
@@ -122,6 +123,7 @@ station.nameMain=Name, main category
|
||||
station.nameSecond=Name, second category
|
||||
station.ownAntennaGain=Own antenna gain, dBi
|
||||
station.ownAntennaHeight=Own antenna height, metres
|
||||
station.ownHeightAsl=Own ground height ASL, metres
|
||||
station.ownSection=Own station
|
||||
station.ownTxPower=Own TX power, watts
|
||||
station.password=Password
|
||||
@@ -137,6 +139,7 @@ station.serverHost=Server host name
|
||||
station.serverPort=Server port
|
||||
station.serverSection=ON4KST server
|
||||
station.targetAntennaGain=Target antenna gain, dBi
|
||||
station.targetHighestPoint=Target station on the highest point of its locator square
|
||||
station.targetTxPower=Target TX power, watts
|
||||
trxSynch.hint=An enabled source does not produce a frequency by itself. The own QRG is only updated once valid RadioInfo or Win-Test STATUS data actually arrives; until then it stays as entered by hand.
|
||||
trxSynch.hintSection=What an enabled source does
|
||||
|
||||
@@ -223,7 +223,7 @@ fun buildSettingsTabs(
|
||||
* so a tidier order or a shorter title would be a regression, not an improvement.
|
||||
*/
|
||||
return listOf(
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, host::applyOwnQrgFollower)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
|
||||
|
||||
@@ -13,9 +13,11 @@ import kst4contest.view.compose.Form
|
||||
*
|
||||
* The controls stage their edits in the window's draft (see StationTabState); only the
|
||||
* login credentials write through at once.
|
||||
*
|
||||
* @param onRefused shows why a committed entry was refused (the own ground height)
|
||||
*/
|
||||
@Composable
|
||||
fun StationTab(state: StationTabState) {
|
||||
fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
Column(
|
||||
@@ -98,8 +100,22 @@ fun StationTab(state: StationTabState) {
|
||||
Form.decimal(strings.stationOwnTxPower, state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
|
||||
Form.decimal(strings.stationOwnAntennaGain, state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
|
||||
Form.decimal(strings.stationOwnAntennaHeight, state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
|
||||
/*
|
||||
* Committed on Enter or on leaving the field, not per keystroke: the stored
|
||||
* value reads back as "250.0", and a per-keystroke write would feed that back
|
||||
* into the field while the operator is still typing.
|
||||
*/
|
||||
Form.committed(
|
||||
label = strings.stationOwnHeightAsl,
|
||||
stored = state.stn_pathAnalysisOwnHeightAslText,
|
||||
commit = state::commitOwnHeightAsl,
|
||||
onRefused = onRefused,
|
||||
isUnchanged = StationTabState::isOwnHeightAslUnchanged,
|
||||
current = { state.stn_pathAnalysisOwnHeightAslText },
|
||||
)
|
||||
Form.decimal(strings.stationTargetTxPower, state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
|
||||
Form.decimal(strings.stationTargetAntennaGain, state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
|
||||
Form.check(strings.stationTargetHighestPoint, state.stn_pathAnalysisDxHighestPointInSquare) { state.stn_pathAnalysisDxHighestPointInSquare = it }
|
||||
Form.text(strings.stationDemDirectory, state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@ package kst4contest.view.compose.tabs
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatPreferences
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.view.compose.Form
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
import kst4contest.view.i18n.CurrentStrings
|
||||
|
||||
/**
|
||||
* The Station tab as a typed facade over ChatPreferences.
|
||||
@@ -103,6 +108,58 @@ class StationTabState(
|
||||
|
||||
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
|
||||
|
||||
/**
|
||||
* Own ground elevation above sea level in metres, NaN while the elevation model decides.
|
||||
* Staged like every path-analysis setting; the reachability service reads the stored
|
||||
* value, so a changed height reaches the analysis only once it is applied.
|
||||
*/
|
||||
var stn_pathAnalysisOwnHeightAslMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnHeightAslMeters() }) { prefs.setStn_pathAnalysisOwnHeightAslMeters(it) }
|
||||
|
||||
/** Whether the path ends at the highest point of the remote locator square. */
|
||||
var stn_pathAnalysisDxHighestPointInSquare: Boolean by draft.setting({ prefs.isStn_pathAnalysisDxHighestPointInSquare() }) { prefs.setStn_pathAnalysisDxHighestPointInSquare(it) }
|
||||
|
||||
/**
|
||||
* The entry last committed to the own-height field, as the operator typed it.
|
||||
*
|
||||
* [Form.committed] keys its draft text on the stored text, and the staged number reads
|
||||
* back in its own spelling ("25.0" for "25"). The settings draft is Compose state, so
|
||||
* the commit itself recomposes the field with the new key, and without this the field
|
||||
* would swap "25" for "25.0" under the cursor right after Enter, or drop keys typed
|
||||
* since. Handing back the committed spelling while it still names the same number
|
||||
* keeps key and text in agreement.
|
||||
*/
|
||||
private var committedOwnHeightAslText by mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* The own ground elevation as its field shows it: empty while the elevation model
|
||||
* decides. The spelling of the last commit while it still names the drafted number,
|
||||
* otherwise that number itself (discarded, applied elsewhere, or never committed here).
|
||||
* Written only through [commitOwnHeightAsl].
|
||||
*/
|
||||
val stn_pathAnalysisOwnHeightAslText: String
|
||||
get() {
|
||||
val drafted = stn_pathAnalysisOwnHeightAslMeters.let { if (it.isFinite()) it.toString() else "" }
|
||||
val committed = committedOwnHeightAslText
|
||||
|
||||
return if (committed != null && isOwnHeightAslUnchanged(committed, drafted)) committed else drafted
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an own ground elevation entry into the draft. Empty hands the elevation back
|
||||
* to the model; a number in range is staged; anything else is refused and nothing is
|
||||
* staged.
|
||||
*
|
||||
* @return null when staged, otherwise the reason shown to the operator
|
||||
*/
|
||||
fun commitOwnHeightAsl(entered: String): String? {
|
||||
val meters = parseOwnHeightAsl(entered)
|
||||
?: return CurrentStrings.get().stationInvalidOwnHeightAsl(entered.trim())
|
||||
|
||||
stn_pathAnalysisOwnHeightAslMeters = meters
|
||||
committedOwnHeightAslText = entered.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
|
||||
|
||||
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
|
||||
@@ -176,4 +233,31 @@ class StationTabState(
|
||||
loginChatCategorySecond = category
|
||||
applySecondCategory(category)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Reads an own ground elevation entry: empty means "elevation model" (NaN), a
|
||||
* number in the accepted range is taken, anything else is refused with null.
|
||||
* A comma is read as the decimal separator, as in the other number fields.
|
||||
*/
|
||||
fun parseOwnHeightAsl(text: String): Double? {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return Double.NaN
|
||||
}
|
||||
val meters = trimmed.replace(',', '.').toDoubleOrNull() ?: return null
|
||||
return if (ChatPreferences.isValidOwnHeightAslMeters(meters)) meters else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a draft names the stored height. Compared as numbers, so "250", "250.0"
|
||||
* and "250,0" all count as the stored 250.0, and an empty draft as the unset value;
|
||||
* a draft that does not parse is never unchanged, so leaving the field refuses it.
|
||||
*/
|
||||
fun isOwnHeightAslUnchanged(draft: String, stored: String): Boolean {
|
||||
val typed = parseOwnHeightAsl(draft) ?: return false
|
||||
val now = parseOwnHeightAsl(stored) ?: return false
|
||||
return if (typed.isNaN() || now.isNaN()) typed.isNaN() && now.isNaN() else typed == now
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ class DraftedSettingsTableTest {
|
||||
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
|
||||
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
|
||||
// Through the field's commit, which validates and then stages.
|
||||
Case("station own ground height ASL", { it.station.commitOwnHeightAsl("312") },
|
||||
{ it.prefs.getStn_pathAnalysisOwnHeightAslMeters() }),
|
||||
flag("station target on highest point", { it.station.stn_pathAnalysisDxHighestPointInSquare }) { f, v ->
|
||||
f.station.stn_pathAnalysisDxHighestPointInSquare = v
|
||||
},
|
||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
|
||||
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
|
||||
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
|
||||
|
||||
@@ -14,6 +14,10 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
@@ -254,4 +258,169 @@ class StationTabScreenTest {
|
||||
waitForIdle()
|
||||
fieldBeside("Callsign").assertIsEnabled()
|
||||
}
|
||||
|
||||
private val ownHeightLabel = "Own ground height ASL, metres"
|
||||
private val antennaHeightLabel = "Own antenna height, metres"
|
||||
|
||||
/** Far down the tab: the row lookup compares on-screen bounds, so bring it into view. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.scrollToOwnHeight() {
|
||||
onNodeWithText(ownHeightLabel).performScrollTo()
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/** Types [keys] one by one into the cleared own-height field, as an operator would. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.typeKeysIntoOwnHeight(keys: String) {
|
||||
val field = fieldBeside(ownHeightLabel)
|
||||
field.performClick()
|
||||
field.performTextClearance()
|
||||
keys.forEach { field.performTextInput(it.toString()) }
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/** Leaves the own-height field by clicking the antenna height field above it. */
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.leaveOwnHeight() {
|
||||
fieldBeside(antennaHeightLabel).performClick()
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
/*
|
||||
* Typed key by key, nothing is staged until the field is left, nothing reaches the
|
||||
* preferences before Apply, and the typed text is never replaced by the value read back
|
||||
* ("2" did turn into "2.0" while typing "250").
|
||||
*/
|
||||
@Test
|
||||
fun theOwnHeightIsTypedKeyByKeyAndCommittedOnLeavingTheField() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab { StationTab(s.state) { refusals += it } }
|
||||
scrollToOwnHeight()
|
||||
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("")
|
||||
|
||||
typeKeysIntoOwnHeight("250")
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "staged before the field was left")
|
||||
|
||||
leaveOwnHeight()
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
typeKeysIntoOwnHeight("12,5")
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||
leaveOwnHeight()
|
||||
assertEquals(12.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "the height took effect before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
waitForIdle()
|
||||
assertEquals(12.5, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("12,5")
|
||||
|
||||
assertEquals(listOf<String>(), refusals)
|
||||
}
|
||||
|
||||
/*
|
||||
* After Enter the field keeps the operator's draft while the stored value reads back
|
||||
* differently ("250" against "250.0"). A recomposition of the tab must not swap the
|
||||
* draft for the stored text and drop what was typed since. The tab is forced to
|
||||
* recompose down to the field: the refusal callback captures a counter, so a new
|
||||
* counter value hands the path-analysis section a new lambda and it re-reads the
|
||||
* stored text. A connection change, the tab's own state, is driven as well.
|
||||
*/
|
||||
@Test
|
||||
fun aRecompositionAfterACommitKeepsTheKeysTypedSince() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val tick = mutableStateOf(0)
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab {
|
||||
val seen = tick.value
|
||||
StationTab(s.state) { refusals += "$it ($seen)" }
|
||||
}
|
||||
scrollToOwnHeight()
|
||||
|
||||
typeKeysIntoOwnHeight("25")
|
||||
fieldBeside(ownHeightLabel).performKeyInput { pressKey(Key.Enter) }
|
||||
waitForIdle()
|
||||
assertEquals(25.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
fieldBeside(ownHeightLabel).performTextInput("0")
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
runOnIdle { tick.value++ }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
runOnIdle { s.link.value = On4KstConnectionState.CONNECTING }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
|
||||
fieldBeside(ownHeightLabel).performTextInput("1")
|
||||
waitForIdle()
|
||||
runOnIdle { tick.value++ }
|
||||
waitForIdle()
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||
|
||||
leaveOwnHeight()
|
||||
assertEquals(2501.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertEquals(listOf<String>(), refusals)
|
||||
|
||||
// Applying changes the stored spelling to "2501.0"; the field must keep "2501".
|
||||
runOnIdle { s.draft.apply() }
|
||||
waitForIdle()
|
||||
assertEquals(2501.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("2501")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOutOfRangeOwnHeightIsRefusedAndFallsBackToTheStoredValue() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val refusals = mutableListOf<String>()
|
||||
showTab { StationTab(s.state) { refusals += it } }
|
||||
scrollToOwnHeight()
|
||||
|
||||
typeAndCommit(ownHeightLabel, "250")
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
|
||||
typeKeysIntoOwnHeight("9500")
|
||||
leaveOwnHeight()
|
||||
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "an out-of-range height was written")
|
||||
assertEquals(1, refusals.size, "the refusal was not reported")
|
||||
assertTrue(refusals.single().contains("9500"))
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("250")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearingTheOwnHeightHandsItBackToTheElevationModel() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
s.prefs.stn_pathAnalysisOwnHeightAslMeters = 312.0
|
||||
showTab { StationTab(s.state) }
|
||||
scrollToOwnHeight()
|
||||
|
||||
fieldBeside(ownHeightLabel).assertTextEquals("312.0")
|
||||
|
||||
typeAndCommit(ownHeightLabel, "")
|
||||
|
||||
assertTrue(s.state.stn_pathAnalysisOwnHeightAslMeters.isNaN(), "clearing did not hand back to the model")
|
||||
assertEquals(312.0, s.prefs.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "cleared before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertTrue(s.prefs.stn_pathAnalysisOwnHeightAslMeters.isNaN())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theHighestPointCheckboxIsOnAndTakesEffectOnApply() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
val label = "Target station on the highest point of its locator square"
|
||||
|
||||
checkBeside(label).assertIsOn()
|
||||
|
||||
toggle(label)
|
||||
checkBeside(label).assertIsOff()
|
||||
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare, "the switch took effect before Apply")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare)
|
||||
}
|
||||
}
|
||||
@@ -143,4 +143,87 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertTrue(prefs.isLoginToSecondChatEnabled())
|
||||
assertEquals(true, state.loginToSecondChatEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the own height above sea level reads empty while the elevation model decides`() {
|
||||
val s = Session()
|
||||
|
||||
assertEquals("", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl("312,5"))
|
||||
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "a commit took effect before Apply")
|
||||
assertEquals(312.5, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9)
|
||||
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText, "the field keeps the committed spelling")
|
||||
|
||||
s.draft.apply()
|
||||
assertEquals(312.5, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
assertEquals("312,5", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl(" "))
|
||||
s.draft.apply()
|
||||
assertTrue(s.prefs.getStn_pathAnalysisOwnHeightAslMeters().isNaN(), "clearing hands it back to the model")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded own height shows the stored value again`() {
|
||||
val s = Session()
|
||||
s.prefs.setStn_pathAnalysisOwnHeightAslMeters(400.0)
|
||||
|
||||
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||
assertEquals("250", s.state.stn_pathAnalysisOwnHeightAslText)
|
||||
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals("400.0", s.state.stn_pathAnalysisOwnHeightAslText, "the discarded spelling is still shown")
|
||||
assertEquals(400.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an own height that does not parse or is out of range is not staged`() {
|
||||
val s = Session()
|
||||
assertNull(s.state.commitOwnHeightAsl("250"))
|
||||
|
||||
listOf("25x", "9500", "-600", "NaN").forEach { entry ->
|
||||
val refusal = s.state.commitOwnHeightAsl(entry)
|
||||
assertTrue(refusal != null && refusal.contains(entry), "no refusal naming \"$entry\": $refusal")
|
||||
assertEquals(250.0, s.state.stn_pathAnalysisOwnHeightAslMeters, 1e-9, "\"$entry\" was staged")
|
||||
}
|
||||
|
||||
s.draft.apply()
|
||||
assertEquals(250.0, s.prefs.getStn_pathAnalysisOwnHeightAslMeters(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an own height draft is unchanged when it names the stored number`() {
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged("250", "250.0"))
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged("250,0", "250.0"))
|
||||
assertTrue(StationTabState.isOwnHeightAslUnchanged(" ", ""))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("", "250.0"))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("250", ""))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("12.5", "12.0"))
|
||||
assertFalse(StationTabState.isOwnHeightAslUnchanged("9500", "250.0"), "an unparsable draft must be committed and refused")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parsing the own height`() {
|
||||
assertTrue(StationTabState.parseOwnHeightAsl("")!!.isNaN())
|
||||
assertEquals(-430.0, StationTabState.parseOwnHeightAsl("-430"))
|
||||
assertEquals(8848.0, StationTabState.parseOwnHeightAsl(" 8848 "))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("9000.5"))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("-"))
|
||||
assertNull(StationTabState.parseOwnHeightAsl("NaN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the highest point switch waits in the draft until it is applied`() {
|
||||
val s = Session()
|
||||
assertTrue(s.state.stn_pathAnalysisDxHighestPointInSquare, "on by default")
|
||||
|
||||
s.state.stn_pathAnalysisDxHighestPointInSquare = false
|
||||
assertTrue(s.prefs.isStn_pathAnalysisDxHighestPointInSquare(), "the switch took effect before Apply")
|
||||
assertFalse(s.state.stn_pathAnalysisDxHighestPointInSquare)
|
||||
|
||||
s.draft.apply()
|
||||
assertFalse(s.prefs.isStn_pathAnalysisDxHighestPointInSquare())
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,10 @@ setStn_on4kstServersPort
|
||||
setStn_pathAnalysisDefaultTargetAntennaGainDbi
|
||||
setStn_pathAnalysisDefaultTargetTxPowerWatts
|
||||
setStn_pathAnalysisDemRootDirectory
|
||||
setStn_pathAnalysisDxHighestPointInSquare
|
||||
setStn_pathAnalysisOwnAntennaGainDbi
|
||||
setStn_pathAnalysisOwnAntennaHeightMeters
|
||||
setStn_pathAnalysisOwnHeightAslMeters
|
||||
setStn_pathAnalysisOwnTxPowerWatts
|
||||
setStn_pstRotatorEnabled
|
||||
setStn_pstRotatorHost
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
|
||||
- The active terrain profile provider is Open-Meteo using Copernicus GLO-90 data.
|
||||
- The terrain profile cache lives in its own global database `~/.praktiKST/terrainprofilecache.db`. It is deliberately not part of an operator profile: terrain profiles are pure geometry derived from two locators and a sample count, and at a multi operator station both operators share one location, so a per-profile copy would only double the traffic against the terrain service.
|
||||
- Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself.
|
||||
- The highest point of a remote locator square (path analysis, Station tab switch, on by default) is found by `LocatorHighestPointResolver`: a 5 x 5 grid of cell centres, one batched request against the same Open-Meteo provider and rate limiter as the profile, cached in memory per locator for the session and not persisted. A failed lookup is retried after five minutes; until then the analysis falls back to the square centre and that result is not kept in the reachability cache. The lookup runs on the reachability thread only; request building peeks the cache without blocking. The optional own ground height ASL replaces the first profile sample's elevation, a resolved highest point the last one.
|
||||
- `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain.
|
||||
|
||||
### ON4KST session and authentication
|
||||
|
||||
@@ -28,6 +28,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
||||
|
||||
- **Windows-Installer:** Neben dem portablen ZIP-Paket gibt es für Windows bei Stable-Releases jetzt einen MSI-Installer (`KST4Contest-v<Version>-windows-x64.msi`). Er installiert KST4Contest ohne Administratorrechte für das aktuelle Benutzerkonto, legt einen Startmenü-Eintrag und eine Desktop-Verknüpfung an und ersetzt bei einem Update die vorhandene Installation. Einstellungen und Datenbank bleiben unter `%USERPROFILE%\.praktiKST` und werden mit einer portablen Kopie geteilt. Damit ist die erste Stufe von [Issue #61](https://github.com/praktimarc/kst4contest/issues/61) umgesetzt.
|
||||
|
||||
- **Eigene Standorthöhe und höchster Punkt des Gegenstationsfelds in der Streckenanalyse:** Der Reiter **Station** hat im Abschnitt der Streckenanalyse zwei neue Einstellungen. **Eigene Standorthöhe über NN, Meter** ersetzt den Wert des Höhenmodells am eigenen Locatormittelpunkt durch die Höhe des tatsächlichen Standorts; bleibt das Feld leer, entscheidet wie bisher das Höhenmodell. **Gegenstation auf dem höchsten Punkt ihres Locatorfelds**, standardmäßig eingeschaltet, lässt die Strecke am höchsten von 25 abgetasteten Punkten des Locatorfelds der Gegenstation enden statt in dessen Mitte, weil Conteststationen meist vom höchsten Punkt ihres Feldes aus arbeiten. Die Suche kostet pro Gegenstationsfeld und Sitzung eine zusätzliche Anfrage an den Höhendienst; schlägt sie fehl, rechnet die Analyse wie bisher mit der Feldmitte.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Zwischenspeicher der Geländeprofile getrennt:** Berechnete Geländeprofile liegen jetzt in der eigenen, gemeinsam genutzten Datei `terrainprofilecache.db` und werden nach Besitzer getrennt gespeichert. Bisher wurde der gesamte Zwischenspeicher gelöscht, sobald sich Rufzeichen oder Locator änderten; bei einem Profilwechsel wäre damit jedes berechnete Profil verloren gegangen.
|
||||
|
||||
@@ -965,6 +965,8 @@ Aus den Höhenpunkten berechnet KST4Contest unter anderem:
|
||||
|
||||
Die eingestellte eigene Antennenhöhe wird zur lokalen Geländehöhe addiert. Für die Gegenstation wird derzeit eine feste angenommene Antennenhöhe von 10 Metern über Grund verwendet.
|
||||
|
||||
Standardmäßig endet die Strecke am höchsten Punkt des Locatorfelds der Gegenstation, nicht in dessen Mitte; die lokale Geländehöhe lässt sich durch die tatsächliche Höhe des eigenen Standorts ersetzen. Beide Einstellungen sind unter [Streckenanalyse und Link-Budget](de-Konfiguration#streckenanalyse-und-link-budget) beschrieben.
|
||||
|
||||
Bewegst du die Maus über das Profil, wird der dazugehörige Abfragepunkt zusätzlich auf der Karte markiert. Dadurch lässt sich ein auffälliger Berg oder Geländeeinschnitt leichter einer geografischen Position zuordnen.
|
||||
|
||||
### Welche Frequenz wird für die Berechnung verwendet?
|
||||
|
||||
@@ -72,13 +72,21 @@ Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Gel
|
||||
| Einstellung | Verwendung |
|
||||
|---|---|
|
||||
| **Own antenna height AGL** | Höhe der eigenen Antenne über dem lokalen Gelände in Metern |
|
||||
| **Eigene Standorthöhe über NN, Meter** | Optional: Geländehöhe des eigenen Standorts über dem Meeresspiegel. Leer lassen, um das Höhenmodell zu verwenden |
|
||||
| **Own TX power W** | Eigene Sendeleistung in Watt |
|
||||
| **Own ant. gain dBi** | Gewinn der eigenen Antenne in dBi |
|
||||
| **DX OM TX power W** | Angenommene Sendeleistung der Gegenstation in Watt |
|
||||
| **DX OM ant. gain dBi** | Angenommener Antennengewinn der Gegenstation in dBi |
|
||||
| **Gegenstation auf dem höchsten Punkt ihres Locatorfelds** | Lässt die Strecke am höchsten Punkt des Locatorfelds der Gegenstation enden statt in dessen Mitte (standardmäßig eingeschaltet) |
|
||||
|
||||
**AGL** bedeutet *Above Ground Level*. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem Höhenprofil; KST4Contest addiert die konfigurierte Antennenhöhe zu diesem Wert.
|
||||
|
||||
Dieses Feld nimmt nur die Antennenhöhe über Grund auf; für die Höhe des Standorts über dem Meeresspiegel gibt es ein eigenes Feld, das im nächsten Absatz beschrieben ist.
|
||||
|
||||
Das Höhenprofil nimmt die Geländehöhe am Mittelpunkt des eigenen Locatorfelds. Liegt der tatsächliche Standort deutlich höher oder tiefer, etwa auf einem Hügel am Rand des Feldes, trägst du seine Geländehöhe unter **Eigene Standorthöhe über NN, Meter** ein. KST4Contest verwendet dann diesen Wert statt des Höhenmodells und addiert die Antennenhöhe weiterhin darauf. Zulässig sind Werte von -500 bis 9000 Metern. Ein leeres Feld stellt das Höhenmodell wieder her. Die eigene Position auf der Karte und im Profil bleibt der Locatormittelpunkt.
|
||||
|
||||
Ein Locator sagt nur, in welchem Feld eine Gegenstation steht, nicht wo darin. Conteststationen arbeiten meist vom höchsten Punkt ihres Feldes aus. Ist **Gegenstation auf dem höchsten Punkt ihres Locatorfelds** eingeschaltet, tastet KST4Contest das Feld der Gegenstation mit einem Raster von 5 x 5 Punkten ab, nimmt den höchsten davon und lässt die Strecke dort enden, mit Position und Höhe dieses Punktes. Die Suche braucht je Locatorfeld der Gegenstation eine Anfrage an den Online-Höhendienst; das Ergebnis bleibt bis zum Beenden von KST4Contest erhalten. Schlägt die Suche fehl, rechnet die Analyse wie bisher mit der Feldmitte und versucht es einige Minuten später erneut.
|
||||
|
||||
Für die Gegenstation verwendet KST4Contest derzeit eine feste Antennenhöhe von 10 Metern über dem lokalen Gelände. Sendeleistung und Antennengewinn der Gegenstation stammen aus den beiden **DX OM**-Feldern. Diese Werte sind bewusst nur Annahmen: Der ON4KST-Chat überträgt weder die tatsächliche Antennenhöhe noch die vollständigen Stationsdaten der Gegenstation.
|
||||
|
||||
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, muss vor der Eingabe `2.15 dB` addiert werden:
|
||||
|
||||
@@ -28,6 +28,8 @@ Several operators sharing one computer can now use their own callsigns, locators
|
||||
|
||||
- **Windows installer:** next to the portable ZIP package, stable releases now also ship an MSI installer for Windows (`KST4Contest-v<version>-windows-x64.msi`). It installs KST4Contest for the current user account without administrator rights, adds a Start menu entry and a desktop shortcut, and replaces the existing installation on an update. Settings and database stay under `%USERPROFILE%\.praktiKST` and are shared with a portable copy. This implements the first stage of [Issue #61](https://github.com/praktimarc/kst4contest/issues/61).
|
||||
|
||||
- **Own site height and highest point of the remote square in the path analysis:** the **Station** tab has two new settings in its path-analysis section. **Own ground height ASL, metres** replaces the elevation-model value at the own locator centre by the height of the actual site; left empty, the elevation model decides as before. **Target station on the highest point of its locator square**, on by default, ends the path at the highest of 25 sampled points of the remote locator square instead of at its centre, because contest stations usually operate from the high ground of their square. The lookup costs one additional request to the elevation service per remote square and session; if it fails, the analysis uses the square centre as before.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The terrain profile cache is separate:** computed terrain profiles now live in their own shared file `terrainprofilecache.db` and are stored per owner. Previously the whole cache was dropped whenever the callsign or locator changed, which would have discarded every computed profile on each profile switch.
|
||||
|
||||
@@ -68,13 +68,21 @@ The station map uses several values from the **Station** tab to calculate the te
|
||||
| Setting | Use |
|
||||
|---|---|
|
||||
| **Own antenna height AGL** | Height of the local antenna above the surrounding terrain in metres |
|
||||
| **Own ground height ASL, metres** | Optional: ground elevation of the own site above sea level. Leave empty to use the elevation model |
|
||||
| **Own TX power W** | Local transmit power in watts |
|
||||
| **Own ant. gain dBi** | Gain of the local antenna in dBi |
|
||||
| **DX OM TX power W** | Assumed transmit power of the remote station in watts |
|
||||
| **DX OM ant. gain dBi** | Assumed antenna gain of the remote station in dBi |
|
||||
| **Target station on the highest point of its locator square** | Ends the path at the highest point of the remote locator square instead of at its centre (on by default) |
|
||||
|
||||
**AGL** means *Above Ground Level*. Do not enter the height above sea level. The terrain elevation at the local station already comes from the elevation profile; KST4Contest adds the configured antenna height to this value.
|
||||
|
||||
This field is for the antenna height above ground only; the height of the site above sea level, if you want to set it, has its own field, described next.
|
||||
|
||||
The elevation profile takes the local terrain elevation at the centre of the own locator square. If the actual site lies noticeably higher or lower, for example on a hill at the edge of the square, enter its ground elevation in **Own ground height ASL, metres** (**ASL**: *Above Sea Level*). KST4Contest then uses this value instead of the elevation model and still adds the antenna height on top. Values from -500 to 9000 metres are accepted. An empty field restores the elevation model. The own position on the map and in the profile remains the locator centre.
|
||||
|
||||
A locator only tells which square a remote station is in, not where in it. Contest stations usually operate from the high ground of their square, so with **Target station on the highest point of its locator square** switched on, KST4Contest samples the remote square on a grid of 5 x 5 points, takes the highest of them and ends the path there, at that point's position and elevation. The lookup needs one request to the online elevation service per remote locator square; the result is kept until KST4Contest is closed. If the lookup fails, the analysis uses the centre of the square as before and tries again a few minutes later.
|
||||
|
||||
For the remote station, KST4Contest currently uses a fixed antenna height of 10 metres above the local terrain. Its transmit power and antenna gain come from the two **DX OM** fields. These values are deliberately assumptions: ON4KST provides neither the actual antenna height nor the complete station data of the remote operator.
|
||||
|
||||
Antenna gains must be entered in `dBi`. Add `2.15 dB` before entering a value specified in `dBd`:
|
||||
|
||||
@@ -949,6 +949,8 @@ The profile combines:
|
||||
|
||||
The configured **Own antenna height AGL** is added to the terrain elevation at the local station. For the remote station, KST4Contest currently assumes an antenna height of 10 metres above the local terrain.
|
||||
|
||||
By default the path ends at the highest point of the remote station's locator square, not at its centre; the local terrain elevation can be replaced by the actual height of the own site. Both settings are described under [Path Analysis and Link Budget](en-Configuration#path-analysis-and-link-budget).
|
||||
|
||||
Moving the mouse over the path profile marks the corresponding position on the map. This makes it easier to identify which hill or terrain section causes a reported obstruction.
|
||||
|
||||
### Frequency selection
|
||||
|
||||
Reference in new issue
Block a user