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:
Rsclub2_2andClaude Opus 5.5 authored and GitHub committed 2026-10-16 13:18:56 +02:00
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