From c7e54c1028e103ceee8b46e5bf4a823d371d7252 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:44:27 -0500 Subject: [PATCH 01/27] spike(coverage): compute RF coverage locally instead of driving a WebView Adds feature:coverage, which sweeps radials from a site, builds a terrain profile per radial and runs org.meshtastic:kp1812 (ITU-R P.1812) per sample to predict received signal strength in-process. This is the replacement for SitePlannerRunner's hidden WebView: 319 lines that load site.meshtastic.org, wait up to 45 s for a JavaScript bridge, and need the network. Desktop cannot run that at all, so today it opens a browser and asks the user to export a .geojson and re-import it by hand. The seam is a one-method ElevationSource, backed by feature/map-terrain's Mapterhorn tiles in the app and by a lambda in tests - so the suite proves the behaviour with no network, no WebView and no terrain download: signal decays with distance, a 400 m ridge shadows what is behind it, more power reaches further, and reachability agrees with receiver sensitivity. Scoped to jvm() on purpose. kp1812 publishes no androidTarget - Android is meant to take its jvm artifact, as with kzstd - and that resolution question is separate from whether the model works. P.1812 is also a different model from the planner's ITM, so predictions will not match it pixel for pixel. --- feature/coverage/README.md | 52 +++++ feature/coverage/build.gradle.kts | 38 ++++ .../feature/coverage/LocalCoverage.kt | 177 ++++++++++++++++++ .../feature/coverage/LocalCoverageTest.kt | 103 ++++++++++ gradle/libs.versions.toml | 2 + settings.gradle.kts | 2 + 6 files changed, 374 insertions(+) create mode 100644 feature/coverage/README.md create mode 100644 feature/coverage/build.gradle.kts create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt diff --git a/feature/coverage/README.md b/feature/coverage/README.md new file mode 100644 index 00000000000..28ef5e38aaf --- /dev/null +++ b/feature/coverage/README.md @@ -0,0 +1,52 @@ +# feature:coverage — SPIKE + +Local RF coverage, replacing the headless-WebView hand-off to the hosted Site Planner. + +## What it replaces + +| Host | Today | With this | +| --- | --- | --- | +| Android | 319-line hidden `WebView` loading site.meshtastic.org, JS bridge, 45 s timeout, needs network | in-process, offline once terrain is cached | +| Desktop | opens a browser; user **exports a `.geojson` and re-imports it by hand** | in-process | + +`SitePlannerRunner.kt` is load-bearing in ways that read as a warning: the WebView must be +`alpha(0)` *but still attached and 280 dp* or WebGL never gets a context; it carries a deferred +retry for the system-WebView provider-update race; and `shouldOverrideUrlLoading` locks +navigation to the planner's origin so nothing else can reach `onCoverage`. All of that exists to +work around running a browser to do arithmetic. + +## Shape + +``` +Site + ElevationSource ──► LocalCoverage.sweep() ──► Coverage(points: List) + │ + └─ org.meshtastic:kp1812 (ITU-R P.1812) +``` + +`ElevationSource` is a single suspend method. The app backs it with `feature/map-terrain`'s +Mapterhorn tiles; tests back it with a lambda, which is why the suite needs no network, no +WebView and no terrain download. + +## Scope and caveats + +- **`jvm()` only.** `kp1812` publishes no `androidTarget` — Android is meant to consume its `jvm` + artifact, the same choice `kzstd` makes — and proving that resolution path is a separate + question from proving the model works. Desktop is also where the current experience is worst. +- **Different model.** P.1812 is not ITM. Predictions will not match the hosted planner pixel for + pixel, and that is expected rather than a defect. +- **No UI wiring.** This is computation plus tests. Replacing `DesktopSitePlannerSlot` is the next + step and needs the full android baseline run. +- **`kp1812` is unpublished**, so the spike resolves it from `mavenLocal` via the repo's existing + `-PuseMavenLocal` flag. Publish it with `./gradlew publishJvmPublicationToMavenLocal + publishKotlinMultiplatformPublicationToMavenLocal` from the sibling checkout. + +## Tests + +Behavioural, not conformance — `kp1812` already checks itself against the ITU reference: + +- signal decays with distance over flat ground +- a 400 m ridge measurably shadows what is behind it +- more transmit power reaches at least as far +- `reachable` agrees with the receiver sensitivity +- the sweep covers every bearing +- the geodesy round-trips diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts new file mode 100644 index 00000000000..4a42a232be8 --- /dev/null +++ b/feature/coverage/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +plugins { alias(libs.plugins.meshtastic.kmp.feature) } + +// SPIKE: local RF coverage, replacing the headless-WebView hand-off to the hosted Site Planner. +// Pure computation over org.meshtastic:kp1812 (ITU-R P.1812) and an ElevationSource — no Compose +// UI, no rendering, no network. The app supplies elevation from feature/map-terrain's Mapterhorn +// tiles; tests supply a lambda. +// SPIKE SCOPE: jvm() only. kp1812 publishes no androidTarget (Android is meant to consume its +// jvm artifact, the same choice kzstd makes), and proving that resolution path is a separate +// question from proving the model works. Desktop is also where the current experience is worst: +// it cannot run the WebView at all, so today it opens a browser and asks the user to export and +// re-import a file by hand. +kotlin { + jvm() + + sourceSets { + commonMain.dependencies { + implementation(libs.kp1812) + implementation(libs.kotlinx.coroutines.core) + } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + } +} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt new file mode 100644 index 00000000000..f83f603d88a --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import org.meshtastic.kp1812.Atmosphere +import org.meshtastic.kp1812.P1812 +import org.meshtastic.kp1812.Polarization +import org.meshtastic.kp1812.TerrainPath +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** + * Spike: compute RF coverage locally, replacing the headless-WebView hand-off to the hosted + * Site Planner. + * + * Today `SitePlannerRunner` loads site.meshtastic.org in a hidden WebView, waits up to 45 s for a + * JavaScript bridge to hand back GeoJSON, and needs the network. Desktop cannot even do that — + * it opens a browser and asks the user to export and re-import a file by hand. + * + * This computes the same answer in-process from `org.meshtastic:kp1812` and an [ElevationSource], + * so it works offline once terrain is cached and produces a result on every platform the app runs + * on. The propagation model is ITU-R P.1812 rather than the planner's SPLAT!/ITM — a different + * model, so predictions will not match the hosted planner pixel for pixel. + */ +class LocalCoverage( + private val elevation: ElevationSource, + private val atmosphere: Atmosphere = Atmosphere(), +) { + + /** + * Sweep radials out from [site] and return received signal strength at every sample point. + * + * @param site the transmitter + * @param radials how many bearings to sweep; the planner's own sweep uses one per perimeter pixel + * @param samplesPerRadial profile points along each radial, including the transmitter + */ + suspend fun sweep(site: Site, radials: Int = DEFAULT_RADIALS, samplesPerRadial: Int = DEFAULT_SAMPLES): Coverage { + require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" } + require(samplesPerRadial >= MIN_SAMPLES) { "samplesPerRadial must be >= $MIN_SAMPLES, got $samplesPerRadial" } + + val points = ArrayList(radials * samplesPerRadial) + val stepKm = site.radiusKm / (samplesPerRadial - 1) + + for (i in 0 until radials) { + val bearing = 360.0 * i / radials + // One profile per radial, reused for every receiver position along it: the profile to a + // point 5 km out is the prefix of the profile to a point 20 km out. + val lats = DoubleArray(samplesPerRadial) + val lons = DoubleArray(samplesPerRadial) + val heights = DoubleArray(samplesPerRadial) + for (s in 0 until samplesPerRadial) { + val (lat, lon) = destination(site.latitude, site.longitude, bearing, stepKm * s) + lats[s] = lat + lons[s] = lon + heights[s] = elevation.elevationMeters(lat, lon) + } + + // Skip the first few samples: P.1812 needs a profile with interior points, and a + // receiver on top of the transmitter is not a useful prediction anyway. + for (end in MIN_PROFILE_POINTS - 1 until samplesPerRadial) { + val n = end + 1 + val d = DoubleArray(n) { stepKm * it } + val h = DoubleArray(n) { heights[it] } + val prediction = P1812.predict( + path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), + frequencyGhz = site.frequencyMhz / 1000.0, + txHeightM = site.txHeightM, + rxHeightM = site.rxHeightM, + timePercent = site.timePercent, + pathCentreLatitudeDeg = (site.latitude + lats[end]) / 2.0, + polarization = Polarization.VERTICAL, + atmosphere = atmosphere, + ) + val rxDbm = P1812.receivedPower( + prediction, + txPowerDbm = site.txPowerDbm, + txGainDbi = site.txGainDbi, + rxGainDbi = site.rxGainDbi, + ).value + points.add(CoveragePoint(lats[end], lons[end], rxDbm)) + } + } + return Coverage(site, points) + } + + private companion object { + const val DEFAULT_RADIALS = 180 + const val DEFAULT_SAMPLES = 60 + const val MIN_RADIALS = 4 + const val MIN_SAMPLES = 4 + const val MIN_PROFILE_POINTS = 3 + const val INLAND = 4 + } +} + +/** Elevation above mean sea level, metres. Backed by Mapterhorn tiles in the app; trivially fakeable in tests. */ +fun interface ElevationSource { + suspend fun elevationMeters(latitude: Double, longitude: Double): Double +} + +/** The transmitter and the radio configuration to model. */ +data class Site( + val name: String, + val latitude: Double, + val longitude: Double, + val frequencyMhz: Double, + val txPowerDbm: Double, + val rxSensitivityDbm: Double, + val txHeightM: Double = 2.0, + val rxHeightM: Double = 1.0, + val txGainDbi: Double = 2.0, + val rxGainDbi: Double = 0.0, + val radiusKm: Double = 15.0, + val clutterHeightM: Double = 1.0, + /** Percentage of time the prediction holds. P.1812 accepts 1..50. */ + val timePercent: Double = 50.0, +) + +/** One sampled receiver position and the signal predicted there. */ +data class CoveragePoint(val latitude: Double, val longitude: Double, val rxDbm: Double) + +/** The result of a sweep. */ +data class Coverage(val site: Site, val points: List) { + /** Points at or above the receiver's sensitivity — where a node would actually hear this site. */ + val reachable: List get() = points.filter { it.rxDbm >= site.rxSensitivityDbm } + + /** Fraction of sampled points that are reachable. */ + val reachableFraction: Double get() = if (points.isEmpty()) 0.0 else reachable.size.toDouble() / points.size + + /** Greatest distance at which any sampled point is reachable, km. */ + val maxRangeKm: Double + get() = reachable.maxOfOrNull { haversineKm(site.latitude, site.longitude, it.latitude, it.longitude) } ?: 0.0 +} + +private const val EARTH_RADIUS_KM = 6371.0 + +/** Great-circle destination from a start point along [bearingDeg] for [distanceKm]. */ +internal fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, distanceKm: Double): Pair { + val ang = distanceKm / EARTH_RADIUS_KM + val lat1 = latDeg.toRadians() + val lon1 = lonDeg.toRadians() + val brg = bearingDeg.toRadians() + val lat2 = asin(sin(lat1) * cos(ang) + cos(lat1) * sin(ang) * cos(brg)) + val lon2 = lon1 + atan2(sin(brg) * sin(ang) * cos(lat1), cos(ang) - sin(lat1) * sin(lat2)) + return lat2.toDegrees() to lon2.toDegrees() +} + +/** Great-circle distance between two points, km. */ +internal fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = (lat2 - lat1).toRadians() + val dLon = (lon2 - lon1).toRadians() + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1.toRadians()) * cos(lat2.toRadians()) * sin(dLon / 2) * sin(dLon / 2) + return 2 * EARTH_RADIUS_KM * asin(max(-1.0, kotlin.math.min(1.0, kotlin.math.sqrt(a)))) +} + +private fun Double.toRadians(): Double = this * PI / 180.0 + +private fun Double.toDegrees(): Double = this * 180.0 / PI diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt new file mode 100644 index 00000000000..44ac557b4ff --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.test.runTest +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Spike verification: the local model behaves the way a coverage prediction must, without + * needing a network, a WebView or a terrain download. + * + * These are behavioural assertions, not conformance ones — `kp1812`'s own suite already checks + * the model against the ITU reference. What matters here is that this module drives it correctly. + */ +class LocalCoverageTest { + + private val flat = ElevationSource { _, _ -> 0.0 } + + private fun site(radiusKm: Double = 10.0, txPowerDbm: Double = 30.0) = Site( + name = "Test", + latitude = 47.6, + longitude = -122.2, + frequencyMhz = 915.0, + txPowerDbm = txPowerDbm, + rxSensitivityDbm = -130.0, + txHeightM = 10.0, + radiusKm = radiusKm, + ) + + @Test + fun signalFallsOffWithDistance() = runTest { + val coverage = LocalCoverage(flat).sweep(site(), radials = 4, samplesPerRadial = 20) + // Take one radial's worth of points and check monotone decay over flat ground. + val radial = coverage.points.take(18) + val distances = radial.map { haversineKm(47.6, -122.2, it.latitude, it.longitude) } + assertTrue(distances.zipWithNext().all { (a, b) -> b > a }, "samples should step outward") + val strengths = radial.map { it.rxDbm } + assertTrue( + strengths.first() > strengths.last(), + "signal should be weaker far away: ${strengths.first()} -> ${strengths.last()}", + ) + } + + @Test + fun aHillBlocksSignalBehindIt() = runTest { + // A ridge 5 km north of the site, 400 m tall. Everything beyond it should be worse than + // the same distance to the south, where the ground is flat. + val ridged = ElevationSource { lat, _ -> + val northKm = (lat - 47.6) * 111.0 + if (northKm in 4.5..5.5) 400.0 else 0.0 + } + val coverage = LocalCoverage(ridged).sweep(site(), radials = 4, samplesPerRadial = 24) + val far = 8.0 + fun strengthTowards(bearingIndex: Int): Double { + val perRadial = coverage.points.size / 4 + return coverage.points.drop(bearingIndex * perRadial).take(perRadial) + .minByOrNull { abs(haversineKm(47.6, -122.2, it.latitude, it.longitude) - far) }!! + .rxDbm + } + val north = strengthTowards(0) // bearing 0 = north, over the ridge + val south = strengthTowards(2) // bearing 180 = south, flat + assertTrue(north < south, "signal past a 400 m ridge ($north dBm) should be weaker than flat ($south dBm)") + } + + @Test + fun higherTransmitPowerReachesFurther() = runTest { + val low = LocalCoverage(flat).sweep(site(txPowerDbm = 17.0), radials = 4, samplesPerRadial = 20) + val high = LocalCoverage(flat).sweep(site(txPowerDbm = 30.0), radials = 4, samplesPerRadial = 20) + assertTrue( + high.maxRangeKm >= low.maxRangeKm, + "30 dBm (${high.maxRangeKm} km) should reach at least as far as 17 dBm (${low.maxRangeKm} km)", + ) + assertTrue(high.reachableFraction >= low.reachableFraction) + } + + @Test + fun reachableRespectsReceiverSensitivity() = runTest { + val coverage = LocalCoverage(flat).sweep(site(), radials = 4, samplesPerRadial = 12) + assertTrue(coverage.points.isNotEmpty()) + assertTrue(coverage.reachable.all { it.rxDbm >= coverage.site.rxSensitivityDbm }) + assertTrue(coverage.points.none { it.rxDbm >= coverage.site.rxSensitivityDbm && it !in coverage.reachable }) + } + + @Test + fun sweepCoversEveryBearing() = runTest { + val coverage = LocalCoverage(flat).sweep(site(), radials = 8, samplesPerRadial = 6) + // 8 radials x (6 samples - 2 skipped leading points) = 32 + assertEquals(8 * 4, coverage.points.size) + } + + @Test + fun destinationAndHaversineAgree() { + val (lat, lon) = destination(47.6, -122.2, bearingDeg = 45.0, distanceKm = 25.0) + val back = haversineKm(47.6, -122.2, lat, lon) + assertTrue(abs(back - 25.0) < 0.01, "round trip was $back km, expected 25") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d74f6ae637a..c0ef95406c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -375,6 +375,8 @@ dd-sdk-android = [ ] +kp1812 = { module = "org.meshtastic:kp1812", version = "0.1.0" } + [plugins] # Android android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 8a8976a2992..5f7a581177c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -112,6 +112,7 @@ include( ":feature:connections", ":feature:map", ":feature:map-maplibre", + ":feature:coverage", ":feature:map-terrain", ":feature:node", ":feature:settings", @@ -129,3 +130,4 @@ include( ":baselineprofile", ":store-screenshots", ) + From 92038e8d6ed5f08c3466c86dc720814b8ea3ed3b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:46:52 -0500 Subject: [PATCH 02/27] fix(coverage): put the kp1812 entry in [libraries], not [bundles] A module table is invalid in [bundles], so the catalog failed to parse - which Gradle surfaced two layers up as "Error resolving plugin [id: 'meshtastic.develocity']". The real cause was only visible in the stack trace, at TomlCatalogFileParser.throwVersionCatalogProblemException. --- gradle/libs.versions.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0ef95406c5..a6226c28cb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -363,6 +363,9 @@ takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "ta meshtastic-protobufs = { module = "org.meshtastic:protobufs", version.ref = "meshtastic-protobufs" } +# SPIKE: unpublished; resolved from mavenLocal via -PuseMavenLocal. +kp1812 = { module = "org.meshtastic:kp1812", version = "0.1.0" } + [bundles] # Datadog RUM/telemetry stack — always consumed together (androidApp google flavor) dd-sdk-android = [ @@ -375,8 +378,6 @@ dd-sdk-android = [ ] -kp1812 = { module = "org.meshtastic:kp1812", version = "0.1.0" } - [plugins] # Android android-application = { id = "com.android.application", version.ref = "agp" } From 5c121e4fc0b7e052e52415f1beac67deaa2646d8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:14:20 -0500 Subject: [PATCH 03/27] refactor(coverage): move the model, elevation source and export to commonMain feature:coverage was jvm-only because I put the map-terrain dependency in jvmMain, not because anything required it. TerrainTileFetcher and decodeTerrariumTile are already expect/actual with android and jvm actuals, and TerrainTileMath and MapterhornEndpoints were always common - so MapterhornElevation moves across with one real change: Dispatchers.IO is JVM/Android-only, so it becomes Dispatchers.Default. Coverage.toGeoJson() moves out of the desktop demo into commonMain, where it belongs: it is pure string building and it is exactly what Android needs to feed the existing import path. Only CoverageDemo stays in jvmMain, since it renders with java.awt and javax.imageio. Adding the android target also forced the deferred question - the convention plugin gives every KMP module iOS targets, so the build immediately demanded kp1812-iossimulatorarm64. Publishing all thirteen kp1812 targets locally resolved it, which incidentally proves the iOS klibs are consumable. Kotlin/Native then rejected String.format the moment the export became common. Replaced with a toFixed1() helper. Its first test asserted -76.15 rounds to -76.2; roundToLong breaks ties toward positive infinity and -76.15 * 10 is -761.4999999999999 anyway, so the real answer is -76.1. The assertion was wrong, not the code, and ties are now deliberately unasserted - pinning them would test floating-point representation rather than behaviour. Note allTests reports green while iosSimulatorArm64Test is SKIPPED: this repo compile-checks Apple targets via kmpSmokeCompile rather than executing them. Eight tests execute on JVM; iOS is compile-verified only. --- feature/coverage/build.gradle.kts | 30 ++++- .../feature/coverage/CoverageGeoJson.kt | 44 +++++++ .../feature/coverage/MapterhornElevation.kt | 90 +++++++++++++ .../feature/coverage/LocalCoverageTest.kt | 28 ++++ .../feature/coverage/CoverageDemo.kt | 123 ++++++++++++++++++ 5 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt create mode 100644 feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts index 4a42a232be8..30db6c3a610 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -20,19 +20,39 @@ plugins { alias(libs.plugins.meshtastic.kmp.feature) } // Pure computation over org.meshtastic:kp1812 (ITU-R P.1812) and an ElevationSource — no Compose // UI, no rendering, no network. The app supplies elevation from feature/map-terrain's Mapterhorn // tiles; tests supply a lambda. -// SPIKE SCOPE: jvm() only. kp1812 publishes no androidTarget (Android is meant to consume its -// jvm artifact, the same choice kzstd makes), and proving that resolution path is a separate -// question from proving the model works. Desktop is also where the current experience is worst: -// it cannot run the WebView at all, so today it opens a browser and asks the user to export and -// re-import a file by hand. kotlin { jvm() + // kp1812 publishes no androidTarget - Android resolves its jvm artifact, the same way this + // repo already consumes takpacket-sdk-jvm. + @Suppress("UnstableApiUsage") + android { + namespace = "org.meshtastic.feature.coverage" + androidResources.enable = false + } + sourceSets { commonMain.dependencies { implementation(libs.kp1812) implementation(libs.kotlinx.coroutines.core) + // Elevation comes from the same Mapterhorn archives the map already uses for hillshade + // and contours. Over flat synthetic ground a coverage plot is a bullseye and proves + // nothing; against real terrain it has to show ridges shadowing valleys. + implementation(projects.feature.mapTerrain) } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + jvmTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } + +// SPIKE: run a real prediction and write a PNG + GeoJSON, so the replacement can be *seen*. +// ./gradlew :feature:coverage:coverageDemo -PuseMavenLocal +tasks.register("coverageDemo") { + group = "verification" + description = "Compute real coverage from Mapterhorn terrain via kp1812 and render it." + val jvmMain = kotlin.targets.getByName("jvm").compilations.getByName("main") + classpath = jvmMain.output.allOutputs + jvmMain.runtimeDependencyFiles!! + mainClass.set("org.meshtastic.feature.coverage.CoverageDemo") + args = (providers.gradleProperty("demoArgs").orNull ?: "").split(" ").filter { it.isNotBlank() } +} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt new file mode 100644 index 00000000000..a9922082219 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.feature.coverage + +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * The coverage as a GeoJSON FeatureCollection with simplestyle-spec properties — the same shape the + * hosted planner's export produces, so the app's existing import path accepts it unchanged. + */ +fun Coverage.toGeoJson(): String { + val features = reachable.joinToString(",\n") { p -> + val color = when { + p.rxDbm > site.rxSensitivityDbm + 30 -> "#67ea94" + p.rxDbm > site.rxSensitivityDbm + 15 -> "#ffd166" + else -> "#ef476f" + } + """ {"type":"Feature","geometry":{"type":"Point","coordinates":[${p.longitude},${p.latitude}]},""" + + """"properties":{"rx_dbm":${p.rxDbm.toFixed1()},"marker-color":"$color"}}""" + } + return """{ + "type": "FeatureCollection", + "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812"}, + "features": [ +$features + ] +} +""" +} + +/** + * One decimal place, without `String.format` — which is JVM-only and does not exist on + * Kotlin/Native or wasm. Adding the native targets is what surfaced that. + */ +internal fun Double.toFixed1(): String { + val scaled = (this * 10).roundToLong() + val sign = if (scaled < 0) "-" else "" + val magnitude = abs(scaled) + return "$sign${magnitude / 10}.${magnitude % 10}" +} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt new file mode 100644 index 00000000000..b6a4618d292 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.meshtastic.feature.map.terrain.ElevationTile +import org.meshtastic.feature.map.terrain.MapterhornEndpoints +import org.meshtastic.feature.map.terrain.TerrainTileFetcher +import org.meshtastic.feature.map.terrain.TerrainTileMath +import org.meshtastic.feature.map.terrain.decodeTerrariumTile +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.tan + +/** + * Real elevation from the same Mapterhorn archives the map already uses for hillshade and contours. + * + * Common, not desktop-only: `TerrainTileFetcher` and `decodeTerrariumTile` are already + * expect/actual with android and jvm actuals, so nothing here is platform-specific. The native + * actual of the decoder throws by design (see its doc comment in feature:map-terrain), which is + * the same trade that module already makes. + * + * This is what makes the demo meaningful: over flat synthetic ground a coverage plot is a bullseye + * and proves nothing. Against real terrain the prediction has to show ridges shadowing valleys, + * which is the entire reason the planner exists. + * + * Tiles are decoded once and cached in memory — a radial sweep asks for thousands of points that + * land in a handful of tiles. + */ +class MapterhornElevation(private val zoom: Int = DEFAULT_ZOOM) : ElevationSource, AutoCloseable { + + private val fetcher = TerrainTileFetcher(MapterhornEndpoints.GLOBAL_PMTILES_URL) + private val cache = HashMap() + private val lock = Mutex() + + override suspend fun elevationMeters(latitude: Double, longitude: Double): Double { + val tile = TerrainTileMath.tileAt(zoom, latitude, longitude) + val key = (tile.x.toLong() shl 32) or tile.y.toLong() + val decoded = lock.withLock { + if (cache.containsKey(key)) { + cache[key] + } else { + // Dispatchers.IO is JVM/Android-only; Default keeps this source common. + val bytes = withContext(Dispatchers.Default) { fetcher.fetchTile(zoom, tile.x, tile.y) } + val t = bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } + cache[key] = t + t + } + } ?: return 0.0 // ocean, or outside coverage — sea level, as the planner also assumes + + // Fractional position of this coordinate within its tile. + val n = 1 shl zoom + val fx = (longitude + 180.0) / 360.0 * n - tile.x + val latRad = latitude * PI / 180.0 + val fy = (1.0 - ln(tan(latRad) + 1.0 / kotlin.math.cos(latRad)) / PI) / 2.0 * n - tile.y + val px = (fx * decoded.width).toInt().coerceIn(0, decoded.width - 1) + val py = (fy * decoded.height).toInt().coerceIn(0, decoded.height - 1) + return decoded.elevationAt(px, py).toDouble() + } + + /** How many distinct tiles the sweep actually touched — useful when reporting a run. */ + val tilesFetched: Int get() = cache.size + + override fun close() = fetcher.close() + + private companion object { + /** + * Mapterhorn's global archive tops out at z12 ([MapterhornEndpoints.GLOBAL_MAX_ZOOM]); + * z11 is ~75 m/px at mid latitudes, comparable to the planner's 90 m standard mode. + */ + const val DEFAULT_ZOOM = 11 + } +} diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt index 44ac557b4ff..db775956684 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -101,3 +101,31 @@ class LocalCoverageTest { assertTrue(abs(back - 25.0) < 0.01, "round trip was $back km, expected 25") } } + +/** + * The one-decimal helper that replaced `String.format`, which does not exist off the JVM. + * + * Exact halves are deliberately not asserted: `roundToLong` breaks ties toward positive infinity, + * and whether a decimal like -76.15 even *is* a tie depends on its binary representation + * (-76.15 * 10 is -761.4999999999999, so it rounds to -76.1). Immaterial for displaying dBm, and + * a test that pinned it would be asserting floating-point trivia rather than behaviour. + */ +class ToFixed1Test { + @Test + fun formatsToOneDecimalPlace() { + assertEquals("-76.1", (-76.14).toFixed1()) + assertEquals("-76.2", (-76.16).toFixed1()) + assertEquals("0.0", 0.0.toFixed1()) + assertEquals("130.0", 130.0.toFixed1()) + assertEquals("-130.0", (-129.999).toFixed1()) + assertEquals("7.5", 7.45001.toFixed1()) + } + + @Test + fun roundTripsThroughGeoJson() { + // The value that actually matters: what lands in the exported feature properties. + assertEquals("-76.1", (-76.14).toFixed1()) + assertEquals("-145.4", (-145.44).toFixed1()) + assertEquals("-61.7", (-61.72).toFixed1()) + } +} diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt new file mode 100644 index 00000000000..ad7118d3cb8 --- /dev/null +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.runBlocking +import java.io.File +import javax.imageio.ImageIO +import java.awt.image.BufferedImage +import kotlin.math.roundToInt +import kotlin.system.measureTimeMillis + +/** + * Runs a real coverage prediction end to end and writes a PNG plus the GeoJSON the app imports. + * + * This exists so the spike can be *seen* rather than only asserted: real Mapterhorn terrain, the + * ITU-R P.1812 model from `org.meshtastic:kp1812`, no network call to site.meshtastic.org, no + * WebView, no browser. The same `LocalCoverage` the desktop app would call. + * + * `./gradlew :feature:coverage:coverageDemo -PuseMavenLocal` + */ +object CoverageDemo { + + @JvmStatic + fun main(args: Array) { + val lat = args.getOrNull(0)?.toDoubleOrNull() ?: DEFAULT_LAT + val lon = args.getOrNull(1)?.toDoubleOrNull() ?: DEFAULT_LON + val outDir = File(args.getOrNull(2) ?: "build/coverage-demo").apply { mkdirs() } + + val site = Site( + name = "Demo", + latitude = lat, + longitude = lon, + frequencyMhz = 906.875, // Meshtastic US LongFast + txPowerDbm = 30.0, + rxSensitivityDbm = -130.0, // LONG_FAST + txHeightM = 10.0, + rxHeightM = 1.5, + radiusKm = 25.0, + ) + + println("site ${site.latitude}, ${site.longitude} ${site.frequencyMhz} MHz ${site.txPowerDbm} dBm") + MapterhornElevation().use { elevation -> + lateinit var coverage: Coverage + val ms = measureTimeMillis { + coverage = runBlocking { + LocalCoverage(elevation).sweep(site, radials = RADIALS, samplesPerRadial = SAMPLES) + } + } + val reach = coverage.reachable + println("computed ${coverage.points.size} points in ${ms}ms from ${elevation.tilesFetched} terrain tiles") + println("reachable: ${reach.size} (${(coverage.reachableFraction * 100).roundToInt()}%)") + println("max range: ${"%.1f".format(coverage.maxRangeKm)} km") + println("rx dBm range: ${"%.1f".format(coverage.points.minOf { it.rxDbm })} .. " + + "${"%.1f".format(coverage.points.maxOf { it.rxDbm })}") + + File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) + renderPng(coverage, File(outDir, "coverage.png")) + println("wrote ${outDir.absolutePath}/coverage.{png,geojson}") + } + } + + /** Plot the sweep as a top-down image, coloured by signal strength. */ + @Suppress("MagicNumber") + private fun renderPng(coverage: Coverage, dest: File) { + val size = 700 + val img = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB) + val g = img.createGraphics() + g.color = java.awt.Color(0x14, 0x15, 0x1c) + g.fillRect(0, 0, size, size) + + val site = coverage.site + val scale = (size / 2.0) / site.radiusKm + val sensitivity = site.rxSensitivityDbm + val strongest = coverage.points.maxOf { it.rxDbm } + + for (p in coverage.points) { + val km = haversineKm(site.latitude, site.longitude, p.latitude, p.longitude) + val bearing = kotlin.math.atan2( + (p.longitude - site.longitude) * kotlin.math.cos(site.latitude * Math.PI / 180), + p.latitude - site.latitude, + ) + val px = (size / 2 + kotlin.math.sin(bearing) * km * scale).toInt() + val py = (size / 2 - kotlin.math.cos(bearing) * km * scale).toInt() + if (px !in 0 until size || py !in 0 until size) continue + g.color = colorFor(p.rxDbm, sensitivity, strongest) + g.fillOval(px - 3, py - 3, 6, 6) + } + // transmitter + g.color = java.awt.Color(0x67, 0xEA, 0x94) + g.fillOval(size / 2 - 5, size / 2 - 5, 10, 10) + g.dispose() + ImageIO.write(img, "png", dest) + } + + /** Below sensitivity is left dark; above it ramps red → yellow → green. */ + @Suppress("MagicNumber") + private fun colorFor(dbm: Double, sensitivity: Double, strongest: Double): java.awt.Color { + if (dbm < sensitivity) return java.awt.Color(0x22, 0x23, 0x2c) + val t = ((dbm - sensitivity) / (strongest - sensitivity)).coerceIn(0.0, 1.0) + val r = if (t < 0.5) 255 else (255 * (1 - (t - 0.5) * 2)).toInt().coerceIn(0, 255) + val gc = if (t < 0.5) (255 * t * 2).toInt().coerceIn(0, 255) else 255 + return java.awt.Color(r, gc, 60) + } + + private const val DEFAULT_LAT = 47.6062 // Seattle — real relief nearby + private const val DEFAULT_LON = -122.3321 + private const val RADIALS = 120 + private const val SAMPLES = 40 +} From e9b343a6c2f936c6c23bb455675839a7cca755e4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:40:30 -0500 Subject: [PATCH 04/27] feat(coverage): draw coverage as a map layer, and stop under-sampling terrain Two fixes from seeing it run in the app. The result now persists via MapLayersManager.addGeoJsonLayer - the same path the F-Droid flavour already uses for the WebView's GeoJSON - so it lands on the map, appears in the layers list as LayerType.COVERAGE and survives the dialog closing. That seam was already there and already injected into DesktopMapViewProvider; the previous commit built a dead-end dialog instead of looking for it. Terrain profile resolution is now decoupled from receiver spacing. One array served both, so a 30 km radius meant a 900 m profile step while P.1812 integrates diffraction across the whole profile. It now samples every 100 m, close to Mapterhorn z11's ~75 m/px, and strides receivers along it. Cost is unchanged because the expense is the number of predict() calls, not profile length. Measured on the Seattle demo, this moved the reachable fraction 91% -> 88% - so the earlier claim that aliasing was depressing the reachable count was wrong. What it did change is dynamic range, 84 dB -> 105 dB, consistent with a profile that now resolves peaks instead of averaging them away. sweepCoversEveryBearing asserted an exact 32 points and got 56: striding receivers along a dense profile yields a few more per radial when the stride rounds. It now asserts every bearing contributes equally, which is the property that actually matters. --- desktopApp/build.gradle.kts | 1 + .../desktop/map/DesktopSitePlannerSlot.kt | 238 ++++++++++++++++-- .../feature/coverage/LocalCoverage.kt | 71 ++++-- .../feature/coverage/LocalCoverageTest.kt | 18 +- .../feature/coverage/CoverageDemo.kt | 4 +- 5 files changed, 287 insertions(+), 45 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 640b57d8bf5..0eceb633ce8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -372,6 +372,7 @@ dependencies { implementation(projects.feature.messaging) implementation(projects.feature.connections) implementation(projects.feature.map) + implementation(projects.feature.coverage) implementation(projects.feature.discovery) implementation(projects.feature.firmware) implementation(projects.feature.wifiProvision) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index f4f4daa2930..151d836f3dc 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -16,30 +16,74 @@ */ package org.meshtastic.desktop.map +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel +import org.maplibre.spatialk.geojson.Position +import org.meshtastic.feature.coverage.Coverage +import org.meshtastic.feature.coverage.LocalCoverage +import org.meshtastic.feature.coverage.MapterhornElevation +import org.meshtastic.feature.coverage.Site +import org.meshtastic.feature.coverage.haversineKm +import org.meshtastic.feature.coverage.toGeoJson import org.meshtastic.feature.map.SharedMapViewModel -import org.meshtastic.feature.map.component.SitePlannerBrowserSheet +import org.meshtastic.feature.map.layers.MapLayersManager +import org.meshtastic.feature.map.component.SitePlannerParams +import org.meshtastic.feature.map.component.SitePlannerSheet import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.maplibre.SitePlannerSession +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.log10 +import kotlin.math.roundToInt +import kotlin.math.sin /** - * Site Planner on the desktop: the same configuration form the Android maps show, handed off to the browser. + * Site Planner on the desktop — computed **in this app**, not in a browser. * - * The Android hosts run the planner in an embedded WebView and take the coverage estimate back through a JavaScript - * bridge, which lands it on the map as a GeoJSON layer. Desktop has no embedded browser: the JetBrains Runtime the app - * builds against does ship JCEF, but jlink strips it out of the packaged runtime, and putting it back measured at - * roughly three and a half times the size of the whole application. + * Previously this opened site.meshtastic.org in the system browser and asked the user to export a + * `.geojson` and re-import it by hand: desktop has no embedded browser, and putting JCEF back into + * the jlink'd runtime measured at roughly three and a half times the size of the whole application. * - * So the estimate is produced and viewed in the browser rather than drawn here. The form is not skipped — the - * transmitter is still seeded from the connected radio and the map centre is still offered as a shortcut, so the - * planner opens with everything already filled in. + * Now `feature:coverage` runs ITU-R P.1812 (`org.meshtastic:kp1812`) against the same Mapterhorn + * elevation the map already uses for hillshade and contours. No network call to the planner, no + * WebView, no export/re-import round trip. + * + * Note this is a different propagation model from the hosted planner's SPLAT!/ITM, so predictions + * will not match it pixel for pixel. */ @Composable fun DesktopSitePlannerSlot(session: SitePlannerSession) { val sharedViewModel: SharedMapViewModel = koinViewModel() + val layersManager: MapLayersManager = koinInject() val ourNode by sharedViewModel.ourNodeInfo.collectAsStateWithLifecycle() val channelSet by sharedViewModel.channelSet.collectAsStateWithLifecycle() @@ -48,11 +92,173 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { // A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to. val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode - SitePlannerBrowserSheet( - initial = subject.toSitePlannerParams(channelSet), - onDismiss = session.onDismiss, - onUseNodeLocation = - subject?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }, - onUseMapCenter = { session.mapCenter().let { it.latitude to it.longitude } }, - ) + var params by remember(subject) { mutableStateOf(subject.toSitePlannerParams(channelSet)) } + var running by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } + var failure by remember { mutableStateOf(null) } + + val current = running + val coverage = result + + when { + coverage != null -> CoverageResultDialog(coverage) { + result = null + session.onDismiss() + } + + current != null -> { + ComputingDialog(current.name) { + running = null + session.onDismiss() + } + LaunchedEffect(current) { + runCatching { + withContext(Dispatchers.Default) { + MapterhornElevation().use { elevation -> + LocalCoverage(elevation).sweep(current.toSite(), radials = RADIALS, receiversPerRadial = RECEIVERS) + } + } + } + .onSuccess { swept -> + // Persist and draw it on the map, the same path the F-Droid flavour uses for + // the WebView's GeoJSON — so the coverage survives the dialog closing and + // shows up in the layers list like any other import. + layersManager.addGeoJsonLayer(current.name, swept.toGeoJson()) + session.moveTo(Position(longitude = current.longitude, latitude = current.latitude)) + result = swept + } + .onFailure { failure = it.message ?: it::class.simpleName } + running = null + } + } + + else -> SitePlannerSheet( + initial = params, + onSubmit = { submitted -> + params = submitted + running = submitted + }, + onDismiss = session.onDismiss, + note = failure?.let { "Coverage failed: $it" } + ?: "Computed on this device with ITU-R P.1812 — no browser, works offline once terrain is cached.", + onUseNodeLocation = subject?.takeIf { it.validPosition != null }?.let { node -> + { params = params.copy(latitude = node.latitude, longitude = node.longitude) } + }, + onUseMapCenter = { + session.mapCenter().let { params = params.copy(latitude = it.latitude, longitude = it.longitude) } + }, + ) + } +} + +/** The planner's flat params, as the coverage module's site. */ +private fun SitePlannerParams.toSite(): Site = Site( + name = name, + latitude = latitude, + longitude = longitude, + frequencyMhz = txFreqMhz, + // The planner carries watts; P.1812 wants dBm. + txPowerDbm = 10.0 * log10(txPowerWatts * MILLIWATTS_PER_WATT), + rxSensitivityDbm = rxSensitivityDbm, + txHeightM = txHeightMeters, + rxHeightM = rxHeightMeters, + txGainDbi = txGainDbi, + radiusKm = maxRangeKm, +) + +@Composable +private fun ComputingDialog(name: String, onCancel: () -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.size(300.dp).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + CircularProgressIndicator() + Text("Estimating coverage for $name", style = MaterialTheme.typography.bodyLarge) + Text("ITU-R P.1812 · on this device", style = MaterialTheme.typography.bodySmall) + TextButton(onClick = onCancel) { Text("Cancel") } + } + } + } +} + +@Composable +private fun CoverageResultDialog(coverage: Coverage, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.padding(20.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(coverage.site.name, style = MaterialTheme.typography.titleMedium) + Text("Added to the map as a coverage layer", style = MaterialTheme.typography.bodySmall) + Text( + "ITU-R P.1812 · ${coverage.site.frequencyMhz.roundToInt()} MHz · " + + "${coverage.site.txPowerDbm.roundToInt()} dBm", + style = MaterialTheme.typography.bodySmall, + ) + CoveragePlot(coverage, modifier = Modifier.fillMaxWidth().aspectRatio(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text("${coverage.points.size} points", style = MaterialTheme.typography.bodySmall) + Text( + "${(coverage.reachableFraction * PERCENT).roundToInt()}% reachable", + style = MaterialTheme.typography.bodySmall, + ) + Text( + "max ${coverage.maxRangeKm.roundToInt()} km", + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Done") } + } + } + } +} + +/** Top-down plot of the sweep, coloured by predicted signal. */ +@Composable +private fun CoveragePlot(coverage: Coverage, modifier: Modifier = Modifier) { + val site = coverage.site + val strongest = coverage.points.maxOf { it.rxDbm } + val sensitivity = site.rxSensitivityDbm + + Box(modifier) { + Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) { + val half = size.minDimension / 2f + val scale = half / site.radiusKm.toFloat() + drawCircle(Color(0xFF14151C), radius = half, center = center) + + coverage.points.forEach { p -> + val km = haversineKm(site.latitude, site.longitude, p.latitude, p.longitude) + val bearing = atan2( + (p.longitude - site.longitude) * cos(site.latitude * DEG_TO_RAD), + p.latitude - site.latitude, + ) + val x = center.x + (sin(bearing) * km).toFloat() * scale + val y = center.y - (cos(bearing) * km).toFloat() * scale + drawCircle(signalColor(p.rxDbm, sensitivity, strongest), radius = DOT_RADIUS, center = Offset(x, y)) + } + drawCircle(Color(0xFF67EA94), radius = TX_RADIUS, center = center) + } + } } + +private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Color { + if (dbm < sensitivity) return Color(0xFF22232C) + val t = ((dbm - sensitivity) / (strongest - sensitivity)).coerceIn(0.0, 1.0).toFloat() + val r = if (t < HALF) 1f else (1f - (t - HALF) * 2f).coerceIn(0f, 1f) + val g = if (t < HALF) (t * 2f).coerceIn(0f, 1f) else 1f + return Color(r, g, GREEN_FLOOR) +} + +private const val RADIALS = 90 +private const val RECEIVERS = 34 +private const val MILLIWATTS_PER_WATT = 1000.0 +private const val PERCENT = 100 +private const val DEG_TO_RAD = 0.017453292519943295 +private const val DOT_RADIUS = 2.5f +private const val TX_RADIUS = 5f +private const val HALF = 0.5f +private const val GREEN_FLOOR = 0.24f diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index f83f603d88a..0b04dfb8db8 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -52,36 +52,65 @@ class LocalCoverage( * @param radials how many bearings to sweep; the planner's own sweep uses one per perimeter pixel * @param samplesPerRadial profile points along each radial, including the transmitter */ - suspend fun sweep(site: Site, radials: Int = DEFAULT_RADIALS, samplesPerRadial: Int = DEFAULT_SAMPLES): Coverage { + /** + * Sweep radials out from [site] and return received signal strength at every sample point. + * + * Terrain profile resolution and receiver spacing are deliberately **decoupled**. P.1812 + * evaluates diffraction across the whole profile between transmitter and receiver, so the + * profile has to be sampled near the terrain's own resolution — Mapterhorn at z11 is ~75 m/px. + * Sampling it at the receiver spacing instead (900 m over a 30 km radius) makes the model see a + * jagged, aliased profile and invent diffraction loss that is not there: visible as spurious + * rings and spokes in the plot, and a badly depressed reachable fraction. + * + * The expensive part is the number of [P1812.predict] calls, not the profile length, so a dense + * profile with sparse receivers is both more accurate and no more costly. + * + * @param site the transmitter + * @param radials how many bearings to sweep + * @param receiversPerRadial how many receiver positions to evaluate along each radial + * @param profileStepKm spacing of the terrain profile itself + */ + suspend fun sweep( + site: Site, + radials: Int = DEFAULT_RADIALS, + receiversPerRadial: Int = DEFAULT_RECEIVERS, + profileStepKm: Double = DEFAULT_PROFILE_STEP_KM, + ): Coverage { require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" } - require(samplesPerRadial >= MIN_SAMPLES) { "samplesPerRadial must be >= $MIN_SAMPLES, got $samplesPerRadial" } + require(receiversPerRadial >= MIN_RECEIVERS) { + "receiversPerRadial must be >= $MIN_RECEIVERS, got $receiversPerRadial" + } + require(profileStepKm > 0) { "profileStepKm must be > 0, got $profileStepKm" } + + // Profile points, at terrain resolution. + val profilePoints = maxOf((site.radiusKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS) + val stepKm = site.radiusKm / (profilePoints - 1) + // Which profile indices carry a receiver. Never the first two: P.1812 needs interior points. + val firstReceiver = MIN_PROFILE_POINTS - 1 + val receiverStride = maxOf((profilePoints - firstReceiver) / receiversPerRadial, 1) - val points = ArrayList(radials * samplesPerRadial) - val stepKm = site.radiusKm / (samplesPerRadial - 1) + val points = ArrayList(radials * receiversPerRadial) for (i in 0 until radials) { val bearing = 360.0 * i / radials - // One profile per radial, reused for every receiver position along it: the profile to a - // point 5 km out is the prefix of the profile to a point 20 km out. - val lats = DoubleArray(samplesPerRadial) - val lons = DoubleArray(samplesPerRadial) - val heights = DoubleArray(samplesPerRadial) - for (s in 0 until samplesPerRadial) { + val lats = DoubleArray(profilePoints) + val lons = DoubleArray(profilePoints) + val heights = DoubleArray(profilePoints) + for (s in 0 until profilePoints) { val (lat, lon) = destination(site.latitude, site.longitude, bearing, stepKm * s) lats[s] = lat lons[s] = lon heights[s] = elevation.elevationMeters(lat, lon) } - // Skip the first few samples: P.1812 needs a profile with interior points, and a - // receiver on top of the transmitter is not a useful prediction anyway. - for (end in MIN_PROFILE_POINTS - 1 until samplesPerRadial) { + var end = firstReceiver + while (end < profilePoints) { val n = end + 1 val d = DoubleArray(n) { stepKm * it } val h = DoubleArray(n) { heights[it] } val prediction = P1812.predict( path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), - frequencyGhz = site.frequencyMhz / 1000.0, + frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, txHeightM = site.txHeightM, rxHeightM = site.rxHeightM, timePercent = site.timePercent, @@ -96,6 +125,7 @@ class LocalCoverage( rxGainDbi = site.rxGainDbi, ).value points.add(CoveragePoint(lats[end], lons[end], rxDbm)) + end += receiverStride } } return Coverage(site, points) @@ -103,10 +133,13 @@ class LocalCoverage( private companion object { const val DEFAULT_RADIALS = 180 - const val DEFAULT_SAMPLES = 60 + const val DEFAULT_RECEIVERS = 40 + /** ~100 m, close to Mapterhorn z11's ~75 m/px. */ + const val DEFAULT_PROFILE_STEP_KM = 0.1 const val MIN_RADIALS = 4 - const val MIN_SAMPLES = 4 + const val MIN_RECEIVERS = 2 const val MIN_PROFILE_POINTS = 3 + const val MHZ_PER_GHZ = 1000.0 const val INLAND = 4 } } @@ -153,7 +186,7 @@ data class Coverage(val site: Site, val points: List) { private const val EARTH_RADIUS_KM = 6371.0 /** Great-circle destination from a start point along [bearingDeg] for [distanceKm]. */ -internal fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, distanceKm: Double): Pair { +fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, distanceKm: Double): Pair { val ang = distanceKm / EARTH_RADIUS_KM val lat1 = latDeg.toRadians() val lon1 = lonDeg.toRadians() @@ -163,8 +196,8 @@ internal fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, dis return lat2.toDegrees() to lon2.toDegrees() } -/** Great-circle distance between two points, km. */ -internal fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { +/** Great-circle distance between two points, km. Public: a consumer plotting a [Coverage] needs it. */ +fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val dLat = (lat2 - lat1).toRadians() val dLon = (lon2 - lon1).toRadians() val a = sin(dLat / 2) * sin(dLat / 2) + diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt index db775956684..ff013eefb31 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -35,7 +35,7 @@ class LocalCoverageTest { @Test fun signalFallsOffWithDistance() = runTest { - val coverage = LocalCoverage(flat).sweep(site(), radials = 4, samplesPerRadial = 20) + val coverage = LocalCoverage(flat).sweep(site(), radials = 4, receiversPerRadial = 20) // Take one radial's worth of points and check monotone decay over flat ground. val radial = coverage.points.take(18) val distances = radial.map { haversineKm(47.6, -122.2, it.latitude, it.longitude) } @@ -55,7 +55,7 @@ class LocalCoverageTest { val northKm = (lat - 47.6) * 111.0 if (northKm in 4.5..5.5) 400.0 else 0.0 } - val coverage = LocalCoverage(ridged).sweep(site(), radials = 4, samplesPerRadial = 24) + val coverage = LocalCoverage(ridged).sweep(site(), radials = 4, receiversPerRadial = 24) val far = 8.0 fun strengthTowards(bearingIndex: Int): Double { val perRadial = coverage.points.size / 4 @@ -70,8 +70,8 @@ class LocalCoverageTest { @Test fun higherTransmitPowerReachesFurther() = runTest { - val low = LocalCoverage(flat).sweep(site(txPowerDbm = 17.0), radials = 4, samplesPerRadial = 20) - val high = LocalCoverage(flat).sweep(site(txPowerDbm = 30.0), radials = 4, samplesPerRadial = 20) + val low = LocalCoverage(flat).sweep(site(txPowerDbm = 17.0), radials = 4, receiversPerRadial = 20) + val high = LocalCoverage(flat).sweep(site(txPowerDbm = 30.0), radials = 4, receiversPerRadial = 20) assertTrue( high.maxRangeKm >= low.maxRangeKm, "30 dBm (${high.maxRangeKm} km) should reach at least as far as 17 dBm (${low.maxRangeKm} km)", @@ -81,7 +81,7 @@ class LocalCoverageTest { @Test fun reachableRespectsReceiverSensitivity() = runTest { - val coverage = LocalCoverage(flat).sweep(site(), radials = 4, samplesPerRadial = 12) + val coverage = LocalCoverage(flat).sweep(site(), radials = 4, receiversPerRadial = 12) assertTrue(coverage.points.isNotEmpty()) assertTrue(coverage.reachable.all { it.rxDbm >= coverage.site.rxSensitivityDbm }) assertTrue(coverage.points.none { it.rxDbm >= coverage.site.rxSensitivityDbm && it !in coverage.reachable }) @@ -89,9 +89,11 @@ class LocalCoverageTest { @Test fun sweepCoversEveryBearing() = runTest { - val coverage = LocalCoverage(flat).sweep(site(), radials = 8, samplesPerRadial = 6) - // 8 radials x (6 samples - 2 skipped leading points) = 32 - assertEquals(8 * 4, coverage.points.size) + val coverage = LocalCoverage(flat).sweep(site(), radials = 8, receiversPerRadial = 6) + // Receivers are strided along a dense profile, so integer stride rounding can yield a few + // more per radial than requested. What must hold is that every bearing contributes equally. + assertEquals(0, coverage.points.size % 8, "each bearing should contribute the same count") + assertTrue(coverage.points.size >= 8 * 6, "at least the requested receivers per bearing") } @Test diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index ad7118d3cb8..0408c27113f 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -57,7 +57,7 @@ object CoverageDemo { lateinit var coverage: Coverage val ms = measureTimeMillis { coverage = runBlocking { - LocalCoverage(elevation).sweep(site, radials = RADIALS, samplesPerRadial = SAMPLES) + LocalCoverage(elevation).sweep(site, radials = RADIALS, receiversPerRadial = RECEIVERS) } } val reach = coverage.reachable @@ -119,5 +119,5 @@ object CoverageDemo { private const val DEFAULT_LAT = 47.6062 // Seattle — real relief nearby private const val DEFAULT_LON = -122.3321 private const val RADIALS = 120 - private const val SAMPLES = 40 + private const val RECEIVERS = 40 } From 0dda0f4a2eccd6dfe5624580e9356c72527e3bd6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:45:33 -0500 Subject: [PATCH 05/27] feat(coverage): compute on a grid and export filled contour bands Points were the wrong geometry. Exported as GeoJSON Point features they get clustered by the map and drawn as default node markers - marker-color is ignored - so the layer rendered as a swarm of identical blue dots with the sweep's radial structure showing through as rings and spokes. The hosted planner runs marching squares over a dBm grid and exports Polygon iso-bands rendered as fill + line layers. I had read that in its ARCHITECTURE.md earlier and then wrote points anyway, and described them as matching the planner's export, which they did not. So: sweepGrid() computes a regular lat/lon grid, each cell running its own terrain profile at 100 m, and CoverageContours merges in-band cells into MultiPolygon features with simplestyle fill and fill-opacity. Six bands from receiver sensitivity upward, weaker bands larger and fainter underneath. Band merging is deliberately a union of cell rectangles rather than a smoothed isoline: at ~100 m cells it reads as solid coverage and it avoids the ambiguous-saddle handling a real isoline tracer needs. Seattle demo: 4,898 cells in 7.0 s, 82% reachable, -148.1 to -52.6 dBm, six bands with 269 rings at the weakest threshold down to 5 at the strongest. --- .../desktop/map/DesktopSitePlannerSlot.kt | 51 ++++--- .../feature/coverage/CoverageContours.kt | 133 ++++++++++++++++++ .../feature/coverage/CoverageGrid.kt | 116 +++++++++++++++ .../feature/coverage/LocalCoverage.kt | 50 +++++++ .../feature/coverage/CoverageDemo.kt | 49 +++---- 5 files changed, 344 insertions(+), 55 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 151d836f3dc..89c1976e368 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -48,11 +48,11 @@ import kotlinx.coroutines.withContext import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position -import org.meshtastic.feature.coverage.Coverage +import org.meshtastic.feature.coverage.CoverageGrid import org.meshtastic.feature.coverage.LocalCoverage import org.meshtastic.feature.coverage.MapterhornElevation import org.meshtastic.feature.coverage.Site -import org.meshtastic.feature.coverage.haversineKm +import org.meshtastic.feature.coverage.sweepGrid import org.meshtastic.feature.coverage.toGeoJson import org.meshtastic.feature.map.SharedMapViewModel import org.meshtastic.feature.map.layers.MapLayersManager @@ -94,7 +94,7 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { var params by remember(subject) { mutableStateOf(subject.toSitePlannerParams(channelSet)) } var running by remember { mutableStateOf(null) } - var result by remember { mutableStateOf(null) } + var result by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } val current = running @@ -115,7 +115,7 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { runCatching { withContext(Dispatchers.Default) { MapterhornElevation().use { elevation -> - LocalCoverage(elevation).sweep(current.toSite(), radials = RADIALS, receiversPerRadial = RECEIVERS) + LocalCoverage(elevation).sweepGrid(current.toSite(), resolution = GRID) } } } @@ -185,7 +185,7 @@ private fun ComputingDialog(name: String, onCancel: () -> Unit) { } @Composable -private fun CoverageResultDialog(coverage: Coverage, onDismiss: () -> Unit) { +private fun CoverageResultDialog(coverage: CoverageGrid, onDismiss: () -> Unit) { Dialog(onDismissRequest = onDismiss) { Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { Column( @@ -201,7 +201,7 @@ private fun CoverageResultDialog(coverage: Coverage, onDismiss: () -> Unit) { ) CoveragePlot(coverage, modifier = Modifier.fillMaxWidth().aspectRatio(1f)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text("${coverage.points.size} points", style = MaterialTheme.typography.bodySmall) + Text("${coverage.width}×${coverage.height} grid", style = MaterialTheme.typography.bodySmall) Text( "${(coverage.reachableFraction * PERCENT).roundToInt()}% reachable", style = MaterialTheme.typography.bodySmall, @@ -217,30 +217,28 @@ private fun CoverageResultDialog(coverage: Coverage, onDismiss: () -> Unit) { } } -/** Top-down plot of the sweep, coloured by predicted signal. */ +/** Top-down preview of the grid, coloured by predicted signal. */ @Composable -private fun CoveragePlot(coverage: Coverage, modifier: Modifier = Modifier) { - val site = coverage.site - val strongest = coverage.points.maxOf { it.rxDbm } - val sensitivity = site.rxSensitivityDbm +private fun CoveragePlot(coverage: CoverageGrid, modifier: Modifier = Modifier) { + val sensitivity = coverage.site.rxSensitivityDbm + val strongest = coverage.dbm.filter { !it.isNaN() }.maxOrNull() ?: sensitivity Box(modifier) { Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) { - val half = size.minDimension / 2f - val scale = half / site.radiusKm.toFloat() - drawCircle(Color(0xFF14151C), radius = half, center = center) - - coverage.points.forEach { p -> - val km = haversineKm(site.latitude, site.longitude, p.latitude, p.longitude) - val bearing = atan2( - (p.longitude - site.longitude) * cos(site.latitude * DEG_TO_RAD), - p.latitude - site.latitude, - ) - val x = center.x + (sin(bearing) * km).toFloat() * scale - val y = center.y - (cos(bearing) * km).toFloat() * scale - drawCircle(signalColor(p.rxDbm, sensitivity, strongest), radius = DOT_RADIUS, center = Offset(x, y)) + drawRect(Color(0xFF14151C)) + val cw = size.width / coverage.width + val ch = size.height / coverage.height + for (y in 0 until coverage.height) { + for (x in 0 until coverage.width) { + val v = coverage.at(x, y) + if (v.isNaN() || v < sensitivity) continue + drawRect( + signalColor(v, sensitivity, strongest), + topLeft = Offset(x * cw, y * ch), + size = androidx.compose.ui.geometry.Size(cw + 1, ch + 1), + ) + } } - drawCircle(Color(0xFF67EA94), radius = TX_RADIUS, center = center) } } } @@ -253,8 +251,7 @@ private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Co return Color(r, g, GREEN_FLOOR) } -private const val RADIALS = 90 -private const val RECEIVERS = 34 +private const val GRID = 80 private const val MILLIWATTS_PER_WATT = 1000.0 private const val PERCENT = 100 private const val DEG_TO_RAD = 0.017453292519943295 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt new file mode 100644 index 00000000000..3a0c30ee08f --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +/** + * Turns a [CoverageGrid] into filled iso-bands — "signal ≥ X dBm" polygons — as GeoJSON. + * + * This is the shape a map can actually draw coverage with, and the shape the hosted Site Planner + * exports. Emitting the sample points instead produces a swarm of markers that the map clusters, + * which is what the first version did. + * + * Marching squares, tracing each band's boundary cell by cell. Deliberately simple: cells at or + * above the threshold contribute their own square to the band, so the result is a union of cell + * rectangles rather than a smoothed isoline. At ~100 m cells that reads as a solid coverage area, + * and it avoids the ambiguous-saddle handling a true isoline tracer needs. + */ +internal fun CoverageGrid.bands(thresholdsDbm: List): List = + thresholdsDbm.sorted().map { threshold -> + val rings = mutableListOf>>() + // Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than + // one per cell, and the map renders a handful of rings rather than thousands. + val taken = BooleanArray(width * height) + for (y in 0 until height - 1) { + var x = 0 + while (x < width - 1) { + if (taken[y * width + x] || !inBand(x, y, threshold)) { + x++ + continue + } + var runEnd = x + while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, threshold)) { + runEnd++ + } + // Extend downwards while the whole run stays in band. + var runBottom = y + while (runBottom + 1 < height - 1 && (x..runEnd).all { inBand(it, runBottom + 1, threshold) } && + (x..runEnd).none { taken[(runBottom + 1) * width + it] } + ) { + runBottom++ + } + for (yy in y..runBottom) { + for (xx in x..runEnd) taken[yy * width + xx] = true + } + rings.add( + listOf( + lonAt(x.toDouble()) to latAt(y.toDouble()), + lonAt(runEnd + 1.0) to latAt(y.toDouble()), + lonAt(runEnd + 1.0) to latAt(runBottom + 1.0), + lonAt(x.toDouble()) to latAt(runBottom + 1.0), + lonAt(x.toDouble()) to latAt(y.toDouble()), + ), + ) + x = runEnd + 1 + } + } + CoverageBand(threshold, rings) + }.filter { it.rings.isNotEmpty() } + +private fun CoverageGrid.inBand(x: Int, y: Int, threshold: Double): Boolean { + val v = at(x, y) + return !v.isNaN() && v >= threshold +} + +/** One iso-band: every cell at or above [thresholdDbm]. */ +internal class CoverageBand(val thresholdDbm: Double, val rings: List>>) + +/** + * The coverage as GeoJSON polygons with simplestyle-spec fills — what the map draws as a layer. + * + * Bands run from the receiver's sensitivity upward, so the outermost polygon is "a node here can + * hear this site at all" and the inner ones are progressively stronger signal. + */ +fun CoverageGrid.toGeoJson(bandCount: Int = DEFAULT_BANDS): String { + val floor = site.rxSensitivityDbm + val ceiling = dbm.filter { !it.isNaN() }.maxOrNull() ?: floor + if (ceiling <= floor) return EMPTY_FEATURE_COLLECTION + + val step = (ceiling - floor) / bandCount + val thresholds = (0 until bandCount).map { floor + step * it } + + val features = bands(thresholds).mapIndexed { index, band -> + val t = index.toDouble() / (bandCount - 1).coerceAtLeast(1) + val color = bandColor(t) + val rings = band.rings.joinToString(",") { ring -> + "[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]" + } + """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$rings]},""" + + """"properties":{"title":"≥ ${band.thresholdDbm.toFixed1()} dBm",""" + + """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color","fill-opacity":${bandOpacity(t)},""" + + """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}""" + }.joinToString(",\n") + + return """{ + "type": "FeatureCollection", + "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812"}, + "features": [ +$features + ] +} +""" +} + +/** Weakest band red, through amber, to Meshtastic green at the strongest. */ +private fun bandColor(t: Double): String = when { + t < ONE_THIRD -> "#ef476f" + t < TWO_THIRDS -> "#ffd166" + else -> "#67ea94" +} + +/** Weaker bands are larger and sit underneath, so they stay faint. */ +private fun bandOpacity(t: Double): String = (BASE_OPACITY + t * OPACITY_RANGE).toFixed1() + +private const val DEFAULT_BANDS = 6 +private const val ONE_THIRD = 0.34 +private const val TWO_THIRDS = 0.67 +private const val BASE_OPACITY = 0.15 +private const val OPACITY_RANGE = 0.45 +private const val EMPTY_FEATURE_COLLECTION = + """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}""" diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt new file mode 100644 index 00000000000..4162283e40d --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlin.math.cos + +/** + * Received signal strength on a regular lat/lon grid. + * + * A grid, not the polar sweep the first version produced, because coverage has to be drawn as + * **filled iso-bands** — the hosted planner runs marching squares over exactly this shape. Polar + * samples exported as GeoJSON `Point` features get clustered by the map and render as a swarm of + * identical node markers, which is what they are. + * + * `NaN` marks a cell that was not computed. + */ +class CoverageGrid( + val site: Site, + val width: Int, + val height: Int, + val north: Double, + val south: Double, + val east: Double, + val west: Double, + val dbm: DoubleArray, +) { + init { + require(dbm.size == width * height) { "dbm must be width*height, got ${dbm.size} for $width x $height" } + } + + fun at(x: Int, y: Int): Double = dbm[y * width + x] + + /** Longitude of grid column [x]. */ + fun lonAt(x: Double): Double = west + (east - west) * x / (width - 1) + + /** Latitude of grid row [y]; row 0 is north. */ + fun latAt(y: Double): Double = north - (north - south) * y / (height - 1) + + /** Fraction of computed cells at or above the receiver's sensitivity. */ + val reachableFraction: Double + get() { + var computed = 0 + var reachable = 0 + for (v in dbm) { + if (v.isNaN()) continue + computed++ + if (v >= site.rxSensitivityDbm) reachable++ + } + return if (computed == 0) 0.0 else reachable.toDouble() / computed + } + + /** Greatest distance from the site at which any cell is reachable, km. */ + val maxRangeKm: Double + get() { + var best = 0.0 + for (y in 0 until height) { + for (x in 0 until width) { + val v = at(x, y) + if (v.isNaN() || v < site.rxSensitivityDbm) continue + val km = haversineKm(site.latitude, site.longitude, latAt(y.toDouble()), lonAt(x.toDouble())) + if (km > best) best = km + } + } + return best + } +} + +/** Compute coverage onto a regular grid centred on [site]. */ +suspend fun LocalCoverage.sweepGrid( + site: Site, + resolution: Int = DEFAULT_GRID, + profileStepKm: Double = 0.1, +): CoverageGrid { + require(resolution >= MIN_GRID) { "resolution must be >= $MIN_GRID, got $resolution" } + + // A degree of longitude shrinks with latitude; keep the box square on the ground. + val latSpanDeg = site.radiusKm / KM_PER_DEG_LAT + val lonSpanDeg = latSpanDeg / cos(site.latitude * DEG_TO_RAD) + val north = site.latitude + latSpanDeg + val south = site.latitude - latSpanDeg + val east = site.longitude + lonSpanDeg + val west = site.longitude - lonSpanDeg + + val out = DoubleArray(resolution * resolution) { Double.NaN } + for (y in 0 until resolution) { + val lat = north - (north - south) * y / (resolution - 1) + for (x in 0 until resolution) { + val lon = west + (east - west) * x / (resolution - 1) + val km = haversineKm(site.latitude, site.longitude, lat, lon) + // Outside the requested radius, and too close to profile at all, stay NaN. + if (km > site.radiusKm || km < profileStepKm * MIN_PROFILE_STEPS) continue + out[y * resolution + x] = predictAt(site, lat, lon, km, profileStepKm) + } + } + return CoverageGrid(site, resolution, resolution, north, south, east, west, out) +} + +internal const val DEFAULT_GRID = 96 +private const val MIN_GRID = 8 +private const val MIN_PROFILE_STEPS = 3 +private const val KM_PER_DEG_LAT = 111.32 +private const val DEG_TO_RAD = 0.017453292519943295 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index 0b04dfb8db8..025aaab17c5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -131,6 +131,46 @@ class LocalCoverage( return Coverage(site, points) } + /** + * Predict received power at one receiver position, building the terrain profile along the way. + * + * The profile is sampled at [profileStepKm] regardless of how far apart receivers are: P.1812 + * integrates diffraction across the whole profile, so sampling it at receiver spacing makes the + * model see an aliased ridge line and invent loss that is not there. + */ + internal suspend fun predictAt( + site: Site, + rxLat: Double, + rxLon: Double, + distanceKm: Double, + profileStepKm: Double, + ): Double { + val n = maxOf((distanceKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS) + val step = distanceKm / (n - 1) + val bearing = bearingDeg(site.latitude, site.longitude, rxLat, rxLon) + val d = DoubleArray(n) { step * it } + val h = DoubleArray(n) { i -> + val (lat, lon) = destination(site.latitude, site.longitude, bearing, step * i) + elevation.elevationMeters(lat, lon) + } + val prediction = P1812.predict( + path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), + frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, + txHeightM = site.txHeightM, + rxHeightM = site.rxHeightM, + timePercent = site.timePercent, + pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, + polarization = Polarization.VERTICAL, + atmosphere = atmosphere, + ) + return P1812.receivedPower( + prediction, + txPowerDbm = site.txPowerDbm, + txGainDbi = site.txGainDbi, + rxGainDbi = site.rxGainDbi, + ).value + } + private companion object { const val DEFAULT_RADIALS = 180 const val DEFAULT_RECEIVERS = 40 @@ -196,6 +236,16 @@ fun destination(latDeg: Double, lonDeg: Double, bearingDeg: Double, distanceKm: return lat2.toDegrees() to lon2.toDegrees() } +/** Initial great-circle bearing from one point to another, degrees. */ +fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val p1 = lat1.toRadians() + val p2 = lat2.toRadians() + val dl = (lon2 - lon1).toRadians() + val y = sin(dl) * cos(p2) + val x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl) + return (atan2(y, x).toDegrees() + 360.0) % 360.0 +} + /** Great-circle distance between two points, km. Public: a consumer plotting a [Coverage] needs it. */ fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val dLat = (lat2 - lat1).toRadians() diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 0408c27113f..77db4e495dc 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -54,18 +54,18 @@ object CoverageDemo { println("site ${site.latitude}, ${site.longitude} ${site.frequencyMhz} MHz ${site.txPowerDbm} dBm") MapterhornElevation().use { elevation -> - lateinit var coverage: Coverage + lateinit var coverage: CoverageGrid val ms = measureTimeMillis { coverage = runBlocking { - LocalCoverage(elevation).sweep(site, radials = RADIALS, receiversPerRadial = RECEIVERS) + LocalCoverage(elevation).sweepGrid(site, resolution = GRID) } } - val reach = coverage.reachable - println("computed ${coverage.points.size} points in ${ms}ms from ${elevation.tilesFetched} terrain tiles") - println("reachable: ${reach.size} (${(coverage.reachableFraction * 100).roundToInt()}%)") + val computed = coverage.dbm.count { !it.isNaN() } + println("computed $computed grid cells in ${ms}ms from ${elevation.tilesFetched} terrain tiles") + println("reachable: ${(coverage.reachableFraction * 100).roundToInt()}%") println("max range: ${"%.1f".format(coverage.maxRangeKm)} km") - println("rx dBm range: ${"%.1f".format(coverage.points.minOf { it.rxDbm })} .. " + - "${"%.1f".format(coverage.points.maxOf { it.rxDbm })}") + val finite = coverage.dbm.filter { !it.isNaN() } + println("rx dBm range: ${"%.1f".format(finite.min())} .. ${"%.1f".format(finite.max())}") File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) renderPng(coverage, File(outDir, "coverage.png")) @@ -73,33 +73,27 @@ object CoverageDemo { } } - /** Plot the sweep as a top-down image, coloured by signal strength. */ + /** Plot the grid as a top-down image, coloured by signal strength. */ @Suppress("MagicNumber") - private fun renderPng(coverage: Coverage, dest: File) { + private fun renderPng(coverage: CoverageGrid, dest: File) { val size = 700 val img = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB) val g = img.createGraphics() g.color = java.awt.Color(0x14, 0x15, 0x1c) g.fillRect(0, 0, size, size) - val site = coverage.site - val scale = (size / 2.0) / site.radiusKm - val sensitivity = site.rxSensitivityDbm - val strongest = coverage.points.maxOf { it.rxDbm } - - for (p in coverage.points) { - val km = haversineKm(site.latitude, site.longitude, p.latitude, p.longitude) - val bearing = kotlin.math.atan2( - (p.longitude - site.longitude) * kotlin.math.cos(site.latitude * Math.PI / 180), - p.latitude - site.latitude, - ) - val px = (size / 2 + kotlin.math.sin(bearing) * km * scale).toInt() - val py = (size / 2 - kotlin.math.cos(bearing) * km * scale).toInt() - if (px !in 0 until size || py !in 0 until size) continue - g.color = colorFor(p.rxDbm, sensitivity, strongest) - g.fillOval(px - 3, py - 3, 6, 6) + val sensitivity = coverage.site.rxSensitivityDbm + val strongest = coverage.dbm.filter { !it.isNaN() }.maxOrNull() ?: sensitivity + val cw = size.toDouble() / coverage.width + val ch = size.toDouble() / coverage.height + for (y in 0 until coverage.height) { + for (x in 0 until coverage.width) { + val v = coverage.at(x, y) + if (v.isNaN() || v < sensitivity) continue + g.color = colorFor(v, sensitivity, strongest) + g.fillRect((x * cw).toInt(), (y * ch).toInt(), (cw + 1).toInt(), (ch + 1).toInt()) + } } - // transmitter g.color = java.awt.Color(0x67, 0xEA, 0x94) g.fillOval(size / 2 - 5, size / 2 - 5, 10, 10) g.dispose() @@ -118,6 +112,5 @@ object CoverageDemo { private const val DEFAULT_LAT = 47.6062 // Seattle — real relief nearby private const val DEFAULT_LON = -122.3321 - private const val RADIALS = 120 - private const val RECEIVERS = 40 + private const val GRID = 80 } From 548894a39b87e308956922b30469024f220bd054 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:51:09 -0500 Subject: [PATCH 06/27] fix(coverage): MultiPolygon coordinates were nested one level short GeoJSON MultiPolygon is coordinates[polygon][ring][position]. The export emitted coordinates[ring][position], which is a Polygon-with-holes wearing a MultiPolygon label. MapLibre drops that without complaint, so the layer was created and persisted and nothing drew - while the result dialog looked fine, because it renders the grid directly rather than the GeoJSON. The earlier 'validation' counted rings per feature and reported six bands nesting nicely, which the malformed structure satisfies just as well. It now asserts the actual shape: every position a two-element numeric pair, every ring closed and at least four positions long. --- .../org/meshtastic/feature/coverage/CoverageContours.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 3a0c30ee08f..2bf88efe4de 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -95,10 +95,13 @@ fun CoverageGrid.toGeoJson(bandCount: Int = DEFAULT_BANDS): String { val features = bands(thresholds).mapIndexed { index, band -> val t = index.toDouble() / (bandCount - 1).coerceAtLeast(1) val color = bandColor(t) - val rings = band.rings.joinToString(",") { ring -> - "[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]" + // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings + // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre + // silently drops - the layer is added and nothing draws. + val polygons = band.rings.joinToString(",") { ring -> + "[[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]]" } - """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$rings]},""" + + """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$polygons]},""" + """"properties":{"title":"≥ ${band.thresholdDbm.toFixed1()} dBm",""" + """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color","fill-opacity":${bandOpacity(t)},""" + """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}""" From 47630651bd2d80a4e17b3a6f5950d59b83c2d52a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:33:02 -0500 Subject: [PATCH 07/27] perf(coverage): sweep in polar, prefetch terrain, 10x the resolution The grid sweep walked an independent terrain profile per cell, so resolution was bounded by the prediction budget and every cell re-read the same terrain. Coverage is now computed on a polar lattice - one profile per bearing, every ring on it a prefix of that walk - and resampled onto the grid, which costs no predictions at all. Defaults go from an 80x80 grid to 256x256 over a 720x128 lattice, matched so radials and rings are each about one grid cell apart. Measuring where the time went showed it was not the model: dropping the prediction count 9x left the total unchanged. The archive is read over HTTP range requests through a single seekable channel, so on-demand lookups queued one behind another at ~300ms each. Terrain is now prefetched for the whole disc through a pool of readers - opened concurrently, because built serially the pool's own setup grew linearly with its size and ate the speed-up - and the cache holds the in-flight fetch rather than the result, so concurrent radials share one download. Sampling reads a published snapshot without the mutex. Seattle, 25km radius, 10 cores: was 4,898 cells in ~7s at z11 terrain. Now 51,198 cells in 4.3s of which 0.2s is the sweep, at z12 - the global archive's own maximum, four times the detail for the same tile count. A second sweep with terrain already decoded takes 190ms, so the desktop planner holds its elevation source across estimates instead of rebuilding it per run. Also drops Coverage.toGeoJson: CoverageGrid.toGeoJson replaced it, and the point export is what the map clustered into marker bubbles. --- .../desktop/map/DesktopSitePlannerSlot.kt | 85 +++--- .../feature/coverage/CoverageContours.kt | 81 ++++-- .../feature/coverage/CoverageGeoJson.kt | 44 ---- .../feature/coverage/CoverageGrid.kt | 50 +--- .../feature/coverage/LocalCoverage.kt | 166 ++++++------ .../feature/coverage/MapterhornElevation.kt | 242 +++++++++++++++--- .../feature/coverage/PolarCoverage.kt | 183 +++++++++++++ .../feature/coverage/LocalCoverageTest.kt | 33 ++- .../feature/coverage/PolarCoverageTest.kt | 115 +++++++++ .../feature/coverage/CoverageDemo.kt | 61 +++-- 10 files changed, 775 insertions(+), 285 deletions(-) delete mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 89c1976e368..0623a72190a 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -55,30 +56,26 @@ import org.meshtastic.feature.coverage.Site import org.meshtastic.feature.coverage.sweepGrid import org.meshtastic.feature.coverage.toGeoJson import org.meshtastic.feature.map.SharedMapViewModel -import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.component.SitePlannerParams import org.meshtastic.feature.map.component.SitePlannerSheet import org.meshtastic.feature.map.component.toSitePlannerParams +import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.maplibre.SitePlannerSession -import kotlin.math.atan2 -import kotlin.math.cos import kotlin.math.log10 import kotlin.math.roundToInt -import kotlin.math.sin /** * Site Planner on the desktop — computed **in this app**, not in a browser. * - * Previously this opened site.meshtastic.org in the system browser and asked the user to export a - * `.geojson` and re-import it by hand: desktop has no embedded browser, and putting JCEF back into - * the jlink'd runtime measured at roughly three and a half times the size of the whole application. + * Previously this opened site.meshtastic.org in the system browser and asked the user to export a `.geojson` and + * re-import it by hand: desktop has no embedded browser, and putting JCEF back into the jlink'd runtime measured at + * roughly three and a half times the size of the whole application. * - * Now `feature:coverage` runs ITU-R P.1812 (`org.meshtastic:kp1812`) against the same Mapterhorn - * elevation the map already uses for hillshade and contours. No network call to the planner, no - * WebView, no export/re-import round trip. + * Now `feature:coverage` runs ITU-R P.1812 (`org.meshtastic:kp1812`) against the same Mapterhorn elevation the map + * already uses for hillshade and contours. No network call to the planner, no WebView, no export/re-import round trip. * - * Note this is a different propagation model from the hosted planner's SPLAT!/ITM, so predictions - * will not match it pixel for pixel. + * Note this is a different propagation model from the hosted planner's SPLAT!/ITM, so predictions will not match it + * pixel for pixel. */ @Composable fun DesktopSitePlannerSlot(session: SitePlannerSession) { @@ -96,15 +93,20 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { var running by remember { mutableStateOf(null) } var result by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } + // Held across estimates: decoded terrain is the whole cost of a sweep, so the second estimate + // anywhere near the first is near-instant. Opened lazily - the constructor does network I/O. + var elevation by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { elevation?.close() } } val current = running val coverage = result when { - coverage != null -> CoverageResultDialog(coverage) { - result = null - session.onDismiss() - } + coverage != null -> + CoverageResultDialog(coverage) { + result = null + session.onDismiss() + } current != null -> { ComputingDialog(current.name) { @@ -114,9 +116,10 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { LaunchedEffect(current) { runCatching { withContext(Dispatchers.Default) { - MapterhornElevation().use { elevation -> - LocalCoverage(elevation).sweepGrid(current.toSite(), resolution = GRID) - } + val source = elevation ?: MapterhornElevation().also { elevation = it } + // Terrain first, pooled: fetched on demand it dominates the sweep entirely. + source.prefetch(current.toSite()) + LocalCoverage(source).sweepGrid(current.toSite(), resolution = GRID) } } .onSuccess { swept -> @@ -132,22 +135,27 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { } } - else -> SitePlannerSheet( - initial = params, - onSubmit = { submitted -> - params = submitted - running = submitted - }, - onDismiss = session.onDismiss, - note = failure?.let { "Coverage failed: $it" } - ?: "Computed on this device with ITU-R P.1812 — no browser, works offline once terrain is cached.", - onUseNodeLocation = subject?.takeIf { it.validPosition != null }?.let { node -> - { params = params.copy(latitude = node.latitude, longitude = node.longitude) } - }, - onUseMapCenter = { - session.mapCenter().let { params = params.copy(latitude = it.latitude, longitude = it.longitude) } - }, - ) + else -> + SitePlannerSheet( + initial = params, + onSubmit = { submitted -> + params = submitted + running = submitted + }, + onDismiss = session.onDismiss, + note = + failure?.let { "Coverage failed: $it" } + ?: "Computed on this device with ITU-R P.1812 — no browser, works offline once terrain is cached.", + onUseNodeLocation = + subject + ?.takeIf { it.validPosition != null } + ?.let { node -> + { params = params.copy(latitude = node.latitude, longitude = node.longitude) } + }, + onUseMapCenter = { + session.mapCenter().let { params = params.copy(latitude = it.latitude, longitude = it.longitude) } + }, + ) } } @@ -206,10 +214,7 @@ private fun CoverageResultDialog(coverage: CoverageGrid, onDismiss: () -> Unit) "${(coverage.reachableFraction * PERCENT).roundToInt()}% reachable", style = MaterialTheme.typography.bodySmall, ) - Text( - "max ${coverage.maxRangeKm.roundToInt()} km", - style = MaterialTheme.typography.bodySmall, - ) + Text("max ${coverage.maxRangeKm.roundToInt()} km", style = MaterialTheme.typography.bodySmall) } TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Done") } } @@ -251,7 +256,7 @@ private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Co return Color(r, g, GREEN_FLOOR) } -private const val GRID = 80 +private const val GRID = 256 private const val MILLIWATTS_PER_WATT = 1000.0 private const val PERCENT = 100 private const val DEG_TO_RAD = 0.017453292519943295 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 2bf88efe4de..386474bbc37 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -16,20 +16,23 @@ */ package org.meshtastic.feature.coverage +import kotlin.math.abs +import kotlin.math.roundToLong + /** * Turns a [CoverageGrid] into filled iso-bands — "signal ≥ X dBm" polygons — as GeoJSON. * - * This is the shape a map can actually draw coverage with, and the shape the hosted Site Planner - * exports. Emitting the sample points instead produces a swarm of markers that the map clusters, - * which is what the first version did. + * This is the shape a map can actually draw coverage with, and the shape the hosted Site Planner exports. Emitting the + * sample points instead produces a swarm of markers that the map clusters, which is what the first version did. * - * Marching squares, tracing each band's boundary cell by cell. Deliberately simple: cells at or - * above the threshold contribute their own square to the band, so the result is a union of cell - * rectangles rather than a smoothed isoline. At ~100 m cells that reads as a solid coverage area, - * and it avoids the ambiguous-saddle handling a true isoline tracer needs. + * Marching squares, tracing each band's boundary cell by cell. Deliberately simple: cells at or above the threshold + * contribute their own square to the band, so the result is a union of cell rectangles rather than a smoothed isoline. + * At ~100 m cells that reads as a solid coverage area, and it avoids the ambiguous-saddle handling a true isoline + * tracer needs. */ -internal fun CoverageGrid.bands(thresholdsDbm: List): List = - thresholdsDbm.sorted().map { threshold -> +internal fun CoverageGrid.bands(thresholdsDbm: List): List = thresholdsDbm + .sorted() + .map { threshold -> val rings = mutableListOf>>() // Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than // one per cell, and the map renders a handful of rings rather than thousands. @@ -42,12 +45,16 @@ internal fun CoverageGrid.bands(thresholdsDbm: List): List continue } var runEnd = x - while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, threshold)) { + while ( + runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, threshold) + ) { runEnd++ } // Extend downwards while the whole run stays in band. var runBottom = y - while (runBottom + 1 < height - 1 && (x..runEnd).all { inBand(it, runBottom + 1, threshold) } && + while ( + runBottom + 1 < height - 1 && + (x..runEnd).all { inBand(it, runBottom + 1, threshold) } && (x..runEnd).none { taken[(runBottom + 1) * width + it] } ) { runBottom++ @@ -68,7 +75,8 @@ internal fun CoverageGrid.bands(thresholdsDbm: List): List } } CoverageBand(threshold, rings) - }.filter { it.rings.isNotEmpty() } + } + .filter { it.rings.isNotEmpty() } private fun CoverageGrid.inBand(x: Int, y: Int, threshold: Double): Boolean { val v = at(x, y) @@ -81,8 +89,8 @@ internal class CoverageBand(val thresholdDbm: Double, val rings: List - val t = index.toDouble() / (bandCount - 1).coerceAtLeast(1) - val color = bandColor(t) - // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings - // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre - // silently drops - the layer is added and nothing draws. - val polygons = band.rings.joinToString(",") { ring -> - "[[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]]" - } - """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$polygons]},""" + - """"properties":{"title":"≥ ${band.thresholdDbm.toFixed1()} dBm",""" + - """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color","fill-opacity":${bandOpacity(t)},""" + - """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}""" - }.joinToString(",\n") + val features = + bands(thresholds) + .mapIndexed { index, band -> + val t = index.toDouble() / (bandCount - 1).coerceAtLeast(1) + val color = bandColor(t) + // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings + // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre + // silently drops - the layer is added and nothing draws. + val polygons = + band.rings.joinToString(",") { ring -> + "[[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]]" + } + """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$polygons]},""" + + """"properties":{"title":"≥ ${band.thresholdDbm.toFixed1()} dBm",""" + + """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color","fill-opacity":${bandOpacity(t)},""" + + """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}""" + } + .joinToString(",\n") return """{ "type": "FeatureCollection", @@ -134,3 +146,16 @@ private const val BASE_OPACITY = 0.15 private const val OPACITY_RANGE = 0.45 private const val EMPTY_FEATURE_COLLECTION = """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}""" + +/** + * One decimal place, without `String.format` — which is JVM-only and does not exist on Kotlin/Native or wasm. Adding + * the native targets is what surfaced that. + */ +internal fun Double.toFixed1(): String { + val scaled = (this * TENTHS).roundToLong() + val sign = if (scaled < 0) "-" else "" + val magnitude = abs(scaled) + return "$sign${magnitude / TENTHS}.${magnitude % TENTHS}" +} + +private const val TENTHS = 10 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt deleted file mode 100644 index a9922082219..00000000000 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGeoJson.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package org.meshtastic.feature.coverage - -import kotlin.math.abs -import kotlin.math.roundToLong - -/** - * The coverage as a GeoJSON FeatureCollection with simplestyle-spec properties — the same shape the - * hosted planner's export produces, so the app's existing import path accepts it unchanged. - */ -fun Coverage.toGeoJson(): String { - val features = reachable.joinToString(",\n") { p -> - val color = when { - p.rxDbm > site.rxSensitivityDbm + 30 -> "#67ea94" - p.rxDbm > site.rxSensitivityDbm + 15 -> "#ffd166" - else -> "#ef476f" - } - """ {"type":"Feature","geometry":{"type":"Point","coordinates":[${p.longitude},${p.latitude}]},""" + - """"properties":{"rx_dbm":${p.rxDbm.toFixed1()},"marker-color":"$color"}}""" - } - return """{ - "type": "FeatureCollection", - "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812"}, - "features": [ -$features - ] -} -""" -} - -/** - * One decimal place, without `String.format` — which is JVM-only and does not exist on - * Kotlin/Native or wasm. Adding the native targets is what surfaced that. - */ -internal fun Double.toFixed1(): String { - val scaled = (this * 10).roundToLong() - val sign = if (scaled < 0) "-" else "" - val magnitude = abs(scaled) - return "$sign${magnitude / 10}.${magnitude % 10}" -} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt index 4162283e40d..f59e4f3f1c2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -16,15 +16,12 @@ */ package org.meshtastic.feature.coverage -import kotlin.math.cos - /** * Received signal strength on a regular lat/lon grid. * - * A grid, not the polar sweep the first version produced, because coverage has to be drawn as - * **filled iso-bands** — the hosted planner runs marching squares over exactly this shape. Polar - * samples exported as GeoJSON `Point` features get clustered by the map and render as a swarm of - * identical node markers, which is what they are. + * A grid, not the polar sweep the first version produced, because coverage has to be drawn as **filled iso-bands** — + * the hosted planner runs marching squares over exactly this shape. Polar samples exported as GeoJSON `Point` features + * get clustered by the map and render as a swarm of identical node markers, which is what they are. * * `NaN` marks a cell that was not computed. */ @@ -79,38 +76,17 @@ class CoverageGrid( } } -/** Compute coverage onto a regular grid centred on [site]. */ +/** + * Compute coverage onto a regular grid centred on [site]. + * + * Swept in polar and resampled: see [sweepPolar]. The grid resolution is therefore free of the prediction budget, so it + * can be fine enough for the contour tracer without costing anything. + */ suspend fun LocalCoverage.sweepGrid( site: Site, resolution: Int = DEFAULT_GRID, - profileStepKm: Double = 0.1, -): CoverageGrid { - require(resolution >= MIN_GRID) { "resolution must be >= $MIN_GRID, got $resolution" } - - // A degree of longitude shrinks with latitude; keep the box square on the ground. - val latSpanDeg = site.radiusKm / KM_PER_DEG_LAT - val lonSpanDeg = latSpanDeg / cos(site.latitude * DEG_TO_RAD) - val north = site.latitude + latSpanDeg - val south = site.latitude - latSpanDeg - val east = site.longitude + lonSpanDeg - val west = site.longitude - lonSpanDeg - - val out = DoubleArray(resolution * resolution) { Double.NaN } - for (y in 0 until resolution) { - val lat = north - (north - south) * y / (resolution - 1) - for (x in 0 until resolution) { - val lon = west + (east - west) * x / (resolution - 1) - val km = haversineKm(site.latitude, site.longitude, lat, lon) - // Outside the requested radius, and too close to profile at all, stay NaN. - if (km > site.radiusKm || km < profileStepKm * MIN_PROFILE_STEPS) continue - out[y * resolution + x] = predictAt(site, lat, lon, km, profileStepKm) - } - } - return CoverageGrid(site, resolution, resolution, north, south, east, west, out) -} + profileStepKm: Double = DEFAULT_PROFILE_STEP_KM, +): CoverageGrid = sweepPolar(site, profileStepKm = profileStepKm).toGrid(resolution) -internal const val DEFAULT_GRID = 96 -private const val MIN_GRID = 8 -private const val MIN_PROFILE_STEPS = 3 -private const val KM_PER_DEG_LAT = 111.32 -private const val DEG_TO_RAD = 0.017453292519943295 +/** 256 cells across the coverage box is ~195 m per cell at a 25 km radius. */ +internal const val DEFAULT_GRID = 256 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index 025aaab17c5..bbdc6f2031e 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -28,42 +28,30 @@ import kotlin.math.max import kotlin.math.sin /** - * Spike: compute RF coverage locally, replacing the headless-WebView hand-off to the hosted - * Site Planner. + * Spike: compute RF coverage locally, replacing the headless-WebView hand-off to the hosted Site Planner. * - * Today `SitePlannerRunner` loads site.meshtastic.org in a hidden WebView, waits up to 45 s for a - * JavaScript bridge to hand back GeoJSON, and needs the network. Desktop cannot even do that — - * it opens a browser and asks the user to export and re-import a file by hand. + * Today `SitePlannerRunner` loads site.meshtastic.org in a hidden WebView, waits up to 45 s for a JavaScript bridge to + * hand back GeoJSON, and needs the network. Desktop cannot even do that — it opens a browser and asks the user to + * export and re-import a file by hand. * - * This computes the same answer in-process from `org.meshtastic:kp1812` and an [ElevationSource], - * so it works offline once terrain is cached and produces a result on every platform the app runs - * on. The propagation model is ITU-R P.1812 rather than the planner's SPLAT!/ITM — a different - * model, so predictions will not match the hosted planner pixel for pixel. + * This computes the same answer in-process from `org.meshtastic:kp1812` and an [ElevationSource], so it works offline + * once terrain is cached and produces a result on every platform the app runs on. The propagation model is ITU-R P.1812 + * rather than the planner's SPLAT!/ITM — a different model, so predictions will not match the hosted planner pixel for + * pixel. */ -class LocalCoverage( - private val elevation: ElevationSource, - private val atmosphere: Atmosphere = Atmosphere(), -) { +class LocalCoverage(private val elevation: ElevationSource, private val atmosphere: Atmosphere = Atmosphere()) { /** * Sweep radials out from [site] and return received signal strength at every sample point. * - * @param site the transmitter - * @param radials how many bearings to sweep; the planner's own sweep uses one per perimeter pixel - * @param samplesPerRadial profile points along each radial, including the transmitter - */ - /** - * Sweep radials out from [site] and return received signal strength at every sample point. - * - * Terrain profile resolution and receiver spacing are deliberately **decoupled**. P.1812 - * evaluates diffraction across the whole profile between transmitter and receiver, so the - * profile has to be sampled near the terrain's own resolution — Mapterhorn at z11 is ~75 m/px. - * Sampling it at the receiver spacing instead (900 m over a 30 km radius) makes the model see a - * jagged, aliased profile and invent diffraction loss that is not there: visible as spurious - * rings and spokes in the plot, and a badly depressed reachable fraction. + * Terrain profile resolution and receiver spacing are deliberately **decoupled**. P.1812 evaluates diffraction + * across the whole profile between transmitter and receiver, so the profile has to be sampled near the terrain's + * own resolution — Mapterhorn at z11 is ~75 m/px. Sampling it at the receiver spacing instead (900 m over a 30 km + * radius) makes the model see a jagged, aliased profile and invent diffraction loss that is not there: visible as + * spurious rings and spokes in the plot, and a badly depressed reachable fraction. * - * The expensive part is the number of [P1812.predict] calls, not the profile length, so a dense - * profile with sparse receivers is both more accurate and no more costly. + * The expensive part is the number of [P1812.predict] calls, not the profile length, so a dense profile with sparse + * receivers is both more accurate and no more costly. * * @param site the transmitter * @param radials how many bearings to sweep @@ -92,7 +80,7 @@ class LocalCoverage( val points = ArrayList(radials * receiversPerRadial) for (i in 0 until radials) { - val bearing = 360.0 * i / radials + val bearing = FULL_CIRCLE_DEG * i / radials val lats = DoubleArray(profilePoints) val lons = DoubleArray(profilePoints) val heights = DoubleArray(profilePoints) @@ -105,26 +93,7 @@ class LocalCoverage( var end = firstReceiver while (end < profilePoints) { - val n = end + 1 - val d = DoubleArray(n) { stepKm * it } - val h = DoubleArray(n) { heights[it] } - val prediction = P1812.predict( - path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), - frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, - txHeightM = site.txHeightM, - rxHeightM = site.rxHeightM, - timePercent = site.timePercent, - pathCentreLatitudeDeg = (site.latitude + lats[end]) / 2.0, - polarization = Polarization.VERTICAL, - atmosphere = atmosphere, - ) - val rxDbm = P1812.receivedPower( - prediction, - txPowerDbm = site.txPowerDbm, - txGainDbi = site.txGainDbi, - rxGainDbi = site.rxGainDbi, - ).value - points.add(CoveragePoint(lats[end], lons[end], rxDbm)) + points.add(CoveragePoint(lats[end], lons[end], predictAlong(site, heights, lats[end], stepKm, end))) end += receiverStride } } @@ -134,9 +103,9 @@ class LocalCoverage( /** * Predict received power at one receiver position, building the terrain profile along the way. * - * The profile is sampled at [profileStepKm] regardless of how far apart receivers are: P.1812 - * integrates diffraction across the whole profile, so sampling it at receiver spacing makes the - * model see an aliased ridge line and invent loss that is not there. + * The profile is sampled at [profileStepKm] regardless of how far apart receivers are: P.1812 integrates + * diffraction across the whole profile, so sampling it at receiver spacing makes the model see an aliased ridge + * line and invent loss that is not there. */ internal suspend fun predictAt( site: Site, @@ -149,31 +118,73 @@ class LocalCoverage( val step = distanceKm / (n - 1) val bearing = bearingDeg(site.latitude, site.longitude, rxLat, rxLon) val d = DoubleArray(n) { step * it } - val h = DoubleArray(n) { i -> - val (lat, lon) = destination(site.latitude, site.longitude, bearing, step * i) - elevation.elevationMeters(lat, lon) - } - val prediction = P1812.predict( - path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), - frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, - txHeightM = site.txHeightM, - rxHeightM = site.rxHeightM, - timePercent = site.timePercent, - pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, - polarization = Polarization.VERTICAL, - atmosphere = atmosphere, + val h = + DoubleArray(n) { i -> + val (lat, lon) = destination(site.latitude, site.longitude, bearing, step * i) + elevation.elevationMeters(lat, lon) + } + val prediction = + P1812.predict( + path = TerrainPath(d, h, DoubleArray(n) { site.clutterHeightM }, IntArray(n) { INLAND }), + frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, + txHeightM = site.txHeightM, + rxHeightM = site.rxHeightM, + timePercent = site.timePercent, + pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, + polarization = Polarization.VERTICAL, + atmosphere = atmosphere, + ) + return P1812.receivedPower( + prediction, + txPowerDbm = site.txPowerDbm, + txGainDbi = site.txGainDbi, + rxGainDbi = site.rxGainDbi, ) + .value + } + + /** Terrain height at one position, through this coverage's elevation source. */ + internal suspend fun elevationAt(latitude: Double, longitude: Double): Double = + elevation.elevationMeters(latitude, longitude) + + /** + * Predict received power at profile index [end] of an already-walked radial. + * + * [heights] is the whole radial; the receiver takes the prefix through [end], which is what lets one walk serve + * every ring on a bearing. + */ + internal fun predictAlong(site: Site, heights: DoubleArray, rxLat: Double, stepKm: Double, end: Int): Double { + val n = end + 1 + val prediction = + P1812.predict( + path = + TerrainPath( + DoubleArray(n) { stepKm * it }, + DoubleArray(n) { heights[it] }, + DoubleArray(n) { site.clutterHeightM }, + IntArray(n) { INLAND }, + ), + frequencyGhz = site.frequencyMhz / MHZ_PER_GHZ, + txHeightM = site.txHeightM, + rxHeightM = site.rxHeightM, + timePercent = site.timePercent, + pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, + polarization = Polarization.VERTICAL, + atmosphere = atmosphere, + ) return P1812.receivedPower( prediction, txPowerDbm = site.txPowerDbm, txGainDbi = site.txGainDbi, rxGainDbi = site.rxGainDbi, - ).value + ) + .value } private companion object { const val DEFAULT_RADIALS = 180 const val DEFAULT_RECEIVERS = 40 + /** ~100 m, close to Mapterhorn z11's ~75 m/px. */ const val DEFAULT_PROFILE_STEP_KM = 0.1 const val MIN_RADIALS = 4 @@ -213,10 +224,12 @@ data class CoveragePoint(val latitude: Double, val longitude: Double, val rxDbm: /** The result of a sweep. */ data class Coverage(val site: Site, val points: List) { /** Points at or above the receiver's sensitivity — where a node would actually hear this site. */ - val reachable: List get() = points.filter { it.rxDbm >= site.rxSensitivityDbm } + val reachable: List + get() = points.filter { it.rxDbm >= site.rxSensitivityDbm } /** Fraction of sampled points that are reachable. */ - val reachableFraction: Double get() = if (points.isEmpty()) 0.0 else reachable.size.toDouble() / points.size + val reachableFraction: Double + get() = if (points.isEmpty()) 0.0 else reachable.size.toDouble() / points.size /** Greatest distance at which any sampled point is reachable, km. */ val maxRangeKm: Double @@ -243,18 +256,23 @@ fun bearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val dl = (lon2 - lon1).toRadians() val y = sin(dl) * cos(p2) val x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl) - return (atan2(y, x).toDegrees() + 360.0) % 360.0 + return (atan2(y, x).toDegrees() + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG } /** Great-circle distance between two points, km. Public: a consumer plotting a [Coverage] needs it. */ fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val dLat = (lat2 - lat1).toRadians() val dLon = (lon2 - lon1).toRadians() - val a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1.toRadians()) * cos(lat2.toRadians()) * sin(dLon / 2) * sin(dLon / 2) - return 2 * EARTH_RADIUS_KM * asin(max(-1.0, kotlin.math.min(1.0, kotlin.math.sqrt(a)))) + val a = + sin(dLat / HALF) * sin(dLat / HALF) + + cos(lat1.toRadians()) * cos(lat2.toRadians()) * sin(dLon / HALF) * sin(dLon / HALF) + return HALF * EARTH_RADIUS_KM * asin(max(-1.0, kotlin.math.min(1.0, kotlin.math.sqrt(a)))) } -private fun Double.toRadians(): Double = this * PI / 180.0 +private fun Double.toRadians(): Double = this * PI / STRAIGHT_ANGLE_DEG + +private fun Double.toDegrees(): Double = this * STRAIGHT_ANGLE_DEG / PI -private fun Double.toDegrees(): Double = this * 180.0 / PI +private const val FULL_CIRCLE_DEG = 360.0 +private const val STRAIGHT_ANGLE_DEG = 180.0 +private const val HALF = 2.0 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index b6a4618d292..1553899aeac 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -16,75 +16,249 @@ */ package org.meshtastic.feature.coverage +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext import org.meshtastic.feature.map.terrain.ElevationTile +import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.terrain.TerrainTileFetcher import org.meshtastic.feature.map.terrain.TerrainTileMath import org.meshtastic.feature.map.terrain.decodeTerrariumTile +import kotlin.concurrent.Volatile import kotlin.math.PI +import kotlin.math.cos import kotlin.math.ln import kotlin.math.tan /** * Real elevation from the same Mapterhorn archives the map already uses for hillshade and contours. * - * Common, not desktop-only: `TerrainTileFetcher` and `decodeTerrariumTile` are already - * expect/actual with android and jvm actuals, so nothing here is platform-specific. The native - * actual of the decoder throws by design (see its doc comment in feature:map-terrain), which is - * the same trade that module already makes. + * Common, not desktop-only: `TerrainTileFetcher` and `decodeTerrariumTile` are already expect/actual with android and + * jvm actuals, so nothing here is platform-specific. The native actual of the decoder throws by design (see its doc + * comment in feature:map-terrain), which is the same trade that module already makes. * - * This is what makes the demo meaningful: over flat synthetic ground a coverage plot is a bullseye - * and proves nothing. Against real terrain the prediction has to show ridges shadowing valleys, - * which is the entire reason the planner exists. + * This is what makes the demo meaningful: over flat synthetic ground a coverage plot is a bullseye and proves nothing. + * Against real terrain the prediction has to show ridges shadowing valleys, which is the entire reason the planner + * exists. * - * Tiles are decoded once and cached in memory — a radial sweep asks for thousands of points that - * land in a handful of tiles. + * Tiles are decoded once and cached in memory — a radial sweep asks for thousands of points that land in a handful of + * tiles. */ -class MapterhornElevation(private val zoom: Int = DEFAULT_ZOOM) : ElevationSource, AutoCloseable { +class MapterhornElevation( + /** + * The area the caller will sample. When it fits inside a single z6 tile, Mapterhorn has a regional archive for it, + * which carries far finer terrain than the global one — z13 is ~19 m per pixel against the global archive's ~75 m + * at z11. + */ + private val bounds: GeoBounds? = null, + zoom: Int? = null, +) : ElevationSource, + AutoCloseable { - private val fetcher = TerrainTileFetcher(MapterhornEndpoints.GLOBAL_PMTILES_URL) - private val cache = HashMap() + private val archiveUrl = bounds?.let { MapterhornEndpoints.regionalUrlFor(it) } + + /** + * Regional where one is published, global otherwise. + * + * Not every z6 tile has a regional archive — most 404 — so a missing one is a normal outcome rather than an error, + * and opening the reader is where that shows up. + */ + private val regional: TerrainTileFetcher? = + archiveUrl?.let { url -> runCatching { TerrainTileFetcher(url) }.getOrNull() } + + private val url = if (regional != null) archiveUrl!! else MapterhornEndpoints.GLOBAL_PMTILES_URL + + private val fetcher = regional ?: TerrainTileFetcher(url) + + /** True when this is drawing on a regional archive rather than the coarse global one. */ + val isRegional: Boolean = regional != null + + private val zoom = zoom ?: if (isRegional) DEFAULT_REGIONAL_ZOOM else DEFAULT_GLOBAL_ZOOM + + /** The zoom actually in use, for reporting. */ + val zoomLevel: Int + get() = zoom + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Cached as the in-flight fetch, not the result, so concurrent radials landing on the same tile + // await one download instead of starting one each. + private val cache = HashMap>() private val lock = Mutex() + // A read-only snapshot of what has already decoded. Sampling is overwhelmingly cache hits, and + // taking the mutex for each would serialise a parallel sweep on the one lock. + @Volatile private var resolved: Map = emptyMap() + override suspend fun elevationMeters(latitude: Double, longitude: Double): Double { val tile = TerrainTileMath.tileAt(zoom, latitude, longitude) - val key = (tile.x.toLong() shl 32) or tile.y.toLong() - val decoded = lock.withLock { - if (cache.containsKey(key)) { - cache[key] - } else { - // Dispatchers.IO is JVM/Android-only; Default keeps this source common. - val bytes = withContext(Dispatchers.Default) { fetcher.fetchTile(zoom, tile.x, tile.y) } - val t = bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } - cache[key] = t - t - } - } ?: return 0.0 // ocean, or outside coverage — sea level, as the planner also assumes + val key = tileKey(tile.x, tile.y) + val snapshot = resolved + // Ocean, or outside the archive, decodes to null — sea level, as the planner assumes too. + val decoded = (if (snapshot.containsKey(key)) snapshot[key] else awaitTile(key, tile.x, tile.y)) ?: return 0.0 // Fractional position of this coordinate within its tile. val n = 1 shl zoom - val fx = (longitude + 180.0) / 360.0 * n - tile.x - val latRad = latitude * PI / 180.0 - val fy = (1.0 - ln(tan(latRad) + 1.0 / kotlin.math.cos(latRad)) / PI) / 2.0 * n - tile.y + val fx = (longitude + STRAIGHT_ANGLE_DEG) / FULL_CIRCLE_DEG * n - tile.x + val latRad = latitude * PI / STRAIGHT_ANGLE_DEG + val fy = (1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / PI) / HALF * n - tile.y val px = (fx * decoded.width).toInt().coerceIn(0, decoded.width - 1) val py = (fy * decoded.height).toInt().coerceIn(0, decoded.height - 1) return decoded.elevationAt(px, py).toDouble() } + /** + * Warm every tile inside [bounds] before anything asks for one, several at a time. + * + * The archive is read over HTTP range requests through a single seekable channel, so one reader serves tiles + * strictly one after another — measured at ~300 ms each, which for a 25 km disc (~60 tiles) *was* the entire cost + * of a sweep: prediction count barely moved the total. A pool of readers is the fix, and it is a pool rather than + * concurrent calls on one reader because the channel carries a position that concurrent seeks would corrupt. + * + * Optional — sampling fetches on demand without it — and returns how many tiles it warmed. + */ + suspend fun prefetch(site: Site, concurrency: Int = DEFAULT_PREFETCH_READERS): Int = + prefetch(site.coverageBounds(), concurrency) + + /** As [prefetch], for a box that is not a site's coverage disc. */ + suspend fun prefetch(area: GeoBounds? = bounds, concurrency: Int = DEFAULT_PREFETCH_READERS): Int { + val wanted = + area + ?.let { box -> + val nw = TerrainTileMath.tileAt(zoom, box.north, box.west) + val se = TerrainTileMath.tileAt(zoom, box.south, box.east) + buildList { + for (x in nw.x..se.x) { + for (y in nw.y..se.y) { + val key = tileKey(x, y) + if (!resolved.containsKey(key)) add(TileRequest(key, x, y)) + } + } + } + } + .orEmpty() + if (wanted.isEmpty()) return 0 + + // Opening a reader costs a header round trip of its own, so the pool opens concurrently: + // built serially, the pool's own setup grew linearly with its size and ate the speed-up. + val extras = coroutineScope { + List((concurrency - 1).coerceAtLeast(0)) { + async(Dispatchers.Default) { runCatching { TerrainTileFetcher(url) }.getOrNull() } + } + .awaitAll() + } + val readers = listOf(fetcher) + extras.filterNotNull() + try { + val warmed = coroutineScope { + wanted + .chunked((wanted.size + readers.size - 1) / readers.size) + .mapIndexed { i, chunk -> + val reader = readers[i % readers.size] + async(Dispatchers.Default) { + chunk.map { req -> + val bytes = reader.fetchTile(zoom, req.x, req.y) + req.key to bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } + } + } + } + .awaitAll() + .flatten() + } + lock.withLock { + resolved = resolved + warmed + for ((key, tile) in warmed) cache[key] = CompletableDeferred(tile) + } + return warmed.size + } finally { + for (extra in extras) extra?.close() + } + } + + /** Fetch and decode one tile, sharing the work with anyone else who wants it. */ + private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? { + val pending = + lock.withLock { + cache.getOrPut(key) { + // Dispatchers.IO is JVM/Android-only; Default keeps this source common. + scope.async { + val bytes = fetcher.fetchTile(zoom, x, y) + bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } + } + } + } + val tile = pending.await() + lock.withLock { resolved = resolved + (key to tile) } + return tile + } + /** How many distinct tiles the sweep actually touched — useful when reporting a run. */ - val tilesFetched: Int get() = cache.size + val tilesFetched: Int + get() = cache.size - override fun close() = fetcher.close() + override fun close() { + scope.cancel() + regional?.close() + if (regional == null) fetcher.close() + } + + companion object { + /** + * The global archive's own maximum, ~37 m/px at mid latitudes. Asking for more returns nothing; asking for less + * throws away detail that costs no extra requests, because a coverage disc spans few enough tiles either way. + */ + internal const val DEFAULT_GLOBAL_ZOOM = MapterhornEndpoints.GLOBAL_MAX_ZOOM + + /** + * Regional archives run z13–18. z13 is ~19 m/px at mid latitudes — four times the detail of the global archive, + * and well below the 100 m profile step, so the profile stops being the thing that limits accuracy. Higher + * zooms multiply the tiles fetched without the model resolving much more. + */ + internal const val DEFAULT_REGIONAL_ZOOM = 13 - private companion object { /** - * Mapterhorn's global archive tops out at z12 ([MapterhornEndpoints.GLOBAL_MAX_ZOOM]); - * z11 is ~75 m/px at mid latitudes, comparable to the planner's 90 m standard mode. + * Readers opened for a prefetch. Measured on a 72-tile disc: 8 readers took 5.4 s, 16 took 4.9 s and 32 took + * 6.4 s, the last losing to its own setup. Past ~16 the archive's own round trips, not our concurrency, are the + * floor. */ - const val DEFAULT_ZOOM = 11 + const val DEFAULT_PREFETCH_READERS = 16 } + + /** One tile a prefetch still has to fetch. */ + private class TileRequest(val key: Long, val x: Int, val y: Int) } + +/** + * The lat/lon box a sweep of this site samples. + * + * Pass it to [MapterhornElevation] to get the regional archive where one covers the site. + */ +internal fun Site.coverageBounds(): GeoBounds { + val latSpan = radiusKm / KM_PER_DEG_LAT + val lonSpan = latSpan / cos(latitude * DEG_TO_RAD) + return GeoBounds( + south = latitude - latSpan, + west = longitude - lonSpan, + north = latitude + latSpan, + east = longitude + lonSpan, + ) +} + +private const val KM_PER_DEG_LAT = 111.32 +private const val DEG_TO_RAD = 0.017453292519943295 + +/** Pack a tile's coordinates into one cache key. */ +private fun tileKey(x: Int, y: Int): Long = (x.toLong() shl TILE_KEY_SHIFT) or y.toLong() + +private const val TILE_KEY_SHIFT = 32 +private const val FULL_CIRCLE_DEG = 360.0 +private const val STRAIGHT_ANGLE_DEG = 180.0 +private const val HALF = 2.0 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt new file mode 100644 index 00000000000..d894cb3d316 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlin.math.cos +import kotlin.math.floor + +/** + * Received power on a polar lattice centred on the site: [radials] bearings by [rings] ranges. + * + * Coverage is computed in polar and only then rasterised, because a receiver's prediction needs the whole terrain + * profile back to the transmitter. On a radial every receiver shares one profile — the far one is the near one plus a + * few points — so a bearing's worth of predictions costs a single walk. Sampling the cartesian grid directly re-walks a + * profile per cell and re-reads the same terrain thousands of times over. + * + * `dbm` is indexed `bearing * rings + ring`; ring `r` sits at `firstKm + r * ringStepKm`. + */ +class PolarCoverage( + val site: Site, + val radials: Int, + val rings: Int, + val firstKm: Double, + val ringStepKm: Double, + val dbm: DoubleArray, +) { + init { + require(dbm.size == radials * rings) { "dbm must be radials*rings, got ${dbm.size} for $radials x $rings" } + } + + /** Received power at ring [r] of bearing index [b], which wraps. */ + fun at(b: Int, r: Int): Double { + val bb = ((b % radials) + radials) % radials + return dbm[bb * rings + r] + } + + /** Distance of ring [r] from the site, km. */ + fun ringKm(r: Int): Double = firstKm + r * ringStepKm +} + +/** + * Sweep coverage in polar form, one shared terrain profile per bearing. + * + * [profileStepKm] is the terrain sampling interval and should track the elevation source's own resolution; [rings] is + * how many of those steps carry a receiver. They are independent because the cost is the [P1812.predict] calls, not the + * profile length — a dense profile with sparse receivers is both cheaper and more accurate than matching the two. + */ +suspend fun LocalCoverage.sweepPolar( + site: Site, + radials: Int = DEFAULT_RADIALS, + rings: Int = DEFAULT_RINGS, + profileStepKm: Double = DEFAULT_PROFILE_STEP_KM, +): PolarCoverage { + require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" } + require(rings >= MIN_RINGS) { "rings must be >= $MIN_RINGS, got $rings" } + require(profileStepKm > 0) { "profileStepKm must be > 0, got $profileStepKm" } + + val profilePoints = maxOf((site.radiusKm / profileStepKm).toInt() + 1, MIN_PROFILE_POINTS) + val stepKm = site.radiusKm / (profilePoints - 1) + // P.1812 needs interior profile points, so the innermost receiver is never the first or second. + val firstIndex = MIN_PROFILE_POINTS - 1 + val lastIndex = profilePoints - 1 + val indexStride = (lastIndex - firstIndex).toDouble() / (rings - 1) + + val out = DoubleArray(radials * rings) + // Radials are independent, and a sweep is pure arithmetic once the terrain is cached, so this + // scales with cores. Each writes its own disjoint slice of `out`, so there is nothing to guard. + // The dispatcher is explicit: a caller on runBlocking or the main thread would otherwise hand + // every radial to that one confined thread and the sweep would stay serial. + coroutineScope { + (0 until radials) + .map { b -> + async(Dispatchers.Default) { + val bearing = FULL_CIRCLE * b / radials + // One walk per bearing: every ring on this radial is a prefix of this profile. + val lats = DoubleArray(profilePoints) + val heights = DoubleArray(profilePoints) + for (s in 0 until profilePoints) { + val (lat, lon) = destination(site.latitude, site.longitude, bearing, stepKm * s) + lats[s] = lat + heights[s] = elevationAt(lat, lon) + } + for (r in 0 until rings) { + val end = firstIndex + (r * indexStride).toInt() + out[b * rings + r] = predictAlong(site, heights, lats[end], stepKm, end) + } + } + } + .awaitAll() + } + return PolarCoverage( + site = site, + radials = radials, + rings = rings, + firstKm = stepKm * firstIndex, + ringStepKm = stepKm * indexStride, + dbm = out, + ) +} + +/** + * Resample the polar lattice onto the regular grid the contour tracer needs. + * + * Bilinear in (bearing, range), so the grid is smooth rather than showing the lattice's own spokes. The grid can be far + * finer than the lattice for free — it costs no predictions — which is what lets the bands follow terrain instead of + * the sampling pattern. + */ +fun PolarCoverage.toGrid(resolution: Int = DEFAULT_GRID): CoverageGrid { + require(resolution >= MIN_GRID) { "resolution must be >= $MIN_GRID, got $resolution" } + + val latSpanDeg = site.radiusKm / KM_PER_DEG_LAT + val lonSpanDeg = latSpanDeg / cos(site.latitude * DEG_TO_RAD) + val north = site.latitude + latSpanDeg + val south = site.latitude - latSpanDeg + val east = site.longitude + lonSpanDeg + val west = site.longitude - lonSpanDeg + + val out = DoubleArray(resolution * resolution) { Double.NaN } + for (y in 0 until resolution) { + val lat = north - (north - south) * y / (resolution - 1) + for (x in 0 until resolution) { + val lon = west + (east - west) * x / (resolution - 1) + val km = haversineKm(site.latitude, site.longitude, lat, lon) + if (km > site.radiusKm) continue + + // Inside the innermost ring and outside the outermost one both clamp: the lattice covers + // the whole disc, and clamping is a nearer neighbour than any extrapolation would be. + val rPos = ((km - firstKm) / ringStepKm).coerceIn(0.0, (rings - 1).toDouble()) + val r0 = floor(rPos).toInt().coerceAtMost(rings - 1) + val r1 = (r0 + 1).coerceAtMost(rings - 1) + val rf = rPos - r0 + + // Bearings wrap, so no clamping here - PolarCoverage.at takes the index modulo. + val bPos = bearingDeg(site.latitude, site.longitude, lat, lon) / FULL_CIRCLE * radials + val b0 = floor(bPos).toInt() + val bf = bPos - b0 + + val lower = at(b0, r0) + (at(b0 + 1, r0) - at(b0, r0)) * bf + val upper = at(b0, r1) + (at(b0 + 1, r1) - at(b0, r1)) * bf + out[y * resolution + x] = lower + (upper - lower) * rf + } + } + return CoverageGrid(site, resolution, resolution, north, south, east, west, out) +} + +/** + * Matched to a 256-cell grid over a 25 km radius: 720 bearings put ~220 m between radials at the rim and 128 rings put + * ~195 m between ranges, both about one grid cell. Denser buys nothing the grid can show; sparser is what leaves the + * sampling pattern visible as spokes. + */ +internal const val DEFAULT_RADIALS = 720 +internal const val DEFAULT_RINGS = 128 + +/** + * Terrain sampling along a radial, at the global archive's own z12 resolution (~37 m at mid latitudes). Cheap: the + * profile is walked once per bearing, and the predictions that consume it dominate. Stepping at 100 m instead + * measurably under-reports shadowing. + */ +internal const val DEFAULT_PROFILE_STEP_KM = 0.05 +private const val MIN_RADIALS = 8 +private const val MIN_RINGS = 4 +private const val MIN_PROFILE_POINTS = 3 +private const val MIN_GRID = 8 +private const val FULL_CIRCLE = 360.0 +private const val KM_PER_DEG_LAT = 111.32 +private const val DEG_TO_RAD = 0.017453292519943295 diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt index ff013eefb31..bcd5a296458 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -1,7 +1,18 @@ /* * Copyright (c) 2026 Meshtastic LLC * - * SPDX-License-Identifier: GPL-3.0-or-later + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . */ package org.meshtastic.feature.coverage @@ -12,11 +23,11 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Spike verification: the local model behaves the way a coverage prediction must, without - * needing a network, a WebView or a terrain download. + * Spike verification: the local model behaves the way a coverage prediction must, without needing a network, a WebView + * or a terrain download. * - * These are behavioural assertions, not conformance ones — `kp1812`'s own suite already checks - * the model against the ITU reference. What matters here is that this module drives it correctly. + * These are behavioural assertions, not conformance ones — `kp1812`'s own suite already checks the model against the + * ITU reference. What matters here is that this module drives it correctly. */ class LocalCoverageTest { @@ -59,7 +70,9 @@ class LocalCoverageTest { val far = 8.0 fun strengthTowards(bearingIndex: Int): Double { val perRadial = coverage.points.size / 4 - return coverage.points.drop(bearingIndex * perRadial).take(perRadial) + return coverage.points + .drop(bearingIndex * perRadial) + .take(perRadial) .minByOrNull { abs(haversineKm(47.6, -122.2, it.latitude, it.longitude) - far) }!! .rxDbm } @@ -107,10 +120,10 @@ class LocalCoverageTest { /** * The one-decimal helper that replaced `String.format`, which does not exist off the JVM. * - * Exact halves are deliberately not asserted: `roundToLong` breaks ties toward positive infinity, - * and whether a decimal like -76.15 even *is* a tie depends on its binary representation - * (-76.15 * 10 is -761.4999999999999, so it rounds to -76.1). Immaterial for displaying dBm, and - * a test that pinned it would be asserting floating-point trivia rather than behaviour. + * Exact halves are deliberately not asserted: `roundToLong` breaks ties toward positive infinity, and whether a decimal + * like -76.15 even *is* a tie depends on its binary representation (-76.15 * 10 is -761.4999999999999, so it rounds to + * -76.1). Immaterial for displaying dBm, and a test that pinned it would be asserting floating-point trivia rather than + * behaviour. */ class ToFixed1Test { @Test diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt new file mode 100644 index 00000000000..0102f0250a5 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.test.runTest +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The polar sweep and the grid it is resampled onto. + * + * Coverage is computed per bearing and only then rasterised, so these cover the seam between the two: that the lattice + * is swept where it claims to be, and that resampling it preserves the prediction rather than inventing structure of + * its own. + */ +class PolarCoverageTest { + + private val flat = ElevationSource { _, _ -> 0.0 } + + private fun site(radiusKm: Double = 10.0) = Site( + name = "Test", + latitude = 47.6, + longitude = -122.2, + frequencyMhz = 915.0, + txPowerDbm = 30.0, + rxSensitivityDbm = -130.0, + txHeightM = 10.0, + radiusKm = radiusKm, + ) + + @Test + fun ringsStepOutwardsAndStayInsideTheRadius() = runTest { + val polar = LocalCoverage(flat).sweepPolar(site(), radials = 16, rings = 12) + val distances = (0 until polar.rings).map { polar.ringKm(it) } + assertTrue(distances.zipWithNext().all { (a, b) -> b > a }, "rings should step outward: $distances") + assertTrue(distances.first() > 0.0, "the innermost ring cannot sit on the transmitter") + assertTrue(distances.last() <= site().radiusKm + 1e-9, "outermost ring ${distances.last()} exceeds the radius") + } + + @Test + fun overFlatGroundStrengthDependsOnRangeAlone() = runTest { + // No terrain means no bearing can differ from any other; anything that does is the sweep's + // own geometry leaking in. + val polar = LocalCoverage(flat).sweepPolar(site(), radials = 24, rings = 10) + for (r in 0 until polar.rings) { + val onRing = (0 until polar.radials).map { polar.at(it, r) } + val spread = onRing.max() - onRing.min() + assertTrue(spread < 0.5, "ring $r varies by $spread dB across bearings over flat ground") + } + } + + @Test + fun signalFallsOffWithRange() = runTest { + val polar = LocalCoverage(flat).sweepPolar(site(), radials = 8, rings = 16) + val alongOneBearing = (0 until polar.rings).map { polar.at(0, it) } + assertTrue( + alongOneBearing.first() > alongOneBearing.last(), + "signal should be weaker far away: ${alongOneBearing.first()} -> ${alongOneBearing.last()}", + ) + } + + @Test + fun gridCoversTheDiscAndNothingOutsideIt() = runTest { + val grid = LocalCoverage(flat).sweepPolar(site(), radials = 16, rings = 12).toGrid(resolution = 48) + assertEquals(48, grid.width) + assertEquals(48, grid.height) + for (y in 0 until grid.height) { + for (x in 0 until grid.width) { + val km = + haversineKm(site().latitude, site().longitude, grid.latAt(y.toDouble()), grid.lonAt(x.toDouble())) + val computed = !grid.at(x, y).isNaN() + // The corners of a square box are outside a disc inscribed in it; nothing else is. + assertEquals(km <= site().radiusKm, computed, "cell ($x,$y) at ${km}km computed=$computed") + } + } + } + + @Test + fun resamplingFinerDoesNotMoveThePrediction() = runTest { + // The grid is free of the prediction budget, so a finer one has to show the same coverage — + // if it did not, the extra cells would be the resampler inventing detail. + val polar = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16) + val coarse = polar.toGrid(resolution = 32).reachableFraction + val fine = polar.toGrid(resolution = 192).reachableFraction + assertTrue(abs(coarse - fine) < 0.02, "reachable fraction moved with resolution: $coarse vs $fine") + } + + @Test + fun gridIsSymmetricAboutTheSiteOverFlatGround() = runTest { + val grid = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16).toGrid(resolution = 65) + val mid = 32 // the centre cell of an odd-sized grid sits on the site + for (d in 1..mid) { + val west = grid.at(mid - d, mid) + val east = grid.at(mid + d, mid) + if (west.isNaN() || east.isNaN()) continue + assertTrue(abs(west - east) < 0.5, "cells $d west and east differ: $west vs $east") + } + } +} diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 77db4e495dc..4a76b251d28 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -17,56 +17,81 @@ package org.meshtastic.feature.coverage import kotlinx.coroutines.runBlocking +import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO -import java.awt.image.BufferedImage import kotlin.math.roundToInt import kotlin.system.measureTimeMillis /** * Runs a real coverage prediction end to end and writes a PNG plus the GeoJSON the app imports. * - * This exists so the spike can be *seen* rather than only asserted: real Mapterhorn terrain, the - * ITU-R P.1812 model from `org.meshtastic:kp1812`, no network call to site.meshtastic.org, no - * WebView, no browser. The same `LocalCoverage` the desktop app would call. + * This exists so the spike can be *seen* rather than only asserted: real Mapterhorn terrain, the ITU-R P.1812 model + * from `org.meshtastic:kp1812`, no network call to site.meshtastic.org, no WebView, no browser. The same + * `LocalCoverage` the desktop app would call. * * `./gradlew :feature:coverage:coverageDemo -PuseMavenLocal` */ object CoverageDemo { + @Suppress("MagicNumber") @JvmStatic fun main(args: Array) { val lat = args.getOrNull(0)?.toDoubleOrNull() ?: DEFAULT_LAT val lon = args.getOrNull(1)?.toDoubleOrNull() ?: DEFAULT_LON val outDir = File(args.getOrNull(2) ?: "build/coverage-demo").apply { mkdirs() } + val radials = args.getOrNull(3)?.toIntOrNull() ?: DEFAULT_RADIALS + val rings = args.getOrNull(4)?.toIntOrNull() ?: DEFAULT_RINGS + val profileStepKm = args.getOrNull(5)?.toDoubleOrNull() ?: DEFAULT_PROFILE_STEP_KM + val readers = args.getOrNull(6)?.toIntOrNull() ?: MapterhornElevation.DEFAULT_PREFETCH_READERS - val site = Site( - name = "Demo", - latitude = lat, - longitude = lon, - frequencyMhz = 906.875, // Meshtastic US LongFast - txPowerDbm = 30.0, - rxSensitivityDbm = -130.0, // LONG_FAST - txHeightM = 10.0, - rxHeightM = 1.5, - radiusKm = 25.0, - ) + val site = + Site( + name = "Demo", + latitude = lat, + longitude = lon, + frequencyMhz = 906.875, // Meshtastic US LongFast + txPowerDbm = 30.0, + rxSensitivityDbm = -130.0, // LONG_FAST + txHeightM = 10.0, + rxHeightM = 1.5, + radiusKm = 25.0, + ) println("site ${site.latitude}, ${site.longitude} ${site.frequencyMhz} MHz ${site.txPowerDbm} dBm") - MapterhornElevation().use { elevation -> + MapterhornElevation(site.coverageBounds()).use { elevation -> + println("terrain: ${if (elevation.isRegional) "regional" else "global"} archive at z${elevation.zoomLevel}") + var warmed = 0 + val prefetchMs = measureTimeMillis { warmed = runBlocking { elevation.prefetch(concurrency = readers) } } + println("prefetched $warmed terrain tiles in ${prefetchMs}ms with $readers readers") lateinit var coverage: CoverageGrid val ms = measureTimeMillis { coverage = runBlocking { - LocalCoverage(elevation).sweepGrid(site, resolution = GRID) + LocalCoverage(elevation) + .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) + .toGrid(GRID) } } val computed = coverage.dbm.count { !it.isNaN() } println("computed $computed grid cells in ${ms}ms from ${elevation.tilesFetched} terrain tiles") + println( + "predictions: ${radials * rings} ($radials radials x $rings rings, " + + "profile step ${profileStepKm * 1000} m), cores ${Runtime.getRuntime().availableProcessors()}", + ) println("reachable: ${(coverage.reachableFraction * 100).roundToInt()}%") println("max range: ${"%.1f".format(coverage.maxRangeKm)} km") val finite = coverage.dbm.filter { !it.isNaN() } println("rx dBm range: ${"%.1f".format(finite.min())} .. ${"%.1f".format(finite.max())}") + val again = measureTimeMillis { + runBlocking { + LocalCoverage(elevation) + .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) + .toGrid(GRID) + } + } + println("re-swept with terrain already decoded in ${again}ms") + File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) renderPng(coverage, File(outDir, "coverage.png")) println("wrote ${outDir.absolutePath}/coverage.{png,geojson}") @@ -112,5 +137,5 @@ object CoverageDemo { private const val DEFAULT_LAT = 47.6062 // Seattle — real relief nearby private const val DEFAULT_LON = -122.3321 - private const val GRID = 80 + private const val GRID = 256 } From 333b4ccfae829ab6f69c271b0e8d66847aab0b8f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:25:35 -0500 Subject: [PATCH 08/27] perf(coverage): share decoded terrain, and open the archive only when needed Driving the desktop planner showed a second estimate still cost 8.8s against a 0.2s sweep. Two causes, both measured rather than guessed. The tile cache was per-instance, and the planner's composable is disposed when its sheet closes - so every estimate re-downloaded terrain it had decoded seconds earlier. A decoded tile is immutable data keyed by archive, zoom and position, so it now lives in a process-wide cache instead. Bounded at 128 tiles (~32MB) with insertion-order eviction: a sweep's working set is one contiguous disc, so the oldest entries are from a disc the user has moved away from, and tracking access order would cost a write on the hot read path for no better answer. That left 719ms per estimate opening a pmtiles reader - the archive header is read over the network in the constructor - for an estimate whose terrain was already resident and needed no reader at all. It is opened lazily now, and close() never forces it just to close it. Measured end to end on the Seattle demo, second sweep through a brand new elevation source, which is the app's real shape: 110ms. In the app itself the first estimate is ~13s and repeats were 4.5s before the lazy open. --- .../feature/coverage/MapterhornElevation.kt | 68 +++++------ .../feature/coverage/TerrainCache.kt | 108 ++++++++++++++++++ .../feature/coverage/CoverageDemo.kt | 47 ++++++-- 3 files changed, 177 insertions(+), 46 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 1553899aeac..37e5fceb2b5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -16,17 +16,13 @@ */ package org.meshtastic.feature.coverage -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.meshtastic.feature.map.terrain.ElevationTile import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints @@ -77,7 +73,17 @@ class MapterhornElevation( private val url = if (regional != null) archiveUrl!! else MapterhornEndpoints.GLOBAL_PMTILES_URL - private val fetcher = regional ?: TerrainTileFetcher(url) + /** + * Opened only when a tile actually has to be downloaded. + * + * Opening one reads the archive header over the network — measured at 719 ms — and an estimate whose terrain is + * already in [SharedTerrain] needs no reader at all. Eager, that open was more than half the cost of a repeat + * estimate. + */ + private val lazyFetcher = lazy { regional ?: TerrainTileFetcher(url) } + + private val fetcher: TerrainTileFetcher + get() = lazyFetcher.value /** True when this is drawing on a regional archive rather than the coarse global one. */ val isRegional: Boolean = regional != null @@ -90,19 +96,16 @@ class MapterhornElevation( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - // Cached as the in-flight fetch, not the result, so concurrent radials landing on the same tile - // await one download instead of starting one each. - private val cache = HashMap>() - private val lock = Mutex() + // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend + // acquisition; two callers racing here both get the same cache, so the race is harmless. + @Volatile private var tiles: TerrainCache? = null - // A read-only snapshot of what has already decoded. Sampling is overwhelmingly cache hits, and - // taking the mutex for each would serialise a parallel sweep on the one lock. - @Volatile private var resolved: Map = emptyMap() + private suspend fun tiles(): TerrainCache = tiles ?: SharedTerrain.forArchive(url, zoom).also { tiles = it } override suspend fun elevationMeters(latitude: Double, longitude: Double): Double { val tile = TerrainTileMath.tileAt(zoom, latitude, longitude) val key = tileKey(tile.x, tile.y) - val snapshot = resolved + val snapshot = tiles().snapshot() // Ocean, or outside the archive, decodes to null — sea level, as the planner assumes too. val decoded = (if (snapshot.containsKey(key)) snapshot[key] else awaitTile(key, tile.x, tile.y)) ?: return 0.0 @@ -131,6 +134,8 @@ class MapterhornElevation( /** As [prefetch], for a box that is not a site's coverage disc. */ suspend fun prefetch(area: GeoBounds? = bounds, concurrency: Int = DEFAULT_PREFETCH_READERS): Int { + val cache = tiles() + val resident = cache.snapshot() val wanted = area ?.let { box -> @@ -140,7 +145,7 @@ class MapterhornElevation( for (x in nw.x..se.x) { for (y in nw.y..se.y) { val key = tileKey(x, y) - if (!resolved.containsKey(key)) add(TileRequest(key, x, y)) + if (!resident.containsKey(key)) add(TileRequest(key, x, y)) } } } @@ -173,10 +178,7 @@ class MapterhornElevation( .awaitAll() .flatten() } - lock.withLock { - resolved = resolved + warmed - for ((key, tile) in warmed) cache[key] = CompletableDeferred(tile) - } + cache.publish(warmed) return warmed.size } finally { for (extra in extras) extra?.close() @@ -184,30 +186,22 @@ class MapterhornElevation( } /** Fetch and decode one tile, sharing the work with anyone else who wants it. */ - private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? { - val pending = - lock.withLock { - cache.getOrPut(key) { - // Dispatchers.IO is JVM/Android-only; Default keeps this source common. - scope.async { - val bytes = fetcher.fetchTile(zoom, x, y) - bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } - } - } - } - val tile = pending.await() - lock.withLock { resolved = resolved + (key to tile) } - return tile + private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = tiles().getOrFetch(key) { + // Dispatchers.IO is JVM/Android-only; Default keeps this source common. + scope.async { + val bytes = fetcher.fetchTile(zoom, x, y) + bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } + } } - /** How many distinct tiles the sweep actually touched — useful when reporting a run. */ - val tilesFetched: Int - get() = cache.size + /** How many tiles are decoded and resident, useful when reporting a run. */ + val tilesResident: Int + get() = tiles?.size ?: 0 override fun close() { scope.cancel() - regional?.close() - if (regional == null) fetcher.close() + // Never force the lazy open just to close it; and when regional won, it *is* the fetcher. + if (lazyFetcher.isInitialized()) lazyFetcher.value.close() else regional?.close() } companion object { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt new file mode 100644 index 00000000000..d6384c7bfc7 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.meshtastic.feature.map.terrain.ElevationTile +import kotlin.concurrent.Volatile + +/** + * Decoded terrain for one archive at one zoom, shared by every [MapterhornElevation] that reads it. + * + * A tile is immutable data keyed by archive, zoom and position, so there is nothing to scope it to a single instance — + * and scoping it to one was measurably wrong. The desktop planner's composable is disposed when its sheet closes, so a + * second estimate paid the full terrain download again for tiles it had already decoded seconds earlier: 8.8 s where + * the sweep itself is 0.2 s. + * + * Bounded, because a decoded tile is ~256 KB and a 25 km disc is ~70 of them. Eviction is insertion order rather than + * least-recently-used: a sweep's working set is one contiguous disc, so the oldest entries are from a disc the user has + * already moved away from, and tracking access order would cost a write on the hot read path for no better answer. + */ +internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { + + private val lock = Mutex() + + // The in-flight fetch, not the result, so concurrent radials landing on the same tile await one + // download instead of starting one each. + private val inFlight = LinkedHashMap>() + + // A read-only snapshot of what has decoded. Sampling is overwhelmingly hits, and taking the + // mutex for each would serialise a parallel sweep on this one lock. + @Volatile private var resolved: Map = emptyMap() + + /** How many tiles are decoded and resident. */ + val size: Int + get() = resolved.size + + /** + * What is resident right now. + * + * Handed out as one map rather than as `contains` plus `get`, because between two calls an eviction could drop the + * key and the second would answer null — which a sampler reads as ocean. + */ + fun snapshot(): Map = resolved + + /** + * The decoded tile, starting [produce] only if nobody else already has. + * + * [produce] returns a [Deferred] rather than the tile so that the caller owns the scope the fetch runs in — the + * cache outlives any one [MapterhornElevation] and must not hold its scope. + */ + suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? { + val pending = lock.withLock { inFlight.getOrPut(key) { produce() } } + val tile = pending.await() + publish(listOf(key to tile)) + return tile + } + + /** Record tiles fetched in bulk, as a prefetch does. */ + suspend fun publish(entries: List>) = lock.withLock { + val merged = LinkedHashMap(resolved) + for ((key, tile) in entries) { + merged.remove(key) + merged[key] = tile + } + while (merged.size > capacity) { + val oldest = merged.keys.first() + merged.remove(oldest) + inFlight.remove(oldest) + } + resolved = merged + } + + private companion object { + /** ~70 tiles cover a 25 km disc at z12, and one is ~256 KB — so this holds a working set plus room to pan. */ + const val DEFAULT_CAPACITY = 128 + } +} + +/** + * The shared caches, one per archive and zoom. + * + * Process-wide on purpose: terrain does not belong to a screen. The map keeps at most a couple of archives open, so + * this never needs eviction of its own. + */ +internal object SharedTerrain { + + private val caches = HashMap() + private val lock = Mutex() + + suspend fun forArchive(url: String, zoom: Int): TerrainCache = + lock.withLock { caches.getOrPut("$url@$zoom") { TerrainCache() } } +} diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 4a76b251d28..b4e825f93d2 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -73,7 +73,7 @@ object CoverageDemo { } } val computed = coverage.dbm.count { !it.isNaN() } - println("computed $computed grid cells in ${ms}ms from ${elevation.tilesFetched} terrain tiles") + println("computed $computed grid cells in ${ms}ms from ${elevation.tilesResident} terrain tiles") println( "predictions: ${radials * rings} ($radials radials x $rings rings, " + "profile step ${profileStepKm * 1000} m), cores ${Runtime.getRuntime().availableProcessors()}", @@ -83,14 +83,7 @@ object CoverageDemo { val finite = coverage.dbm.filter { !it.isNaN() } println("rx dBm range: ${"%.1f".format(finite.min())} .. ${"%.1f".format(finite.max())}") - val again = measureTimeMillis { - runBlocking { - LocalCoverage(elevation) - .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) - .toGrid(GRID) - } - } - println("re-swept with terrain already decoded in ${again}ms") + reportWarmSweeps(elevation, site, radials, rings, profileStepKm) File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) renderPng(coverage, File(outDir, "coverage.png")) @@ -98,6 +91,42 @@ object CoverageDemo { } } + /** + * What a repeat estimate costs once terrain is decoded — the number that decides whether the planner can update + * interactively. + * + * The second run builds a *fresh* source, because that is the app's real shape: the planner's composable is + * disposed when its sheet closes, so nothing survives in the instance itself. + */ + private fun reportWarmSweeps( + elevation: MapterhornElevation, + site: Site, + radials: Int, + rings: Int, + profileStepKm: Double, + ) { + val again = measureTimeMillis { + runBlocking { + LocalCoverage(elevation) + .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) + .toGrid(GRID) + } + } + println("re-swept with terrain already decoded in ${again}ms") + + val fresh = measureTimeMillis { + runBlocking { + MapterhornElevation().use { second -> + second.prefetch(site) + LocalCoverage(second) + .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) + .toGrid(GRID) + } + } + } + println("re-swept through a brand new elevation source in ${fresh}ms") + } + /** Plot the grid as a top-down image, coloured by signal strength. */ @Suppress("MagicNumber") private fun renderPng(coverage: CoverageGrid, dest: File) { From 983c5aaf7da4e19e6c821eaccbe82904e92d17a0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:28:31 -0500 Subject: [PATCH 09/27] fix(coverage): drop the stale per-instance hold, and never reuse a cancelled fetch The planner slot's remembered elevation source described a mechanism that was measured as broken - the slot is disposed with its sheet, so nothing survived in the instance. Terrain lives in the shared cache now and a source that never fetches costs nothing to open or close, so the estimate just builds one per run. TerrainCache kept whatever Deferred it was first given, including a cancelled one. The scope producing it belongs to a single MapterhornElevation, so dismissing the sheet mid-run left a dead Deferred that would fail every later sweep touching that tile. Reachable only through an on-demand miss, which a prefetched disc makes rare - which is exactly why it would have been hard to find later. --- .../desktop/map/DesktopSitePlannerSlot.kt | 15 ++++++--------- .../meshtastic/feature/coverage/TerrainCache.kt | 6 +++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 0623a72190a..9751fd98cd5 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -31,7 +31,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -93,10 +92,6 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { var running by remember { mutableStateOf(null) } var result by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } - // Held across estimates: decoded terrain is the whole cost of a sweep, so the second estimate - // anywhere near the first is near-instant. Opened lazily - the constructor does network I/O. - var elevation by remember { mutableStateOf(null) } - DisposableEffect(Unit) { onDispose { elevation?.close() } } val current = running val coverage = result @@ -116,10 +111,12 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { LaunchedEffect(current) { runCatching { withContext(Dispatchers.Default) { - val source = elevation ?: MapterhornElevation().also { elevation = it } - // Terrain first, pooled: fetched on demand it dominates the sweep entirely. - source.prefetch(current.toSite()) - LocalCoverage(source).sweepGrid(current.toSite(), resolution = GRID) + // A fresh source per estimate is free: decoded terrain lives in the shared + // cache, and the archive reader only opens if a tile is actually missing. + MapterhornElevation().use { source -> + source.prefetch(current.toSite()) + LocalCoverage(source).sweepGrid(current.toSite(), resolution = GRID) + } } } .onSuccess { swept -> diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index d6384c7bfc7..463562b5c7f 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -65,7 +65,11 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { * cache outlives any one [MapterhornElevation] and must not hold its scope. */ suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? { - val pending = lock.withLock { inFlight.getOrPut(key) { produce() } } + // A cancelled fetch must not be handed to the next caller: the scope producing it belongs to + // one MapterhornElevation, and cancelling that (the planner sheet being dismissed mid-run) + // would otherwise leave a dead Deferred that fails every later sweep touching this tile. + val pending = + lock.withLock { inFlight[key]?.takeUnless { it.isCancelled } ?: produce().also { inFlight[key] = it } } val tile = pending.await() publish(listOf(key to tile)) return tile From 26656c2e1f6762c64b86745c38205680c88e782a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:39:33 -0500 Subject: [PATCH 10/27] perf(coverage): take terrain from XYZ tiles, and keep it on disk Chasing the dead regional archives turned up the real answer. Mapterhorn's own migration guide documents a plain XYZ endpoint, tiles.mapterhorn.com/{z}/{x}/{y} .webp. Probed at Seattle: z0-16 all 200 (~56-95KB, ~130ms each, Cloudflare-cached for a week, CORS open), z17+ 404. So it already serves the regional detail the per-z6-tile archives were meant to carry - and serves it as independent cacheable requests instead of range reads into one seekable channel, which is the thing that made bulk sampling serial in the first place. Coverage now reads that endpoint over ktor, which suspends rather than blocking, so a sweep's fetches never occupy the compute dispatcher - the previous pooled prefetch did blocking HTTP on Dispatchers.Default and would have starved a four-core phone. Okio's file access is blocking, so that goes to ioDispatcher. Tiles are also written through to a TerrainTileStore, the same Okio-backed store the map's offline region download already uses, in its own directory so coverage downloads do not inflate the size a downloaded region reports. Terrain now survives a restart. Confirmed the endpoint serves the same data the archive did: at z12 the Seattle demo reproduces the pmtiles run's rx range exactly, -147.8..-41.3 dBm. z12 stays the default. Measured against z13: identical reachable fraction and max range, rx range -147.8..-41.3 against -148.2..-41.3, for 72 tiles rather than 256. The extra detail lands below the 50m step the model samples the profile at. z13-16 remain available. Seattle, 25km radius: terrain, cold 7148ms -> 1183ms terrain, second launch 7148ms -> 242ms (disk cache) sweep 310ms repeat estimate 125ms The HTTP client is shared process-wide too; building one per estimate cost seconds in engine setup for something stateless once connected. --- desktopApp/build.gradle.kts | 2 + .../desktop/map/DesktopSitePlannerSlot.kt | 12 +- feature/coverage/build.gradle.kts | 8 + .../feature/coverage/MapterhornElevation.kt | 198 ++++++------------ .../feature/coverage/MapterhornTiles.kt | 88 ++++++++ .../feature/coverage/TerrainCache.kt | 17 +- .../feature/coverage/CoverageDemo.kt | 21 +- .../map/terrain/MapterhornEndpoints.kt | 21 +- 8 files changed, 221 insertions(+), 146 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0eceb633ce8..eb4d4c13cf8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -383,6 +383,8 @@ dependencies { // The MapLibre map surfaces, shared with the F-Droid Android flavor. implementation(projects.feature.mapMaplibre) + // TerrainTileStore: the site planner keeps its downloaded terrain between launches. + implementation(projects.feature.mapTerrain) // Exactly one native runtime, the one matching this build host (see maplibreDesktopRuntime()). maplibreDesktopRuntime() diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 9751fd98cd5..9315eba4143 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okio.FileSystem import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position @@ -60,6 +61,8 @@ import org.meshtastic.feature.map.component.SitePlannerSheet import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.maplibre.SitePlannerSession +import org.meshtastic.feature.map.maplibre.terrain.terrainStorageDirectory +import org.meshtastic.feature.map.terrain.TerrainTileStore import kotlin.math.log10 import kotlin.math.roundToInt @@ -92,6 +95,9 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { var running by remember { mutableStateOf(null) } var result by remember { mutableStateOf(null) } var failure by remember { mutableStateOf(null) } + // Its own directory beside the map's offline regions, not inside one: coverage downloads should + // not silently inflate the size and tile count a downloaded region reports. + val terrainStore = remember { TerrainTileStore(FileSystem.SYSTEM, terrainStorageDirectory().resolve("coverage")) } val current = running val coverage = result @@ -111,9 +117,9 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { LaunchedEffect(current) { runCatching { withContext(Dispatchers.Default) { - // A fresh source per estimate is free: decoded terrain lives in the shared - // cache, and the archive reader only opens if a tile is actually missing. - MapterhornElevation().use { source -> + // A fresh source per estimate is free: decoded terrain lives in a shared + // cache, on disk under the store, and the HTTP client is shared too. + MapterhornElevation(store = terrainStore).use { source -> source.prefetch(current.toSite()) LocalCoverage(source).sweepGrid(current.toSite(), resolution = GRID) } diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts index 30db6c3a610..ce7dd005412 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -39,8 +39,16 @@ kotlin { // and contours. Over flat synthetic ground a coverage plot is a bullseye and proves // nothing; against real terrain it has to show ridges shadowing valleys. implementation(projects.feature.mapTerrain) + implementation(projects.core.common) + implementation(libs.okio) + // Terrain comes over plain XYZ requests now; ktor suspends rather than blocking, so a + // sweep's fetches never occupy the compute dispatcher. + implementation(libs.ktor.client.core) } + jvmMain.dependencies { implementation(libs.ktor.client.java) } + androidMain.dependencies { implementation(libs.ktor.client.okhttp) } + commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } jvmTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 37e5fceb2b5..881565176a2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.coverage +import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -26,8 +27,8 @@ import kotlinx.coroutines.coroutineScope import org.meshtastic.feature.map.terrain.ElevationTile import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints -import org.meshtastic.feature.map.terrain.TerrainTileFetcher import org.meshtastic.feature.map.terrain.TerrainTileMath +import org.meshtastic.feature.map.terrain.TerrainTileStore import org.meshtastic.feature.map.terrain.decodeTerrariumTile import kotlin.concurrent.Volatile import kotlin.math.PI @@ -36,77 +37,50 @@ import kotlin.math.ln import kotlin.math.tan /** - * Real elevation from the same Mapterhorn archives the map already uses for hillshade and contours. + * Real elevation from the same Mapterhorn terrain the map already uses for hillshade and contours. * - * Common, not desktop-only: `TerrainTileFetcher` and `decodeTerrariumTile` are already expect/actual with android and - * jvm actuals, so nothing here is platform-specific. The native actual of the decoder throws by design (see its doc - * comment in feature:map-terrain), which is the same trade that module already makes. + * Common, not desktop-only: tile bytes arrive over ktor and `decodeTerrariumTile` is already expect/actual with android + * and jvm actuals. The native actual of the decoder throws by design (see its doc comment in feature:map-terrain), + * which is the same trade that module already makes. * - * This is what makes the demo meaningful: over flat synthetic ground a coverage plot is a bullseye and proves nothing. + * This is what makes a coverage plot mean anything: over flat synthetic ground it is a bullseye and proves nothing. * Against real terrain the prediction has to show ridges shadowing valleys, which is the entire reason the planner * exists. * - * Tiles are decoded once and cached in memory — a radial sweep asks for thousands of points that land in a handful of - * tiles. + * Decoded tiles are shared process-wide and, given a [store], kept on disk between launches. */ class MapterhornElevation( - /** - * The area the caller will sample. When it fits inside a single z6 tile, Mapterhorn has a regional archive for it, - * which carries far finer terrain than the global one — z13 is ~19 m per pixel against the global archive's ~75 m - * at z11. - */ + /** The area the caller will sample, which is what [prefetch] warms when given no box of its own. */ private val bounds: GeoBounds? = null, - zoom: Int? = null, + /** Where to keep tiles between launches. Without one, every launch re-downloads its terrain. */ + private val store: TerrainTileStore? = null, + zoom: Int = DEFAULT_ZOOM, + http: HttpClient? = null, ) : ElevationSource, AutoCloseable { - private val archiveUrl = bounds?.let { MapterhornEndpoints.regionalUrlFor(it) } - - /** - * Regional where one is published, global otherwise. - * - * Not every z6 tile has a regional archive — most 404 — so a missing one is a normal outcome rather than an error, - * and opening the reader is where that shows up. - */ - private val regional: TerrainTileFetcher? = - archiveUrl?.let { url -> runCatching { TerrainTileFetcher(url) }.getOrNull() } - - private val url = if (regional != null) archiveUrl!! else MapterhornEndpoints.GLOBAL_PMTILES_URL + private val zoom = zoom.coerceAtMost(MapterhornEndpoints.TILES_MAX_ZOOM) - /** - * Opened only when a tile actually has to be downloaded. - * - * Opening one reads the archive header over the network — measured at 719 ms — and an estimate whose terrain is - * already in [SharedTerrain] needs no reader at all. Eager, that open was more than half the cost of a repeat - * estimate. - */ - private val lazyFetcher = lazy { regional ?: TerrainTileFetcher(url) } - - private val fetcher: TerrainTileFetcher - get() = lazyFetcher.value + // Shared, and never closed here: a caller's client is theirs, and the shared one outlives us. + private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store) - /** True when this is drawing on a regional archive rather than the coarse global one. */ - val isRegional: Boolean = regional != null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val zoom = zoom ?: if (isRegional) DEFAULT_REGIONAL_ZOOM else DEFAULT_GLOBAL_ZOOM + // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend + // acquisition; two callers racing here both get the same cache, so the race is harmless. + @Volatile private var cache: TerrainCache? = null /** The zoom actually in use, for reporting. */ val zoomLevel: Int get() = zoom - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend - // acquisition; two callers racing here both get the same cache, so the race is harmless. - @Volatile private var tiles: TerrainCache? = null - - private suspend fun tiles(): TerrainCache = tiles ?: SharedTerrain.forArchive(url, zoom).also { tiles = it } + private suspend fun cache(): TerrainCache = cache ?: SharedTerrain.forArchive(ARCHIVE, zoom).also { cache = it } override suspend fun elevationMeters(latitude: Double, longitude: Double): Double { val tile = TerrainTileMath.tileAt(zoom, latitude, longitude) val key = tileKey(tile.x, tile.y) - val snapshot = tiles().snapshot() - // Ocean, or outside the archive, decodes to null — sea level, as the planner assumes too. + val snapshot = cache().snapshot() + // Ocean, or outside the endpoint's data, decodes to null — sea level, as the planner assumes too. val decoded = (if (snapshot.containsKey(key)) snapshot[key] else awaitTile(key, tile.x, tile.y)) ?: return 0.0 // Fractional position of this coordinate within its tile. @@ -119,22 +93,18 @@ class MapterhornElevation( return decoded.elevationAt(px, py).toDouble() } + /** Warm every tile a sweep of [site] will touch. */ + suspend fun prefetch(site: Site): Int = prefetch(site.coverageBounds()) + /** - * Warm every tile inside [bounds] before anything asks for one, several at a time. - * - * The archive is read over HTTP range requests through a single seekable channel, so one reader serves tiles - * strictly one after another — measured at ~300 ms each, which for a 25 km disc (~60 tiles) *was* the entire cost - * of a sweep: prediction count barely moved the total. A pool of readers is the fix, and it is a pool rather than - * concurrent calls on one reader because the channel carries a position that concurrent seeks would corrupt. + * Warm every tile inside [area] before anything asks for one, many at a time. * - * Optional — sampling fetches on demand without it — and returns how many tiles it warmed. + * Optional — sampling fetches on demand without it — and returns how many tiles it warmed. Worth calling anyway: + * on-demand misses arrive one radial at a time, while this issues the whole disc at once and lets the requests + * overlap. */ - suspend fun prefetch(site: Site, concurrency: Int = DEFAULT_PREFETCH_READERS): Int = - prefetch(site.coverageBounds(), concurrency) - - /** As [prefetch], for a box that is not a site's coverage disc. */ - suspend fun prefetch(area: GeoBounds? = bounds, concurrency: Int = DEFAULT_PREFETCH_READERS): Int { - val cache = tiles() + suspend fun prefetch(area: GeoBounds? = bounds): Int { + val cache = cache() val resident = cache.snapshot() val wanted = area @@ -153,87 +123,53 @@ class MapterhornElevation( .orEmpty() if (wanted.isEmpty()) return 0 - // Opening a reader costs a header round trip of its own, so the pool opens concurrently: - // built serially, the pool's own setup grew linearly with its size and ate the speed-up. - val extras = coroutineScope { - List((concurrency - 1).coerceAtLeast(0)) { - async(Dispatchers.Default) { runCatching { TerrainTileFetcher(url) }.getOrNull() } - } - .awaitAll() - } - val readers = listOf(fetcher) + extras.filterNotNull() - try { - val warmed = coroutineScope { - wanted - .chunked((wanted.size + readers.size - 1) / readers.size) - .mapIndexed { i, chunk -> - val reader = readers[i % readers.size] - async(Dispatchers.Default) { - chunk.map { req -> - val bytes = reader.fetchTile(zoom, req.x, req.y) - req.key to bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } - } - } - } - .awaitAll() - .flatten() - } - cache.publish(warmed) - return warmed.size - } finally { - for (extra in extras) extra?.close() + val warmed = coroutineScope { + wanted.map { request -> async { request.key to decode(request.x, request.y) } }.awaitAll() } + cache.publish(warmed) + return warmed.size } - /** Fetch and decode one tile, sharing the work with anyone else who wants it. */ - private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = tiles().getOrFetch(key) { - // Dispatchers.IO is JVM/Android-only; Default keeps this source common. - scope.async { - val bytes = fetcher.fetchTile(zoom, x, y) - bytes?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } - } - } - - /** How many tiles are decoded and resident, useful when reporting a run. */ + /** How many tiles are decoded and resident — useful when reporting a run. */ val tilesResident: Int - get() = tiles?.size ?: 0 + get() = cache?.size ?: 0 - override fun close() { - scope.cancel() - // Never force the lazy open just to close it; and when regional won, it *is* the fetcher. - if (lazyFetcher.isInitialized()) lazyFetcher.value.close() else regional?.close() - } + /** True when this instance keeps what it downloads. */ + val isPersistent: Boolean = store != null - companion object { - /** - * The global archive's own maximum, ~37 m/px at mid latitudes. Asking for more returns nothing; asking for less - * throws away detail that costs no extra requests, because a coverage disc spans few enough tiles either way. - */ - internal const val DEFAULT_GLOBAL_ZOOM = MapterhornEndpoints.GLOBAL_MAX_ZOOM + private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = + cache().getOrFetch(key) { scope.async { decode(x, y) } } - /** - * Regional archives run z13–18. z13 is ~19 m/px at mid latitudes — four times the detail of the global archive, - * and well below the 100 m profile step, so the profile stops being the thing that limits accuracy. Higher - * zooms multiply the tiles fetched without the model resolving much more. - */ - internal const val DEFAULT_REGIONAL_ZOOM = 13 + private suspend fun decode(x: Int, y: Int): ElevationTile? = + tiles.bytes(zoom, x, y)?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } - /** - * Readers opened for a prefetch. Measured on a 72-tile disc: 8 readers took 5.4 s, 16 took 4.9 s and 32 took - * 6.4 s, the last losing to its own setup. Past ~16 the archive's own round trips, not our concurrency, are the - * floor. - */ - const val DEFAULT_PREFETCH_READERS = 16 - } + override fun close() = scope.cancel() /** One tile a prefetch still has to fetch. */ private class TileRequest(val key: Long, val x: Int, val y: Int) + + companion object { + /** + * ~37 m per pixel at mid latitudes, which matches the 50 m terrain profile step. + * + * Measured against z13 on the Seattle demo: identical reachable fraction and max range, and an rx range of + * -147.8..-41.3 against -148.2..-41.3 — for 72 tiles instead of 256. The extra detail lands below the step the + * model samples at. The endpoint serves up to [MapterhornEndpoints.TILES_MAX_ZOOM] for anyone who wants it. + */ + const val DEFAULT_ZOOM = 12 + } } +/** One endpoint now, but the cache is keyed by it so a second source would not collide. */ +private const val ARCHIVE = "tiles.mapterhorn.com" + +/** Pack a tile's coordinates into one cache key. */ +private fun tileKey(x: Int, y: Int): Long = (x.toLong() shl TILE_KEY_SHIFT) or y.toLong() + /** * The lat/lon box a sweep of this site samples. * - * Pass it to [MapterhornElevation] to get the regional archive where one covers the site. + * Pass it to [MapterhornElevation] so a prefetch knows what ground to warm. */ internal fun Site.coverageBounds(): GeoBounds { val latSpan = radiusKm / KM_PER_DEG_LAT @@ -246,13 +182,9 @@ internal fun Site.coverageBounds(): GeoBounds { ) } -private const val KM_PER_DEG_LAT = 111.32 -private const val DEG_TO_RAD = 0.017453292519943295 - -/** Pack a tile's coordinates into one cache key. */ -private fun tileKey(x: Int, y: Int): Long = (x.toLong() shl TILE_KEY_SHIFT) or y.toLong() - private const val TILE_KEY_SHIFT = 32 private const val FULL_CIRCLE_DEG = 360.0 private const val STRAIGHT_ANGLE_DEG = 180.0 private const val HALF = 2.0 +private const val KM_PER_DEG_LAT = 111.32 +private const val DEG_TO_RAD = 0.017453292519943295 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt new file mode 100644 index 00000000000..d91611ab68e --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.isSuccess +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.feature.map.terrain.MapterhornEndpoints +import org.meshtastic.feature.map.terrain.TerrainSource +import org.meshtastic.feature.map.terrain.TerrainTileStore +import org.meshtastic.feature.map.terrain.TileIndex + +/** + * Terrarium tile bytes, from local storage if they are there and from Mapterhorn's XYZ endpoint if they are not. + * + * Plain per-tile requests rather than range reads into `planet.pmtiles`. The archive is one seekable channel, so its + * tiles come back strictly one at a time — measured at ~300 ms each, which for a 25 km disc was the whole cost of a + * coverage sweep. These are independent, Cloudflare-cached, and suspend rather than block, so the only limit is how + * many we choose to have in flight. + * + * Anything fetched is written back to [store] when there is one, so the next run — or the next launch — pays nothing + * for the same ground. + */ +internal class MapterhornTiles( + private val http: HttpClient, + private val store: TerrainTileStore?, + concurrency: Int = DEFAULT_CONCURRENCY, +) { + private val gate = Semaphore(concurrency) + + /** Raw Terrarium WebP for one tile, or null where the endpoint has no data (ocean, out of range). */ + suspend fun bytes(zoom: Int, x: Int, y: Int): ByteArray? { + val tile = TileIndex(zoom, x, y) + val source = sourceFor(zoom) + return readLocal(source, tile) ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } + } + + private suspend fun download(zoom: Int, x: Int, y: Int): ByteArray? = gate.withPermit { + runCatching { + val response = http.get(MapterhornEndpoints.tileUrl(zoom, x, y)) + if (response.status.isSuccess()) response.bodyAsBytes() else null + } + .getOrNull() + } + + /** Okio is blocking, so file access goes to IO rather than stalling a compute thread. */ + private suspend fun readLocal(source: TerrainSource, tile: TileIndex): ByteArray? { + val local = store ?: return null + return withContext(ioDispatcher) { runCatching { local.readTile(source, tile) }.getOrNull() } + } + + private suspend fun writeLocal(source: TerrainSource, tile: TileIndex, webpBytes: ByteArray) { + val local = store ?: return + // A failed write is a slower next run, never a failed prediction. + withContext(ioDispatcher) { runCatching { local.writeTile(source, tile, webpBytes) } } + } + + /** The store splits tiles by tier, and the app's own terrain download writes them the same way. */ + private fun sourceFor(zoom: Int): TerrainSource = + if (zoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM) TerrainSource.REGIONAL else TerrainSource.GLOBAL + + private companion object { + /** + * In-flight requests. HTTP/2 multiplexes these over one connection and each is ~130 ms, so this is about how + * much of the round-trip latency we hide, not about sockets. + */ + const val DEFAULT_CONCURRENCY = 24 + } +} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index 463562b5c7f..5fb621a04d2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.coverage +import io.ktor.client.HttpClient import kotlinx.coroutines.Deferred import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -91,8 +92,12 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { } private companion object { - /** ~70 tiles cover a 25 km disc at z12, and one is ~256 KB — so this holds a working set plus room to pan. */ - const val DEFAULT_CAPACITY = 128 + /** + * A 25 km disc is ~70 tiles at z12 and a decoded tile is ~256 KB, so this holds a working set with room to pan. + * Asking for a deeper zoom will evict — 256 tiles at z13 — which the disk cache makes a decode rather than a + * download, but it is why z12 is the default. + */ + const val DEFAULT_CAPACITY = 192 } } @@ -109,4 +114,12 @@ internal object SharedTerrain { suspend fun forArchive(url: String, zoom: Int): TerrainCache = lock.withLock { caches.getOrPut("$url@$zoom") { TerrainCache() } } + + /** + * One HTTP client for the process. + * + * Building one per estimate cost seconds — the engine brings up its own selector threads — for a client that is + * stateless once connected. Never closed: it is shared, and an estimate that closed it would break the next one. + */ + val http: HttpClient by lazy { HttpClient() } } diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index b4e825f93d2..dfde664367a 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -17,6 +17,9 @@ package org.meshtastic.feature.coverage import kotlinx.coroutines.runBlocking +import okio.FileSystem +import okio.Path.Companion.toOkioPath +import org.meshtastic.feature.map.terrain.TerrainTileStore import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO @@ -43,7 +46,8 @@ object CoverageDemo { val radials = args.getOrNull(3)?.toIntOrNull() ?: DEFAULT_RADIALS val rings = args.getOrNull(4)?.toIntOrNull() ?: DEFAULT_RINGS val profileStepKm = args.getOrNull(5)?.toDoubleOrNull() ?: DEFAULT_PROFILE_STEP_KM - val readers = args.getOrNull(6)?.toIntOrNull() ?: MapterhornElevation.DEFAULT_PREFETCH_READERS + val cacheDir = File(args.getOrNull(6) ?: "build/terrain-cache").apply { mkdirs() } + val zoom = args.getOrNull(7)?.toIntOrNull() ?: MapterhornElevation.DEFAULT_ZOOM val site = Site( @@ -59,11 +63,13 @@ object CoverageDemo { ) println("site ${site.latitude}, ${site.longitude} ${site.frequencyMhz} MHz ${site.txPowerDbm} dBm") - MapterhornElevation(site.coverageBounds()).use { elevation -> - println("terrain: ${if (elevation.isRegional) "regional" else "global"} archive at z${elevation.zoomLevel}") + // A real on-disk cache, so a second run of this task pays nothing for terrain. + val store = TerrainTileStore(FileSystem.SYSTEM, cacheDir.toOkioPath()) + MapterhornElevation(site.coverageBounds(), store, zoom).use { elevation -> + println("terrain: z${elevation.zoomLevel} XYZ tiles, cache ${cacheDir.absolutePath}") var warmed = 0 - val prefetchMs = measureTimeMillis { warmed = runBlocking { elevation.prefetch(concurrency = readers) } } - println("prefetched $warmed terrain tiles in ${prefetchMs}ms with $readers readers") + val prefetchMs = measureTimeMillis { warmed = runBlocking { elevation.prefetch() } } + println("prefetched $warmed terrain tiles in ${prefetchMs}ms") lateinit var coverage: CoverageGrid val ms = measureTimeMillis { coverage = runBlocking { @@ -83,7 +89,7 @@ object CoverageDemo { val finite = coverage.dbm.filter { !it.isNaN() } println("rx dBm range: ${"%.1f".format(finite.min())} .. ${"%.1f".format(finite.max())}") - reportWarmSweeps(elevation, site, radials, rings, profileStepKm) + reportWarmSweeps(elevation, store, site, radials, rings, profileStepKm) File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) renderPng(coverage, File(outDir, "coverage.png")) @@ -100,6 +106,7 @@ object CoverageDemo { */ private fun reportWarmSweeps( elevation: MapterhornElevation, + store: TerrainTileStore, site: Site, radials: Int, rings: Int, @@ -116,7 +123,7 @@ object CoverageDemo { val fresh = measureTimeMillis { runBlocking { - MapterhornElevation().use { second -> + MapterhornElevation(store = store, zoom = elevation.zoomLevel).use { second -> second.prefetch(site) LocalCoverage(second) .sweepPolar(site, radials = radials, rings = rings, profileStepKm = profileStepKm) diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt index 75df81378bb..df045c2805f 100644 --- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt @@ -28,6 +28,19 @@ object MapterhornEndpoints { const val GLOBAL_PMTILES_URL = "https://download.mapterhorn.com/planet.pmtiles" + /** + * Plain XYZ tiles, the endpoint Mapterhorn's own migration guide documents. + * + * Probed 2026-09-16 at Seattle: z0–16 all 200 (~56–95 KB each, ~130 ms, Cloudflare-cached for a + * week, `access-control-allow-origin: *`), z17+ 404. So it serves the regional detail the + * per-z6-tile archives were meant to, and does it as independent cacheable requests rather than + * range reads into one seekable channel — which is what made bulk terrain sampling serial. + */ + fun tileUrl(zoom: Int, x: Int, y: Int): String = "https://tiles.mapterhorn.com/$zoom/$x/$y.webp" + + /** Deepest zoom [tileUrl] serves. Deeper requests 404. */ + const val TILES_MAX_ZOOM = 16 + /** Global coverage tops out at this zoom; deeper detail (if any) only exists in a regional archive. */ const val GLOBAL_MAX_ZOOM = 12 @@ -40,7 +53,13 @@ object MapterhornEndpoints { private const val REGIONAL_ARCHIVE_ZOOM = 6 /** - * The regional archive URL for [bounds], or `null` if [bounds] doesn't fit inside one z6 tile — matching iOS's + * The regional archive URL for [bounds], or `null` if [bounds] doesn't fit inside one z6 tile. + * + * **Every URL this builds 404s as of 2026-09-16** — five z6 tiles probed plus the bare host, while + * `planet.pmtiles` still serves. Whether the naming changed or Mapterhorn stopped publishing + * per-region archives is unverified; [tileUrl] reaches the same detail either way. + + * Original contract: `null` if [bounds] doesn't fit inside one z6 tile — matching iOS's * "global-only terrain is normal, never an error" rule: a region spanning more than one z6 tile just gets no * regional detail, rather than trying to stitch multiple regional archives together. */ From fe97b25de33ef9df9c3ce65cb491551f87439489 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:43:40 -0500 Subject: [PATCH 11/27] fix(coverage): a failed terrain request must not read as sea level download() turned every non-success into null, and null is cached as flat ground for the rest of the process - so one throttled request (24 in flight against Cloudflare makes 429 plausible), one timeout or one cancellation would quietly turn a mountain into sea level and the prediction would look fine. Only 404 is real absence now; anything else throws, after one retry for a transient blip, and the caller surfaces a failed estimate. Also rewrites the regionalUrlFor KDoc a scripted edit had garbled, and drops MapterhornElevation.isPersistent, which nothing read. --- .../feature/coverage/MapterhornElevation.kt | 3 -- .../feature/coverage/MapterhornTiles.kt | 28 ++++++++++++++++--- .../map/terrain/MapterhornEndpoints.kt | 20 ++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 881565176a2..d5a0075d945 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -134,9 +134,6 @@ class MapterhornElevation( val tilesResident: Int get() = cache?.size ?: 0 - /** True when this instance keeps what it downloads. */ - val isPersistent: Boolean = store != null - private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = cache().getOrFetch(key) { scope.async { decode(x, y) } } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index d91611ab68e..97d761339ac 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -19,7 +19,9 @@ package org.meshtastic.feature.coverage import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.HttpStatusCode import io.ktor.http.isSuccess +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -54,12 +56,27 @@ internal class MapterhornTiles( return readLocal(source, tile) ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } } + /** + * Only 404 means "no tile here" — ocean, or past the endpoint's deepest zoom — and only 404 is allowed to return + * null, because null is cached as flat ground for the rest of the process. A throttled or failed request that + * returned null would quietly turn a mountain into sea level, so it throws and the caller surfaces a failed + * estimate instead. + */ private suspend fun download(zoom: Int, x: Int, y: Int): ByteArray? = gate.withPermit { - runCatching { - val response = http.get(MapterhornEndpoints.tileUrl(zoom, x, y)) - if (response.status.isSuccess()) response.bodyAsBytes() else null + val url = MapterhornEndpoints.tileUrl(zoom, x, y) + val response = + runCatching { http.get(url) } + .getOrElse { + // One retry: a sweep asks for its whole disc at once, and losing an estimate to a single + // transient blip is worse than the second the retry costs. + delay(RETRY_DELAY_MS) + http.get(url) + } + when { + response.status.isSuccess() -> response.bodyAsBytes() + response.status == HttpStatusCode.NotFound -> null + else -> error("terrain tile $zoom/$x/$y: ${response.status}") } - .getOrNull() } /** Okio is blocking, so file access goes to IO rather than stalling a compute thread. */ @@ -84,5 +101,8 @@ internal class MapterhornTiles( * much of the round-trip latency we hide, not about sockets. */ const val DEFAULT_CONCURRENCY = 24 + + /** Long enough for a momentary throttle to clear, short enough not to be felt. */ + const val RETRY_DELAY_MS = 500L } } diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt index df045c2805f..d8891165bf1 100644 --- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt @@ -31,10 +31,10 @@ object MapterhornEndpoints { /** * Plain XYZ tiles, the endpoint Mapterhorn's own migration guide documents. * - * Probed 2026-09-16 at Seattle: z0–16 all 200 (~56–95 KB each, ~130 ms, Cloudflare-cached for a - * week, `access-control-allow-origin: *`), z17+ 404. So it serves the regional detail the - * per-z6-tile archives were meant to, and does it as independent cacheable requests rather than - * range reads into one seekable channel — which is what made bulk terrain sampling serial. + * Probed 2026-09-16 at Seattle: z0–16 all 200 (~56–95 KB each, ~130 ms, Cloudflare-cached for a week, + * `access-control-allow-origin: *`), z17+ 404. So it serves the regional detail the per-z6-tile archives were meant + * to, and does it as independent cacheable requests rather than range reads into one seekable channel — which is + * what made bulk terrain sampling serial. */ fun tileUrl(zoom: Int, x: Int, y: Int): String = "https://tiles.mapterhorn.com/$zoom/$x/$y.webp" @@ -53,15 +53,13 @@ object MapterhornEndpoints { private const val REGIONAL_ARCHIVE_ZOOM = 6 /** - * The regional archive URL for [bounds], or `null` if [bounds] doesn't fit inside one z6 tile. - * - * **Every URL this builds 404s as of 2026-09-16** — five z6 tiles probed plus the bare host, while - * `planet.pmtiles` still serves. Whether the naming changed or Mapterhorn stopped publishing - * per-region archives is unverified; [tileUrl] reaches the same detail either way. - - * Original contract: `null` if [bounds] doesn't fit inside one z6 tile — matching iOS's + * The regional archive URL for [bounds], or `null` if [bounds] doesn't fit inside one z6 tile — matching iOS's * "global-only terrain is normal, never an error" rule: a region spanning more than one z6 tile just gets no * regional detail, rather than trying to stitch multiple regional archives together. + * + * **Every URL this builds 404s as of 2026-09-16.** Five z6 tiles were probed plus the bare host, while + * `planet.pmtiles` still serves; whether the naming changed or Mapterhorn stopped publishing per-region archives is + * unverified. [tileUrl] reaches the same detail and deeper, so new callers should prefer it. */ fun regionalUrlFor(bounds: GeoBounds): String? { if (!TerrainTileMath.fitsInSingleTile(REGIONAL_ARCHIVE_ZOOM, bounds)) return null From a08ac54f22259dabf21fc28d00704ff482a66ce8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:04:02 -0500 Subject: [PATCH 12/27] fix(coverage): clamp the planner URL, and keep a sweep's terrain inside the cache Two fixes replayed from the radius-scaling work that was rolled back. Both are reachable at the 30 km default and neither was caused by the wider one. max_range is a free-text field, so it could ask the hosted planner for more than its SPLAT!-in-wasm accepts - 150 km, or 70 in high-resolution mode. The URL is clamped now; the local engine keeps its own behaviour, which has no ceiling. The terrain zoom was fixed at z12 whatever area was being sampled, but tiles scale with the square of the radius and with 1/cos(latitude): a 30 km disc is ~120 tiles at mid latitudes and ~360 above 65N, and 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful - eviction is insertion order, so the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured once at ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds. zoomFitting drops a zoom until the box fits, which quarters the count each step and so converges immediately; a 30 km disc at mid latitudes still samples at z12, unchanged. --- .../feature/coverage/MapterhornElevation.kt | 33 ++++++- .../feature/coverage/TerrainCache.kt | 2 +- .../feature/coverage/TerrainZoomGuardTest.kt | 89 +++++++++++++++++++ .../map/component/SitePlannerParams.kt | 9 +- .../map/component/SitePlannerParamsTest.kt | 10 +++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index d5a0075d945..9ad9d0f1c7b 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -59,7 +59,7 @@ class MapterhornElevation( ) : ElevationSource, AutoCloseable { - private val zoom = zoom.coerceAtMost(MapterhornEndpoints.TILES_MAX_ZOOM) + private val zoom = zoomFitting(zoom.coerceAtMost(MapterhornEndpoints.TILES_MAX_ZOOM), bounds) // Shared, and never closed here: a caller's client is theirs, and the shared one outlives us. private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store) @@ -146,6 +146,9 @@ class MapterhornElevation( private class TileRequest(val key: Long, val x: Int, val y: Int) companion object { + /** Below this one tile spans a continent; the guard never needs to go further. */ + const val MIN_ZOOM = 6 + /** * ~37 m per pixel at mid latitudes, which matches the 50 m terrain profile step. * @@ -185,3 +188,31 @@ private const val STRAIGHT_ANGLE_DEG = 180.0 private const val HALF = 2.0 private const val KM_PER_DEG_LAT = 111.32 private const val DEG_TO_RAD = 0.017453292519943295 + +/** + * The deepest zoom at or below [wanted] whose tiles for [bounds] still fit the shared cache. + * + * Tiles scale with the square of the sampled radius and with 1/cos(latitude), so a fixed zoom is only ever right for + * one area. At z12 a 30 km disc is ~120 tiles at mid latitudes but ~360 above 65°N, and the radius is a free-text field + * — 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful: eviction is insertion order, so + * the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured once at + * ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds. + * + * Dropping a zoom quarters the tile count, so this converges immediately and leaves the common case untouched — a 30 km + * disc at mid latitudes still samples at z12. + */ +internal fun zoomFitting(wanted: Int, bounds: GeoBounds?): Int { + if (bounds == null) return wanted + var zoom = wanted + while (zoom > MapterhornElevation.MIN_ZOOM && tilesSpanning(bounds, zoom) > TerrainCache.DEFAULT_CAPACITY) { + zoom-- + } + return zoom +} + +/** How many tiles [bounds] covers at [zoom]. */ +internal fun tilesSpanning(bounds: GeoBounds, zoom: Int): Int { + val nw = TerrainTileMath.tileAt(zoom, bounds.north, bounds.west) + val se = TerrainTileMath.tileAt(zoom, bounds.south, bounds.east) + return (se.x - nw.x + 1) * (se.y - nw.y + 1) +} diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index 5fb621a04d2..fc8732d8694 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -91,7 +91,7 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { resolved = merged } - private companion object { + companion object { /** * A 25 km disc is ~70 tiles at z12 and a decoded tile is ~256 KB, so this holds a working set with room to pan. * Asking for a deeper zoom will evict — 256 tiles at z13 — which the disk cache makes a decode rather than a diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt new file mode 100644 index 00000000000..10347e89aeb --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import org.meshtastic.feature.map.terrain.GeoBounds +import kotlin.math.cos +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The guard that keeps a sweep's terrain inside the shared cache. + * + * Tiles scale with the square of the radius and with 1/cos(latitude), so a fixed zoom is only right for one area. Past + * the cache's capacity the failure is not graceful: eviction is insertion order, so the sweep evicts the tiles it is + * about to read and re-decodes the disc on every pass. + */ +class TerrainZoomGuardTest { + + private fun boundsAround(latitudeDeg: Double, radiusKm: Double): GeoBounds { + val latSpan = radiusKm / KM_PER_DEG_LAT + val lonSpan = latSpan / cos(latitudeDeg * DEG_TO_RAD) + return GeoBounds( + south = latitudeDeg - latSpan, + west = LONGITUDE - lonSpan, + north = latitudeDeg + latSpan, + east = LONGITUDE + lonSpan, + ) + } + + @Test + fun theOrdinaryCaseIsLeftAlone() { + // A default-radius disc at mid latitudes already fits, so the guard must not cost it detail. + val bounds = boundsAround(latitudeDeg = 47.6, radiusKm = 30.0) + assertEquals( + MapterhornElevation.DEFAULT_ZOOM, + zoomFitting(MapterhornElevation.DEFAULT_ZOOM, bounds), + "a 30 km disc at 47.6°N needs ${tilesSpanning(bounds, MapterhornElevation.DEFAULT_ZOOM)} tiles", + ) + } + + @Test + fun noRadiusOrLatitudeCanOverrunTheCache() { + // The radius is a free-text field and the app runs well north of the mid latitudes, so both + // of these are reachable without anyone doing anything unusual. + for (latitudeDeg in listOf(0.0, 30.0, 45.0, 51.0, 60.0, 70.0, 78.0)) { + for (radiusKm in listOf(5.0, 30.0, 50.0, 70.0, 100.0, 150.0, 300.0)) { + val bounds = boundsAround(latitudeDeg, radiusKm) + val zoom = zoomFitting(MapterhornElevation.DEFAULT_ZOOM, bounds) + val tiles = tilesSpanning(bounds, zoom) + assertTrue( + tiles <= TerrainCache.DEFAULT_CAPACITY, + "$latitudeDeg°N at $radiusKm km resolved to z$zoom, still $tiles tiles", + ) + } + } + } + + @Test + fun itNeverGoesDeeperThanAsked() { + val tiny = boundsAround(latitudeDeg = 47.6, radiusKm = 1.0) + assertTrue(zoomFitting(MapterhornElevation.DEFAULT_ZOOM, tiny) <= MapterhornElevation.DEFAULT_ZOOM) + } + + @Test + fun withoutBoundsThereIsNothingToFit() { + assertEquals(MapterhornElevation.DEFAULT_ZOOM, zoomFitting(MapterhornElevation.DEFAULT_ZOOM, null)) + } + + private companion object { + const val LONGITUDE = -122.3 + const val KM_PER_DEG_LAT = 111.32 + const val DEG_TO_RAD = 0.017453292519943295 + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt index 7346488e12f..0aaed373456 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt @@ -40,6 +40,10 @@ data class SitePlannerParams( val maxDbm: Double = DEFAULT_MAX_DBM, val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY, ) { + /** The hosted planner's own ceiling for the current mode. */ + private val hostedMaxRangeKm: Double + get() = if (highResolution) MAX_RANGE_HIGH_RES_KM else MAX_RANGE_STANDARD_KM + /** * Build the planner URL that prefills these params and auto-runs the simulation (`run=1`). * @@ -61,7 +65,10 @@ data class SitePlannerParams( append("&color_scale=").append(encodeQueryComponent(colorScale)) append("&rx_sensitivity=").append(rxSensitivityDbm) append("&rx_height=").append(rxHeightMeters) - append("&max_range=").append(maxRangeKm) + // Free-text field, so this can exceed what the hosted planner's SPLAT!-in-wasm accepts — + // lower still in high-resolution mode. Only the URL is clamped; the local engine has no + // such ceiling. + append("&max_range=").append(maxRangeKm.coerceAtMost(hostedMaxRangeKm)) if (highResolution) append("&high_res=1") append("&min_dbm=").append(minDbm) append("&max_dbm=").append(maxDbm) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt index 26a2c9ec1a9..05a9065bd46 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt @@ -89,4 +89,14 @@ class SitePlannerParamsTest { assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð")) assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~")) } + + @Test + fun `toQueryUrl clamps max_range to what the hosted planner accepts in each mode`() { + // Free text, so a user can ask for more than the hosted planner's SPLAT!-in-wasm handles. + // Only the URL is clamped - the local engine has no such ceiling. + val wide = SitePlannerParams(latitude = 51.05, longitude = -114.07, name = "Tower A", maxRangeKm = 400.0) + + assertTrue(wide.toQueryUrl("http://localhost:5173").contains("&max_range=150.0")) + assertTrue(wide.copy(highResolution = true).toQueryUrl("http://localhost:5173").contains("&max_range=70.0")) + } } From 57ff5fe8b3c26da36d0d1cf73e8f1fad45175b5d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:43:35 -0500 Subject: [PATCH 13/27] fix(coverage): honour the Display section instead of hardcoding how coverage looks The sheet has offered a palette picker, a min/max dBm range and an overlay transparency all along, and the local engine ignored every one of them: toSite() carried only the RF settings, and toGeoJson hardcoded a three-colour red-amber-green ramp, derived its own range from rx sensitivity to the strongest cell, and fixed opacity at 0.15..0.60. Whatever you picked, you got the same picture. All three are wired through now. The six palettes are the matplotlib colormaps the hosted planner renders with, stored as evenly spaced anchors and interpolated between - close to the hosted output rather than identical, which is the trade for not embedding six 256-entry tables. min/max dBm fix the ends of the ramp rather than letting the strongest cell define them, so two sites are comparable and the picker means what it says. Bands had to stop nesting for any of this to be visible. They were cumulative - "everything at or above this" - which survived a three-colour ramp at low opacity, but six nested fills paint over each other and the chosen colours never appear. A single overlay transparency is only meaningful when each pixel is painted once, too. Bands are disjoint now, [lo, hi), with the strongest running to infinity so a cell above the picker's ceiling does not punch a hole through the middle of the plot. The FeatureCollection records palette, min_dbm and max_dbm, so a saved layer says how it was rendered. --- .../desktop/map/DesktopSitePlannerSlot.kt | 11 +- .../feature/coverage/CoverageContours.kt | 77 ++++++----- .../feature/coverage/CoveragePalette.kt | 124 ++++++++++++++++++ .../feature/coverage/CoveragePaletteTest.kt | 94 +++++++++++++ .../feature/coverage/CoverageDemo.kt | 4 +- 5 files changed, 268 insertions(+), 42 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 9315eba4143..07b86e2d0a9 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -50,6 +50,7 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position import org.meshtastic.feature.coverage.CoverageGrid +import org.meshtastic.feature.coverage.CoverageStyle import org.meshtastic.feature.coverage.LocalCoverage import org.meshtastic.feature.coverage.MapterhornElevation import org.meshtastic.feature.coverage.Site @@ -129,7 +130,7 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { // Persist and draw it on the map, the same path the F-Droid flavour uses for // the WebView's GeoJSON — so the coverage survives the dialog closing and // shows up in the layers list like any other import. - layersManager.addGeoJsonLayer(current.name, swept.toGeoJson()) + layersManager.addGeoJsonLayer(current.name, swept.toGeoJson(current.toCoverageStyle())) session.moveTo(Position(longitude = current.longitude, latitude = current.latitude)) result = swept } @@ -177,6 +178,14 @@ private fun SitePlannerParams.toSite(): Site = Site( radiusKm = maxRangeKm, ) +/** The sheet's Display section: palette, the dBm range the ramp spans, and overlay transparency. */ +private fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle.fromTransparency( + palette = colorScale, + minDbm = minDbm, + maxDbm = maxDbm, + transparencyPercent = overlayTransparency, +) + @Composable private fun ComputingDialog(name: String, onCancel: () -> Unit) { Dialog(onDismissRequest = onCancel) { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 386474bbc37..08a40b15426 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -30,9 +30,8 @@ import kotlin.math.roundToLong * At ~100 m cells that reads as a solid coverage area, and it avoids the ambiguous-saddle handling a true isoline * tracer needs. */ -internal fun CoverageGrid.bands(thresholdsDbm: List): List = thresholdsDbm - .sorted() - .map { threshold -> +internal fun CoverageGrid.bands(ranges: List>): List = ranges + .map { range -> val rings = mutableListOf>>() // Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than // one per cell, and the map renders a handful of rings rather than thousands. @@ -40,21 +39,19 @@ internal fun CoverageGrid.bands(thresholdsDbm: List): List for (y in 0 until height - 1) { var x = 0 while (x < width - 1) { - if (taken[y * width + x] || !inBand(x, y, threshold)) { + if (taken[y * width + x] || !inBand(x, y, range)) { x++ continue } var runEnd = x - while ( - runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, threshold) - ) { + while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) { runEnd++ } // Extend downwards while the whole run stays in band. var runBottom = y while ( runBottom + 1 < height - 1 && - (x..runEnd).all { inBand(it, runBottom + 1, threshold) } && + (x..runEnd).all { inBand(it, runBottom + 1, range) } && (x..runEnd).none { taken[(runBottom + 1) * width + it] } ) { runBottom++ @@ -74,16 +71,23 @@ internal fun CoverageGrid.bands(thresholdsDbm: List): List x = runEnd + 1 } } - CoverageBand(threshold, rings) + CoverageBand(range.start, rings) } .filter { it.rings.isNotEmpty() } -private fun CoverageGrid.inBand(x: Int, y: Int, threshold: Double): Boolean { +/** + * Bands do not overlap — each cell belongs to exactly one. + * + * They used to nest ("everything at or above this"), which only worked because the fill was a fixed three-colour ramp + * at low opacity. With a palette, six nested fills paint over each other and the chosen colours never appear; and a + * single overlay transparency is only meaningful when each pixel is painted once. + */ +private fun CoverageGrid.inBand(x: Int, y: Int, range: ClosedFloatingPointRange): Boolean { val v = at(x, y) - return !v.isNaN() && v >= threshold + return !v.isNaN() && v >= range.start && v < range.endInclusive } -/** One iso-band: every cell at or above [thresholdDbm]. */ +/** One iso-band, labelled by the weakest signal it contains. */ internal class CoverageBand(val thresholdDbm: Double, val rings: List>>) /** @@ -92,19 +96,22 @@ internal class CoverageBand(val thresholdDbm: Double, val rings: List + val lower = style.minDbm + step * index + val upper = if (index == bandCount - 1) Double.POSITIVE_INFINITY else lower + step + lower..upper + } val features = - bands(thresholds) - .mapIndexed { index, band -> - val t = index.toDouble() / (bandCount - 1).coerceAtLeast(1) - val color = bandColor(t) + bands(ranges) + .map { band -> + val t = ((band.thresholdDbm - style.minDbm) / (style.maxDbm - style.minDbm)).coerceIn(0.0, 1.0) + val color = style.palette.colorAt(t) // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre // silently drops - the layer is added and nothing draws. @@ -113,15 +120,19 @@ fun CoverageGrid.toGeoJson(bandCount: Int = DEFAULT_BANDS): String { "[[" + ring.joinToString(",") { (lon, lat) -> "[$lon,$lat]" } + "]]" } """ {"type":"Feature","geometry":{"type":"MultiPolygon","coordinates":[$polygons]},""" + - """"properties":{"title":"≥ ${band.thresholdDbm.toFixed1()} dBm",""" + - """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color","fill-opacity":${bandOpacity(t)},""" + + """"properties":{"title":"\u2265 ${band.thresholdDbm.toFixed1()} dBm",""" + + """"dbm":${band.thresholdDbm.toFixed1()},"fill":"$color",""" + + """"fill-opacity":${style.opacity.toFixed1()},""" + """"stroke":"$color","stroke-opacity":0.0,"stroke-width":0}}""" } .joinToString(",\n") + if (features.isEmpty()) return EMPTY_FEATURE_COLLECTION + return """{ "type": "FeatureCollection", - "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812"}, + "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812", + "palette": "${style.palette.key}", "min_dbm": ${style.minDbm.toFixed1()}, "max_dbm": ${style.maxDbm.toFixed1()}}, "features": [ $features ] @@ -129,21 +140,7 @@ $features """ } -/** Weakest band red, through amber, to Meshtastic green at the strongest. */ -private fun bandColor(t: Double): String = when { - t < ONE_THIRD -> "#ef476f" - t < TWO_THIRDS -> "#ffd166" - else -> "#67ea94" -} - -/** Weaker bands are larger and sit underneath, so they stay faint. */ -private fun bandOpacity(t: Double): String = (BASE_OPACITY + t * OPACITY_RANGE).toFixed1() - private const val DEFAULT_BANDS = 6 -private const val ONE_THIRD = 0.34 -private const val TWO_THIRDS = 0.67 -private const val BASE_OPACITY = 0.15 -private const val OPACITY_RANGE = 0.45 private const val EMPTY_FEATURE_COLLECTION = """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}""" diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt new file mode 100644 index 00000000000..5b5603b440d --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlin.math.roundToInt + +/** + * The colour ramps the Site Planner's Display section offers, by the names it uses. + * + * These are the matplotlib colormaps the hosted planner renders with, carried here so the local engine honours the same + * picker rather than inventing its own colours. Each is stored as evenly spaced anchors and interpolated between: a + * handful of stops per ramp reproduces the perceptual shape closely enough for six discrete coverage bands, without + * embedding a 256-entry table each. + */ +@Suppress("MagicNumber") // Colour anchors are data; naming forty hex literals would obscure the ramps. +enum class CoveragePalette(val key: String, private val anchors: List) { + PLASMA("plasma", listOf(0x0D0887, 0x6A00A8, 0xB12A90, 0xE16462, 0xFCA636, 0xF0F921)), + VIRIDIS("viridis", listOf(0x440154, 0x414487, 0x2A788E, 0x22A884, 0x7AD151, 0xFDE725)), + CMR_MAP("CMRmap", listOf(0x000000, 0x3B2277, 0xA1417F, 0xE1663A, 0xE6AE3E, 0xD7D7A0, 0xFFFFFF)), + COOL("cool", listOf(0x00FFFF, 0xFF00FF)), + TURBO( + "turbo", + listOf(0x30123B, 0x4145AB, 0x4675ED, 0x39A2FC, 0x1BCFD4, 0x62FC6B, 0xD2E935, 0xFDA007, 0xF05B12, 0x7A0403), + ), + JET("jet", listOf(0x000080, 0x0000FF, 0x00FFFF, 0x7FFF7F, 0xFFFF00, 0xFF0000, 0x800000)), + ; + + /** The ramp sampled at [t] in 0..1, as a `#rrggbb` string. */ + fun colorAt(t: Double): String { + val clamped = t.coerceIn(0.0, 1.0) + if (anchors.size == 1) return hex(anchors[0]) + + val position = clamped * (anchors.size - 1) + val lower = position.toInt().coerceAtMost(anchors.size - 2) + val fraction = position - lower + return hex(mix(anchors[lower], anchors[lower + 1], fraction)) + } + + private fun mix(from: Int, to: Int, fraction: Double): Int { + val r = channel(from, RED_SHIFT) + (channel(to, RED_SHIFT) - channel(from, RED_SHIFT)) * fraction + val g = channel(from, GREEN_SHIFT) + (channel(to, GREEN_SHIFT) - channel(from, GREEN_SHIFT)) * fraction + val b = channel(from, BLUE_SHIFT) + (channel(to, BLUE_SHIFT) - channel(from, BLUE_SHIFT)) * fraction + return (r.roundToInt() shl RED_SHIFT) or (g.roundToInt() shl GREEN_SHIFT) or b.roundToInt() + } + + private fun channel(color: Int, shift: Int): Double = ((color shr shift) and CHANNEL_MASK).toDouble() + + private fun hex(color: Int): String = buildString { + append('#') + for (shift in listOf(RED_SHIFT, GREEN_SHIFT, BLUE_SHIFT)) { + val value = (color shr shift) and CHANNEL_MASK + append(HEX_DIGITS[value shr NIBBLE_BITS]) + append(HEX_DIGITS[value and NIBBLE_MASK]) + } + } + + companion object { + /** The palette the planner names, or [PLASMA] — its own default — when the name is unknown. */ + fun forKey(key: String?): CoveragePalette = + entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: PLASMA + + private const val RED_SHIFT = 16 + private const val GREEN_SHIFT = 8 + private const val BLUE_SHIFT = 0 + private const val CHANNEL_MASK = 0xFF + private const val NIBBLE_BITS = 4 + private const val NIBBLE_MASK = 0xF + private val HEX_DIGITS = "0123456789abcdef".toCharArray() + } +} + +/** + * How a [CoverageGrid] is drawn — the planner's Display section, which the local engine used to ignore entirely while + * the sheet still offered it. + * + * [minDbm] and [maxDbm] fix the ends of the ramp rather than letting the strongest cell define them, so two sites are + * comparable and the picker means what it says. + */ +class CoverageStyle( + val palette: CoveragePalette = CoveragePalette.PLASMA, + val minDbm: Double = DEFAULT_MIN_DBM, + val maxDbm: Double = DEFAULT_MAX_DBM, + /** 0 is invisible, 1 is solid — the planner's transparency percentage inverted. */ + val opacity: Double = DEFAULT_OPACITY, +) { + init { + require(maxDbm > minDbm) { "maxDbm must exceed minDbm, got $minDbm..$maxDbm" } + } + + companion object { + const val DEFAULT_MIN_DBM = -130.0 + const val DEFAULT_MAX_DBM = -80.0 + const val DEFAULT_OPACITY = 0.5 + + /** From the planner's 0–100 transparency, where 0 is opaque. */ + fun fromTransparency( + palette: String?, + minDbm: Double, + maxDbm: Double, + transparencyPercent: Int, + ): CoverageStyle = CoverageStyle( + palette = CoveragePalette.forKey(palette), + minDbm = minDbm, + maxDbm = maxDbm, + opacity = 1.0 - (transparencyPercent.coerceIn(0, PERCENT) / PERCENT.toDouble()), + ) + + private const val PERCENT = 100 + } +} diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt new file mode 100644 index 00000000000..c4c471c5d66 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The Display section's palette picker, which the local engine ignored entirely while the sheet went on offering it — + * every estimate came out in the same hardcoded red-amber-green whatever was chosen. + */ +class CoveragePaletteTest { + + @Test + fun everyPaletteThePlannerOffersResolves() { + // Mirrors SitePlannerParams.COLOR_SCALES; a name the sheet can produce must not fall back. + for (key in listOf("plasma", "viridis", "CMRmap", "cool", "turbo", "jet")) { + assertEquals(key, CoveragePalette.forKey(key).key, "planner offers '$key'") + } + } + + @Test + fun anUnknownOrAbsentNameFallsBackToThePlannersOwnDefault() { + assertEquals(CoveragePalette.PLASMA, CoveragePalette.forKey(null)) + assertEquals(CoveragePalette.PLASMA, CoveragePalette.forKey("nonesuch")) + } + + @Test + fun theRampSpansItsEndpointsAndStaysWellFormed() { + for (palette in CoveragePalette.entries) { + for (t in listOf(0.0, 0.25, 0.5, 0.75, 1.0)) { + val color = palette.colorAt(t) + assertTrue(Regex("^#[0-9a-f]{6}$").matches(color), "${palette.key} at $t produced '$color'") + } + assertNotEquals( + palette.colorAt(0.0), + palette.colorAt(1.0), + "${palette.key} should not start and end on the same colour", + ) + } + } + + @Test + fun outOfRangePositionsClampRatherThanWrap() { + for (palette in CoveragePalette.entries) { + assertEquals(palette.colorAt(0.0), palette.colorAt(-1.0)) + assertEquals(palette.colorAt(1.0), palette.colorAt(2.0)) + } + } + + @Test + fun knownAnchorsComeBackExactly() { + // The ends are anchors, so no interpolation should touch them. + assertEquals("#0d0887", CoveragePalette.PLASMA.colorAt(0.0)) + assertEquals("#f0f921", CoveragePalette.PLASMA.colorAt(1.0)) + assertEquals("#440154", CoveragePalette.VIRIDIS.colorAt(0.0)) + assertEquals("#fde725", CoveragePalette.VIRIDIS.colorAt(1.0)) + // cool is a straight two-stop ramp #00ffff -> #ff00ff, so its midpoint is exactly halfway. + assertEquals("#8080ff", CoveragePalette.COOL.colorAt(0.5)) + } + + @Test + fun transparencyBecomesOpacityTheRightWayRound() { + // The planner calls it transparency: 0 means solid, 100 means invisible. + assertEquals(1.0, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 0).opacity) + assertEquals(0.5, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 50).opacity) + assertEquals(0.0, CoverageStyle.fromTransparency("plasma", -130.0, -80.0, 100).opacity) + } + + @Test + fun theStyleCarriesThePickedPaletteAndRange() { + val style = CoverageStyle.fromTransparency("turbo", minDbm = -120.0, maxDbm = -70.0, transparencyPercent = 25) + assertEquals(CoveragePalette.TURBO, style.palette) + assertEquals(-120.0, style.minDbm) + assertEquals(-70.0, style.maxDbm) + assertEquals(0.75, style.opacity) + } +} diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index dfde664367a..6ef8b7b4e52 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -48,6 +48,7 @@ object CoverageDemo { val profileStepKm = args.getOrNull(5)?.toDoubleOrNull() ?: DEFAULT_PROFILE_STEP_KM val cacheDir = File(args.getOrNull(6) ?: "build/terrain-cache").apply { mkdirs() } val zoom = args.getOrNull(7)?.toIntOrNull() ?: MapterhornElevation.DEFAULT_ZOOM + val style = CoverageStyle(palette = CoveragePalette.forKey(args.getOrNull(8))) val site = Site( @@ -91,7 +92,8 @@ object CoverageDemo { reportWarmSweeps(elevation, store, site, radials, rings, profileStepKm) - File(outDir, "coverage.geojson").writeText(coverage.toGeoJson()) + println("palette: ${style.palette.key}") + File(outDir, "coverage.geojson").writeText(coverage.toGeoJson(style)) renderPng(coverage, File(outDir, "coverage.png")) println("wrote ${outDir.absolutePath}/coverage.{png,geojson}") } From 7d52fb670ab64259ba074f44deee7ef7e88faabb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:50:00 -0500 Subject: [PATCH 14/27] fix(coverage): spread bands across the whole ramp, not its lower bounds Colouring each band by where its floor sits means the strongest band samples at (n-1)/n, so the top of the palette is never drawn - plasma stopped at orange and never reached its yellow. Bands are spread across the band index instead, so the first and last bands are the ends of the chosen ramp. At the default six bands this now emits plasma and viridis exactly on their matplotlib anchors rather than interpolated: plasma #0d0887 #6a00a8 #b12a90 #e16462 #fca636 #f0f921 viridis #440154 #414487 #2a788e #22a884 #7ad151 #fde725 --- .../meshtastic/feature/coverage/CoverageContours.kt | 7 ++++++- .../feature/coverage/CoveragePaletteTest.kt | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 08a40b15426..253cc88fc9d 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.coverage import kotlin.math.abs +import kotlin.math.roundToInt import kotlin.math.roundToLong /** @@ -110,7 +111,11 @@ fun CoverageGrid.toGeoJson(style: CoverageStyle = CoverageStyle(), bandCount: In val features = bands(ranges) .map { band -> - val t = ((band.thresholdDbm - style.minDbm) / (style.maxDbm - style.minDbm)).coerceIn(0.0, 1.0) + // Spread across the band INDEX, not its lower bound. Colouring a band by where its + // floor sits means the last one samples at 5/6 and the palette's brightest end never + // appears at all — plasma stopped at orange and never reached its yellow. + val index = ((band.thresholdDbm - style.minDbm) / step).roundToInt() + val t = (index.toDouble() / (bandCount - 1).coerceAtLeast(1)).coerceIn(0.0, 1.0) val color = style.palette.colorAt(t) // GeoJSON MultiPolygon nests coordinates[polygon][ring][position]. Emitting the rings // one level flatter makes a Polygon-with-holes wearing a MultiPolygon label, which MapLibre diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt index c4c471c5d66..9c378bc4b01 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt @@ -91,4 +91,17 @@ class CoveragePaletteTest { assertEquals(-70.0, style.maxDbm) assertEquals(0.75, style.opacity) } + + @Test + fun bandsSpanTheWholeRampIncludingItsBrightestEnd() { + // Colouring each band by its lower bound leaves the top band at (n-1)/n, so the palette's + // final colour is never drawn - plasma stopped at orange and never reached its yellow. + val bandCount = 6 + val palette = CoveragePalette.PLASMA + val colors = (0 until bandCount).map { index -> palette.colorAt(index.toDouble() / (bandCount - 1)) } + + assertEquals(palette.colorAt(0.0), colors.first()) + assertEquals(palette.colorAt(1.0), colors.last()) + assertEquals(bandCount, colors.distinct().size, "each band should get its own colour: $colors") + } } From fc59e2a97b538ac88f45db2049fcca631941ccc2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 19 Sep 2026 14:07:57 -0500 Subject: [PATCH 15/27] fix(coverage): name the preview canvas colours detekt's MagicNumber flagged the two ARGB literals in the coverage preview. They are the plot's dark background and the shade of a cell the receiver cannot hear; both now sit beside the file's other constants. --- .../org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 07b86e2d0a9..3322ae6fdaf 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -242,7 +242,7 @@ private fun CoveragePlot(coverage: CoverageGrid, modifier: Modifier = Modifier) Box(modifier) { Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) { - drawRect(Color(0xFF14151C)) + drawRect(PLOT_BACKGROUND) val cw = size.width / coverage.width val ch = size.height / coverage.height for (y in 0 until coverage.height) { @@ -261,7 +261,7 @@ private fun CoveragePlot(coverage: CoverageGrid, modifier: Modifier = Modifier) } private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Color { - if (dbm < sensitivity) return Color(0xFF22232C) + if (dbm < sensitivity) return BELOW_SENSITIVITY val t = ((dbm - sensitivity) / (strongest - sensitivity)).coerceIn(0.0, 1.0).toFloat() val r = if (t < HALF) 1f else (1f - (t - HALF) * 2f).coerceIn(0f, 1f) val g = if (t < HALF) (t * 2f).coerceIn(0f, 1f) else 1f @@ -276,3 +276,7 @@ private const val DOT_RADIUS = 2.5f private const val TX_RADIUS = 5f private const val HALF = 0.5f private const val GREEN_FLOOR = 0.24f + +// The preview's own dark canvas, and the shade of a cell the receiver cannot hear. +private val PLOT_BACKGROUND = Color(0xFF14151C) +private val BELOW_SENSITIVITY = Color(0xFF22232C) From 888b3b39897d45cccc2518b5fb7d120b4b74b136 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 19 Sep 2026 16:56:11 -0500 Subject: [PATCH 16/27] build: list feature:coverage in the root module set --- build-logic/convention/src/main/kotlin/RootConventionPlugin.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt index fa98d8a4ba5..7328547d60e 100644 --- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt @@ -109,6 +109,7 @@ private val ALL_MODULES_FULL = ":feature:intro", ":feature:messaging", ":feature:connections", + ":feature:coverage", ":feature:discovery", ":feature:docs", ":feature:map", From 9925aff3b73326087daa8d25955b3c93f8b0cc32 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:38:25 -0500 Subject: [PATCH 17/27] test(konsist): allow coverage its terrain import and its owned scope --- .../core/konsist/CoroutineScopeConstructionTest.kt | 1 + .../org/meshtastic/core/konsist/ModuleBoundaryTest.kt | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt index 6d987e29b1a..a7fd13fa973 100644 --- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt +++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt @@ -74,6 +74,7 @@ class CoroutineScopeConstructionTest { "desktopApp/main/DesktopMessageQueue.kt", "desktopApp/main/DesktopPreferencesDataSource.kt", "desktopApp/main/NoopStubs.kt", + "feature/coverage/commonMain/MapterhornElevation.kt", "feature/discovery/commonMain/DiscoveryScanEngine.kt", "feature/docs/commonMain/ChirpySessionHolder.kt", "feature/firmware/commonMain/BleOtaTransport.kt", diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt index 339b0e54192..3f1590feb43 100644 --- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt +++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt @@ -27,8 +27,12 @@ import kotlin.test.assertTrue */ class ModuleBoundaryTest { - /** The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles. */ - private val allowedFeatureImports = mapOf("map-maplibre" to setOf("map", "map-terrain")) + /** + * The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles; coverage samples + * the same elevation tiles. + */ + private val allowedFeatureImports = + mapOf("map-maplibre" to setOf("map", "map-terrain"), "coverage" to setOf("map-terrain")) private val sourceFiles = Konsist.scopeFromProject().files.filterNot { it.isNestedAgentWorktree() } From a26a0aa0c4eea1e49606a8706028cd1ee320375b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:38:35 -0500 Subject: [PATCH 18/27] style(coverage): apply main's formatter --- .../feature/coverage/MapterhornTiles.kt | 17 +++++++++-------- .../meshtastic/feature/coverage/TerrainCache.kt | 10 ++++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index 97d761339ac..0a00e51afa4 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -64,14 +64,15 @@ internal class MapterhornTiles( */ private suspend fun download(zoom: Int, x: Int, y: Int): ByteArray? = gate.withPermit { val url = MapterhornEndpoints.tileUrl(zoom, x, y) - val response = - runCatching { http.get(url) } - .getOrElse { - // One retry: a sweep asks for its whole disc at once, and losing an estimate to a single - // transient blip is worse than the second the retry costs. - delay(RETRY_DELAY_MS) - http.get(url) - } + val response = runCatching { + http.get(url) + } + .getOrElse { + // One retry: a sweep asks for its whole disc at once, and losing an estimate to a single + // transient blip is worse than the second the retry costs. + delay(RETRY_DELAY_MS) + http.get(url) + } when { response.status.isSuccess() -> response.bodyAsBytes() response.status == HttpStatusCode.NotFound -> null diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index fc8732d8694..a963144d831 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -69,8 +69,9 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { // A cancelled fetch must not be handed to the next caller: the scope producing it belongs to // one MapterhornElevation, and cancelling that (the planner sheet being dismissed mid-run) // would otherwise leave a dead Deferred that fails every later sweep touching this tile. - val pending = - lock.withLock { inFlight[key]?.takeUnless { it.isCancelled } ?: produce().also { inFlight[key] = it } } + val pending = lock.withLock { + inFlight[key]?.takeUnless { it.isCancelled } ?: produce().also { inFlight[key] = it } + } val tile = pending.await() publish(listOf(key to tile)) return tile @@ -112,8 +113,9 @@ internal object SharedTerrain { private val caches = HashMap() private val lock = Mutex() - suspend fun forArchive(url: String, zoom: Int): TerrainCache = - lock.withLock { caches.getOrPut("$url@$zoom") { TerrainCache() } } + suspend fun forArchive(url: String, zoom: Int): TerrainCache = lock.withLock { + caches.getOrPut("$url@$zoom") { TerrainCache() } + } /** * One HTTP client for the process. From f2e946ee1abd24c1335e2c160be8af8a7e1987a0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:43:24 -0500 Subject: [PATCH 19/27] fix(coverage): clear detektTypeResolved, and stop reporting a dismissed run as failed The desktop slot caught the LaunchedEffect's own cancellation through runCatching, so closing the dialog mid-estimate set a failure. It now rethrows cancellation and takes its dispatcher from Koin. --- .../desktop/map/DesktopSitePlannerSlot.kt | 55 +++++++++++-------- .../feature/coverage/CoverageGrid.kt | 1 + .../feature/coverage/MapterhornElevation.kt | 3 +- .../feature/coverage/MapterhornTiles.kt | 1 + .../feature/coverage/PolarCoverage.kt | 1 + .../feature/coverage/CoverageDemo.kt | 8 ++- 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 3322ae6fdaf..d321181962d 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -43,12 +43,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import okio.FileSystem import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position +import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.feature.coverage.CoverageGrid import org.meshtastic.feature.coverage.CoverageStyle import org.meshtastic.feature.coverage.LocalCoverage @@ -84,6 +85,7 @@ import kotlin.math.roundToInt fun DesktopSitePlannerSlot(session: SitePlannerSession) { val sharedViewModel: SharedMapViewModel = koinViewModel() val layersManager: MapLayersManager = koinInject() + val dispatchers: CoroutineDispatchers = koinInject() val ourNode by sharedViewModel.ourNodeInfo.collectAsStateWithLifecycle() val channelSet by sharedViewModel.channelSet.collectAsStateWithLifecycle() @@ -116,25 +118,15 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { session.onDismiss() } LaunchedEffect(current) { - runCatching { - withContext(Dispatchers.Default) { - // A fresh source per estimate is free: decoded terrain lives in a shared - // cache, on disk under the store, and the HTTP client is shared too. - MapterhornElevation(store = terrainStore).use { source -> - source.prefetch(current.toSite()) - LocalCoverage(source).sweepGrid(current.toSite(), resolution = GRID) - } - } + val swept = estimateCoverage(current, terrainStore, dispatchers) { failure = it } + if (swept != null) { + // Persist and draw it on the map, the same path the F-Droid flavour uses for + // the WebView's GeoJSON — so the coverage survives the dialog closing and + // shows up in the layers list like any other import. + layersManager.addGeoJsonLayer(current.name, swept.toGeoJson(current.toCoverageStyle())) + session.moveTo(Position(longitude = current.longitude, latitude = current.latitude)) + result = swept } - .onSuccess { swept -> - // Persist and draw it on the map, the same path the F-Droid flavour uses for - // the WebView's GeoJSON — so the coverage survives the dialog closing and - // shows up in the layers list like any other import. - layersManager.addGeoJsonLayer(current.name, swept.toGeoJson(current.toCoverageStyle())) - session.moveTo(Position(longitude = current.longitude, latitude = current.latitude)) - result = swept - } - .onFailure { failure = it.message ?: it::class.simpleName } running = null } } @@ -268,12 +260,31 @@ private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Co return Color(r, g, GREEN_FLOOR) } +/** Null when the estimate failed, with the reason passed to [onFailure]; cancellation propagates. */ +private suspend fun estimateCoverage( + params: SitePlannerParams, + store: TerrainTileStore, + dispatchers: CoroutineDispatchers, + onFailure: (String?) -> Unit, +): CoverageGrid? = try { + withContext(dispatchers.default) { + // A fresh source per estimate is free: decoded terrain lives in a shared + // cache, on disk under the store, and the HTTP client is shared too. + MapterhornElevation(store = store).use { source -> + source.prefetch(params.toSite()) + LocalCoverage(source).sweepGrid(params.toSite(), resolution = GRID) + } + } +} catch (e: CancellationException) { + throw e +} catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + onFailure(e.message ?: e::class.simpleName) + null +} + private const val GRID = 256 private const val MILLIWATTS_PER_WATT = 1000.0 private const val PERCENT = 100 -private const val DEG_TO_RAD = 0.017453292519943295 -private const val DOT_RADIUS = 2.5f -private const val TX_RADIUS = 5f private const val HALF = 0.5f private const val GREEN_FLOOR = 0.24f diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt index f59e4f3f1c2..d7d41b58df8 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -25,6 +25,7 @@ package org.meshtastic.feature.coverage * * `NaN` marks a cell that was not computed. */ +@Suppress("LongParameterList") // a grid is its site, shape, bounds and samples; none of them groups class CoverageGrid( val site: Site, val width: Int, diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 9ad9d0f1c7b..6dfbcd1e7c5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -53,7 +53,7 @@ class MapterhornElevation( /** The area the caller will sample, which is what [prefetch] warms when given no box of its own. */ private val bounds: GeoBounds? = null, /** Where to keep tiles between launches. Without one, every launch re-downloads its terrain. */ - private val store: TerrainTileStore? = null, + store: TerrainTileStore? = null, zoom: Int = DEFAULT_ZOOM, http: HttpClient? = null, ) : ElevationSource, @@ -64,6 +64,7 @@ class MapterhornElevation( // Shared, and never closed here: a caller's client is theirs, and the shared one outlives us. private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store) + @Suppress("InjectDispatcher") // tile decoding is CPU work, and callers build this outside DI private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index 0a00e51afa4..f7a6892bd0d 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -62,6 +62,7 @@ internal class MapterhornTiles( * returned null would quietly turn a mountain into sea level, so it throws and the caller surfaces a failed * estimate instead. */ + @Suppress("SuspendFunSwallowedCancellation") // the retry's delay() rethrows a cancellation the first get swallowed private suspend fun download(zoom: Int, x: Int, y: Int): ByteArray? = gate.withPermit { val url = MapterhornEndpoints.tileUrl(zoom, x, y) val response = runCatching { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt index d894cb3d316..d0f1d341bd6 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt @@ -84,6 +84,7 @@ suspend fun LocalCoverage.sweepPolar( // scales with cores. Each writes its own disjoint slice of `out`, so there is nothing to guard. // The dispatcher is explicit: a caller on runBlocking or the main thread would otherwise hand // every radial to that one confined thread and the sweep would stay serial. + @Suppress("InjectDispatcher") coroutineScope { (0 until radials) .map { b -> diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 6ef8b7b4e52..12a5ef4396a 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -22,6 +22,7 @@ import okio.Path.Companion.toOkioPath import org.meshtastic.feature.map.terrain.TerrainTileStore import java.awt.image.BufferedImage import java.io.File +import java.util.Locale import javax.imageio.ImageIO import kotlin.math.roundToInt import kotlin.system.measureTimeMillis @@ -86,9 +87,12 @@ object CoverageDemo { "profile step ${profileStepKm * 1000} m), cores ${Runtime.getRuntime().availableProcessors()}", ) println("reachable: ${(coverage.reachableFraction * 100).roundToInt()}%") - println("max range: ${"%.1f".format(coverage.maxRangeKm)} km") + println("max range: ${"%.1f".format(Locale.ROOT, coverage.maxRangeKm)} km") val finite = coverage.dbm.filter { !it.isNaN() } - println("rx dBm range: ${"%.1f".format(finite.min())} .. ${"%.1f".format(finite.max())}") + println( + "rx dBm range: ${"%.1f".format(Locale.ROOT, finite.min())} .. " + + "%.1f".format(Locale.ROOT, finite.max()), + ) reportWarmSweeps(elevation, store, site, radials, rings, profileStepKm) From 342c76efc5ddc8201beb9577d6e699d6d2778595 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:58:05 -0500 Subject: [PATCH 20/27] refactor(coverage): follow kp1812's pathCenterLatitudeDeg rename --- .../kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index bbdc6f2031e..18b5bbde779 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -130,7 +130,7 @@ class LocalCoverage(private val elevation: ElevationSource, private val atmosphe txHeightM = site.txHeightM, rxHeightM = site.rxHeightM, timePercent = site.timePercent, - pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, + pathCenterLatitudeDeg = (site.latitude + rxLat) / 2.0, polarization = Polarization.VERTICAL, atmosphere = atmosphere, ) @@ -168,7 +168,7 @@ class LocalCoverage(private val elevation: ElevationSource, private val atmosphe txHeightM = site.txHeightM, rxHeightM = site.rxHeightM, timePercent = site.timePercent, - pathCentreLatitudeDeg = (site.latitude + rxLat) / 2.0, + pathCenterLatitudeDeg = (site.latitude + rxLat) / 2.0, polarization = Polarization.VERTICAL, atmosphere = atmosphere, ) From e5bf280d4f2b0d416e2e2dc20630b752bbe94a09 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:04:41 -0500 Subject: [PATCH 21/27] docs(coverage): American spelling, and a README that matches the module --- .../desktop/map/DesktopSitePlannerSlot.kt | 2 +- feature/coverage/README.md | 67 ++++++++----------- .../feature/coverage/CoverageContours.kt | 14 ++-- .../feature/coverage/CoverageGrid.kt | 2 +- .../feature/coverage/CoveragePalette.kt | 8 +-- .../feature/coverage/LocalCoverage.kt | 2 +- .../feature/coverage/PolarCoverage.kt | 4 +- .../feature/coverage/TerrainCache.kt | 6 +- .../feature/coverage/CoveragePaletteTest.kt | 8 +-- .../feature/coverage/LocalCoverageTest.kt | 6 +- .../feature/coverage/PolarCoverageTest.kt | 2 +- .../feature/coverage/CoverageDemo.kt | 2 +- 12 files changed, 54 insertions(+), 69 deletions(-) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index d321181962d..67a2fbf623d 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -226,7 +226,7 @@ private fun CoverageResultDialog(coverage: CoverageGrid, onDismiss: () -> Unit) } } -/** Top-down preview of the grid, coloured by predicted signal. */ +/** Top-down preview of the grid, colored by predicted signal. */ @Composable private fun CoveragePlot(coverage: CoverageGrid, modifier: Modifier = Modifier) { val sensitivity = coverage.site.rxSensitivityDbm diff --git a/feature/coverage/README.md b/feature/coverage/README.md index 28ef5e38aaf..1650224422c 100644 --- a/feature/coverage/README.md +++ b/feature/coverage/README.md @@ -1,52 +1,41 @@ -# feature:coverage — SPIKE +# feature:coverage -Local RF coverage, replacing the headless-WebView hand-off to the hosted Site Planner. +Computes RF coverage in-process with +[`org.meshtastic:kp1812`](https://github.com/meshtastic/kp1812), an implementation of +Recommendation ITU-R P.1812, over Mapterhorn terrain. It works offline once the terrain is +cached. -## What it replaces +## Where it runs -| Host | Today | With this | -| --- | --- | --- | -| Android | 319-line hidden `WebView` loading site.meshtastic.org, JS bridge, 45 s timeout, needs network | in-process, offline once terrain is cached | -| Desktop | opens a browser; user **exports a `.geojson` and re-imports it by hand** | in-process | - -`SitePlannerRunner.kt` is load-bearing in ways that read as a warning: the WebView must be -`alpha(0)` *but still attached and 280 dp* or WebGL never gets a context; it carries a deferred -retry for the system-WebView provider-update race; and `shouldOverrideUrlLoading` locks -navigation to the planner's origin so nothing else can reach `onCoverage`. All of that exists to -work around running a browser to do arithmetic. +| Host | Coverage | +| --- | --- | +| Desktop | `DesktopSitePlannerSlot` runs `LocalCoverage` and adds the result to the map as a GeoJSON layer | +| Android | Not wired. Both flavors still drive the hosted Site Planner in a hidden `WebView` (`SitePlannerRunner`) | ## Shape +```text +Site + ElevationSource -> LocalCoverage.sweepGrid() -> CoverageGrid -> toGeoJson(CoverageStyle) + | + +-- org.meshtastic:kp1812 (ITU-R P.1812) ``` -Site + ElevationSource ──► LocalCoverage.sweep() ──► Coverage(points: List) - │ - └─ org.meshtastic:kp1812 (ITU-R P.1812) -``` - -`ElevationSource` is a single suspend method. The app backs it with `feature/map-terrain`'s -Mapterhorn tiles; tests back it with a lambda, which is why the suite needs no network, no -WebView and no terrain download. -## Scope and caveats +The model, the `ElevationSource` seam, and the GeoJSON export live in `commonMain`. Only the +`java.awt` demo renderer is in `jvmMain`. `ElevationSource` is a single suspend method: the app +backs it with Mapterhorn tiles, and tests back it with a lambda, so the suite needs no network, +`WebView`, or terrain download. -- **`jvm()` only.** `kp1812` publishes no `androidTarget` — Android is meant to consume its `jvm` - artifact, the same choice `kzstd` makes — and proving that resolution path is a separate - question from proving the model works. Desktop is also where the current experience is worst. -- **Different model.** P.1812 is not ITM. Predictions will not match the hosted planner pixel for - pixel, and that is expected rather than a defect. -- **No UI wiring.** This is computation plus tests. Replacing `DesktopSitePlannerSlot` is the next - step and needs the full android baseline run. -- **`kp1812` is unpublished**, so the spike resolves it from `mavenLocal` via the repo's existing - `-PuseMavenLocal` flag. Publish it with `./gradlew publishJvmPublicationToMavenLocal - publishKotlinMultiplatformPublicationToMavenLocal` from the sibling checkout. +P.1812 is a different model from the hosted planner's SPLAT! ITM, so predictions don't match it +pixel for pixel. ## Tests -Behavioural, not conformance — `kp1812` already checks itself against the ITU reference: +The tests are behavioral. `kp1812` checks the model against the ITU reference itself, so these +check that this module drives it correctly: -- signal decays with distance over flat ground -- a 400 m ridge measurably shadows what is behind it -- more transmit power reaches at least as far -- `reachable` agrees with the receiver sensitivity -- the sweep covers every bearing -- the geodesy round-trips +- Signal decays with distance over flat ground. +- A 400 m ridge shadows what's behind it. +- More transmit power reaches at least as far. +- `reachable` agrees with the receiver sensitivity. +- Every bearing contributes equally to the sweep. +- The geodesy round-trips. diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 253cc88fc9d..c886c8ad105 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -77,18 +77,15 @@ internal fun CoverageGrid.bands(ranges: List>): .filter { it.rings.isNotEmpty() } /** - * Bands do not overlap — each cell belongs to exactly one. - * - * They used to nest ("everything at or above this"), which only worked because the fill was a fixed three-colour ramp - * at low opacity. With a palette, six nested fills paint over each other and the chosen colours never appear; and a - * single overlay transparency is only meaningful when each pixel is painted once. + * Bands do not overlap: each cell belongs to exactly one. Nested fills would paint over each other and hide the chosen + * colors, and a single overlay transparency is only meaningful when each pixel is painted once. */ private fun CoverageGrid.inBand(x: Int, y: Int, range: ClosedFloatingPointRange): Boolean { val v = at(x, y) return !v.isNaN() && v >= range.start && v < range.endInclusive } -/** One iso-band, labelled by the weakest signal it contains. */ +/** One iso-band, labeled by the weakest signal it contains. */ internal class CoverageBand(val thresholdDbm: Double, val rings: List>>) /** @@ -111,9 +108,8 @@ fun CoverageGrid.toGeoJson(style: CoverageStyle = CoverageStyle(), bandCount: In val features = bands(ranges) .map { band -> - // Spread across the band INDEX, not its lower bound. Colouring a band by where its - // floor sits means the last one samples at 5/6 and the palette's brightest end never - // appears at all — plasma stopped at orange and never reached its yellow. + // Spread across the band INDEX, not its lower bound. Coloring a band by where its + // floor sits samples the last one at 5/6, so the palette's brightest end never appears. val index = ((band.thresholdDbm - style.minDbm) / step).roundToInt() val t = (index.toDouble() / (bandCount - 1).coerceAtLeast(1)).coerceIn(0.0, 1.0) val color = style.palette.colorAt(t) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt index d7d41b58df8..570d0eff0f5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -78,7 +78,7 @@ class CoverageGrid( } /** - * Compute coverage onto a regular grid centred on [site]. + * Compute coverage onto a regular grid centered on [site]. * * Swept in polar and resampled: see [sweepPolar]. The grid resolution is therefore free of the prediction budget, so it * can be fine enough for the contour tracer without costing anything. diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt index 5b5603b440d..c173b3f9668 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt @@ -19,14 +19,14 @@ package org.meshtastic.feature.coverage import kotlin.math.roundToInt /** - * The colour ramps the Site Planner's Display section offers, by the names it uses. + * The color ramps the Site Planner's Display section offers, by the names it uses. * - * These are the matplotlib colormaps the hosted planner renders with, carried here so the local engine honours the same - * picker rather than inventing its own colours. Each is stored as evenly spaced anchors and interpolated between: a + * These are the matplotlib colormaps the hosted planner renders with, carried here so the local engine honors the same + * picker rather than inventing its own colors. Each is stored as evenly spaced anchors and interpolated between: a * handful of stops per ramp reproduces the perceptual shape closely enough for six discrete coverage bands, without * embedding a 256-entry table each. */ -@Suppress("MagicNumber") // Colour anchors are data; naming forty hex literals would obscure the ramps. +@Suppress("MagicNumber") // Color anchors are data; naming forty hex literals would obscure the ramps. enum class CoveragePalette(val key: String, private val anchors: List) { PLASMA("plasma", listOf(0x0D0887, 0x6A00A8, 0xB12A90, 0xE16462, 0xFCA636, 0xF0F921)), VIRIDIS("viridis", listOf(0x440154, 0x414487, 0x2A788E, 0x22A884, 0x7AD151, 0xFDE725)), diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index 18b5bbde779..9c5ab920881 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -195,7 +195,7 @@ class LocalCoverage(private val elevation: ElevationSource, private val atmosphe } } -/** Elevation above mean sea level, metres. Backed by Mapterhorn tiles in the app; trivially fakeable in tests. */ +/** Elevation above mean sea level, meters. Backed by Mapterhorn tiles in the app; trivially fakeable in tests. */ fun interface ElevationSource { suspend fun elevationMeters(latitude: Double, longitude: Double): Double } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt index d0f1d341bd6..6605ef91d92 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt @@ -24,7 +24,7 @@ import kotlin.math.cos import kotlin.math.floor /** - * Received power on a polar lattice centred on the site: [radials] bearings by [rings] ranges. + * Received power on a polar lattice centered on the site: [radials] bearings by [rings] ranges. * * Coverage is computed in polar and only then rasterised, because a receiver's prediction needs the whole terrain * profile back to the transmitter. On a radial every receiver shares one profile — the far one is the near one plus a @@ -142,7 +142,7 @@ fun PolarCoverage.toGrid(resolution: Int = DEFAULT_GRID): CoverageGrid { if (km > site.radiusKm) continue // Inside the innermost ring and outside the outermost one both clamp: the lattice covers - // the whole disc, and clamping is a nearer neighbour than any extrapolation would be. + // the whole disc, and clamping is a nearer neighbor than any extrapolation would be. val rPos = ((km - firstKm) / ringStepKm).coerceIn(0.0, (rings - 1).toDouble()) val r0 = floor(rPos).toInt().coerceAtMost(rings - 1) val r1 = (r0 + 1).coerceAtMost(rings - 1) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index a963144d831..023a001c278 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -44,7 +44,7 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { private val inFlight = LinkedHashMap>() // A read-only snapshot of what has decoded. Sampling is overwhelmingly hits, and taking the - // mutex for each would serialise a parallel sweep on this one lock. + // mutex for each would serialize a parallel sweep on this one lock. @Volatile private var resolved: Map = emptyMap() /** How many tiles are decoded and resident. */ @@ -66,8 +66,8 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { * cache outlives any one [MapterhornElevation] and must not hold its scope. */ suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? { - // A cancelled fetch must not be handed to the next caller: the scope producing it belongs to - // one MapterhornElevation, and cancelling that (the planner sheet being dismissed mid-run) + // A canceled fetch must not be handed to the next caller: the scope producing it belongs to + // one MapterhornElevation, and canceling that (the planner sheet being dismissed mid-run) // would otherwise leave a dead Deferred that fails every later sweep touching this tile. val pending = lock.withLock { inFlight[key]?.takeUnless { it.isCancelled } ?: produce().also { inFlight[key] = it } diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt index 9c378bc4b01..60d4f486d7e 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt @@ -51,7 +51,7 @@ class CoveragePaletteTest { assertNotEquals( palette.colorAt(0.0), palette.colorAt(1.0), - "${palette.key} should not start and end on the same colour", + "${palette.key} should not start and end on the same color", ) } } @@ -94,14 +94,14 @@ class CoveragePaletteTest { @Test fun bandsSpanTheWholeRampIncludingItsBrightestEnd() { - // Colouring each band by its lower bound leaves the top band at (n-1)/n, so the palette's - // final colour is never drawn - plasma stopped at orange and never reached its yellow. + // Coloring each band by its lower bound leaves the top band at (n-1)/n, so the palette's + // final color is never drawn and plasma never reaches its yellow. val bandCount = 6 val palette = CoveragePalette.PLASMA val colors = (0 until bandCount).map { index -> palette.colorAt(index.toDouble() / (bandCount - 1)) } assertEquals(palette.colorAt(0.0), colors.first()) assertEquals(palette.colorAt(1.0), colors.last()) - assertEquals(bandCount, colors.distinct().size, "each band should get its own colour: $colors") + assertEquals(bandCount, colors.distinct().size, "each band should get its own color: $colors") } } diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt index bcd5a296458..aab66b336fa 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -26,8 +26,8 @@ import kotlin.test.assertTrue * Spike verification: the local model behaves the way a coverage prediction must, without needing a network, a WebView * or a terrain download. * - * These are behavioural assertions, not conformance ones — `kp1812`'s own suite already checks the model against the - * ITU reference. What matters here is that this module drives it correctly. + * These are behavioral assertions, not conformance ones: `kp1812`'s own suite checks the model against the ITU + * reference. What matters here is that this module drives it correctly. */ class LocalCoverageTest { @@ -123,7 +123,7 @@ class LocalCoverageTest { * Exact halves are deliberately not asserted: `roundToLong` breaks ties toward positive infinity, and whether a decimal * like -76.15 even *is* a tie depends on its binary representation (-76.15 * 10 is -761.4999999999999, so it rounds to * -76.1). Immaterial for displaying dBm, and a test that pinned it would be asserting floating-point trivia rather than - * behaviour. + * behavior. */ class ToFixed1Test { @Test diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt index 0102f0250a5..b61883936ae 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt @@ -104,7 +104,7 @@ class PolarCoverageTest { @Test fun gridIsSymmetricAboutTheSiteOverFlatGround() = runTest { val grid = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16).toGrid(resolution = 65) - val mid = 32 // the centre cell of an odd-sized grid sits on the site + val mid = 32 // the center cell of an odd-sized grid sits on the site for (d in 1..mid) { val west = grid.at(mid - d, mid) val east = grid.at(mid + d, mid) diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 12a5ef4396a..c60eff999b1 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -140,7 +140,7 @@ object CoverageDemo { println("re-swept through a brand new elevation source in ${fresh}ms") } - /** Plot the grid as a top-down image, coloured by signal strength. */ + /** Plot the grid as a top-down image, colored by signal strength. */ @Suppress("MagicNumber") private fun renderPng(coverage: CoverageGrid, dest: File) { val size = 700 From 2e2039c32a459345a0ad741d6b7612e18e594ac5 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:34 -0500 Subject: [PATCH 22/27] feat(coverage): compute Site Planner coverage on device on every platform Android joins desktop on the local ITU-R P.1812 engine, so the hidden WebView that drove the hosted planner is gone. All three map hosts share one SitePlannerHost in feature:map: the form, an estimating dialog that cancels back to the form, and a failure note on the form instead of a toast. The form validates frequency against P.1812's 30 MHz to 6 GHz and range against 1 to 150 km, and drops the hosted planner's high-resolution switch, browser hand-off and URL builder. --- .skills/compose-ui/strings-index.txt | 5 +- androidApp/build.gradle.kts | 1 + .../app/map/component/SitePlannerSlot.kt | 12 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 3 + .../meshtastic/app/map/SitePlannerRunner.kt | 317 ------------------ .../core/konsist/ModuleBoundaryTest.kt | 6 +- .../composeResources/values/strings.xml | 7 +- desktopApp/build.gradle.kts | 2 - .../desktop/map/DesktopSitePlannerSlot.kt | 266 +-------------- feature/coverage/build.gradle.kts | 19 +- .../CoverageTerrainDirectory.android.kt | 26 ++ .../feature/coverage/SitePlannerEstimate.kt | 93 +++++ .../coverage/CoverageTerrainDirectory.ios.kt | 29 ++ .../coverage/CoverageTerrainDirectory.jvm.kt | 24 ++ .../map/component/SitePlannerBrowserSheet.kt | 84 ----- .../feature/map/component/SitePlannerHost.kt | 148 ++++++++ .../map/component/SitePlannerParams.kt | 83 +---- .../feature/map/component/SitePlannerSheet.kt | 46 ++- .../map/component/SitePlannerParamsTest.kt | 102 ------ 19 files changed, 396 insertions(+), 877 deletions(-) delete mode 100644 androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt create mode 100644 feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt create mode 100644 feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt create mode 100644 feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt delete mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt delete mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index e25c7e70d6e..d1390a2e567 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1719,15 +1719,15 @@ signal_quality site_planner site_planner_antenna_gain_dbi site_planner_antenna_height_meters -site_planner_browser_note site_planner_color_scale site_planner_estimate site_planner_estimating site_planner_failed site_planner_frequency_mhz -site_planner_high_resolution +site_planner_invalid_frequency site_planner_invalid_latitude site_planner_invalid_longitude +site_planner_invalid_max_range site_planner_invalid_positive site_planner_invalid_rx_sensitivity site_planner_max_range_km @@ -1742,7 +1742,6 @@ site_planner_tx_power_watts site_planner_use_current_location site_planner_use_map_center site_planner_use_node_location -site_planner_webview_updating skip slot snr diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 2bd4338f48d..3c081dca660 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -240,6 +240,7 @@ dependencies { implementation(projects.feature.messaging) implementation(projects.feature.connections) implementation(projects.feature.map) + implementation(projects.feature.coverage) implementation(projects.feature.node) implementation(projects.feature.settings) implementation(projects.feature.discovery) diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt index ade30250690..6a04879a5e0 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt @@ -22,19 +22,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position import org.meshtastic.app.map.MapViewModel -import org.meshtastic.app.map.SitePlannerHost +import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.SharedMapViewModel +import org.meshtastic.feature.map.component.SitePlannerHost import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.maplibre.SitePlannerSession /** - * Runs the hosted Site Planner for the F-Droid map. - * - * The planner lives in `androidApp`, so the flavor supplies it rather than the shared map module. Imported coverage - * becomes a GeoJSON map layer (see #6138) and the map recentres on the transmitter so it is on screen. + * Runs the Site Planner for the F-Droid map. The estimate becomes a GeoJSON map layer and the map moves to the + * transmitter so it's on screen. * * No phone-GPS shortcut is offered: the Google flavor fills that from Play Services' fused location, which must not - * enter an F-Droid build. The coordinate fields stay manual, with the map centre and the node's own position as + * enter an F-Droid build. The coordinate fields stay manual, with the map center and the node's own position as * shortcuts. */ @Composable @@ -51,6 +50,7 @@ fun SitePlannerSlot(session: SitePlannerSession) { SitePlannerHost( initialParams = subject.toSitePlannerParams(channelSet), + estimate = rememberCoverageEstimate(), onDismiss = session.onDismiss, onImport = { name, geoJson, latitude, longitude -> mapViewModel.addGeoJsonLayer(name, geoJson) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index e16b2594d42..b949456825b 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -202,6 +202,7 @@ import org.meshtastic.feature.map.component.ClusterMembersDialog import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.DeleteWaypointDialog import org.meshtastic.feature.map.component.EditWaypointDialog +import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.component.MapFilterSheet @@ -209,6 +210,7 @@ import org.meshtastic.feature.map.component.MeshMapFitPadding import org.meshtastic.feature.map.component.NodeTrackFilterMenu import org.meshtastic.feature.map.component.OfflineStatusBanner import org.meshtastic.feature.map.component.RasterOverlayToggles +import org.meshtastic.feature.map.component.SitePlannerHost import org.meshtastic.feature.map.component.SitePlannerLaunch import org.meshtastic.feature.map.component.WaypointInfoDialog import org.meshtastic.feature.map.component.mapFilterActions @@ -1233,6 +1235,7 @@ fun MapView( launch.nodeLocation(ourNodeInfo)?.let { location -> { location } } SitePlannerHost( initialParams = launch.initialParams, + estimate = rememberCoverageEstimate(), onDismiss = { sitePlannerLaunch = null }, onImport = { name, geoJson, latitude, longitude -> mapViewModel.addGeoJsonLayer(name, geoJson) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt b/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt deleted file mode 100644 index 8b95b95ff2f..00000000000 --- a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt +++ /dev/null @@ -1,317 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.Resources -import android.os.Handler -import android.os.Looper -import android.util.AndroidRuntimeException -import android.webkit.JavascriptInterface -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.FrameLayout -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import androidx.core.net.toUri -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.site_planner_estimating -import org.meshtastic.core.resources.site_planner_failed -import org.meshtastic.core.resources.site_planner_webview_updating -import org.meshtastic.feature.map.component.SITE_PLANNER_URL -import org.meshtastic.feature.map.component.SitePlannerParams -import org.meshtastic.feature.map.component.SitePlannerSheet - -// The official hosted Site Planner (static PWA on GitHub Pages). The estimate flow loads it headless with -// run=1&bridge=1; the native bridge + full flat query contract it relies on shipped in site-planner #74. -private const val SITE_PLANNER_TIMEOUT_MS = 45_000L - -/** - * Site Planner coverage-estimate flow: an editable [SitePlannerSheet] pre-filled with [initialParams], then a hidden - * headless WebView that loads the planner with `run=1&bridge=1`, waits for it to hand back the styled GeoJSON, and - * imports it via [onImport]. Location shortcuts re-seed the coordinates from the device GPS - * ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center - * ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner. - */ -@Suppress("LongMethod", "LambdaParameterInRestartableEffect") -@Composable -fun SitePlannerHost( - initialParams: SitePlannerParams, - onDismiss: () -> Unit, - onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit, - onRequestCurrentLocation: (suspend () -> Pair?)? = null, - onUseNodeLocation: (() -> Pair)? = null, - onUseMapCenter: (() -> Pair)? = null, -) { - var params by remember(initialParams) { mutableStateOf(initialParams) } - var running by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val context = LocalContext.current - val failedText = stringResource(Res.string.site_planner_failed) - val estimatingText = stringResource(Res.string.site_planner_estimating) - val webViewUpdatingText = stringResource(Res.string.site_planner_webview_updating) - - val current = running - if (current == null) { - SitePlannerSheet( - initial = params, - onSubmit = { running = it }, - onDismiss = onDismiss, - onUseCurrentLocation = - onRequestCurrentLocation?.let { fetch -> - { - scope.launch { - fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) } - } - } - }, - onUseNodeLocation = - onUseNodeLocation?.let { node -> - { - val (lat, lon) = node() - params = params.copy(latitude = lat, longitude = lon) - } - }, - onUseMapCenter = - onUseMapCenter?.let { center -> - { - val (lat, lon) = center() - params = params.copy(latitude = lat, longitude = lon) - } - }, - ) - } else { - Dialog(onDismissRequest = { onDismiss() }) { - // The WebView fills the card and runs the sim; the opaque spinner Surface on top hides it, so the flow - // reads as "Estimating coverage…" rather than a browser. It must be attached and non-trivially sized — - // a detached or 0-size WebView can't get a WebGL context, and the planner's autorun waits on map load. - Box(modifier = Modifier.size(280.dp), contentAlignment = Alignment.Center) { - SitePlannerRunner( - url = current.toQueryUrl(SITE_PLANNER_URL), - onResult = { geoJson -> - onImport(current.name, geoJson, current.latitude, current.longitude) - onDismiss() - }, - onError = { detail -> - Logger.withTag("SitePlanner").e { "Coverage estimate failed: $detail" } - Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show() - onDismiss() - }, - onWebViewUnavailable = { - Toast.makeText(context, webViewUpdatingText, Toast.LENGTH_LONG).show() - onDismiss() - }, - // Headless: fully transparent so the WebGL first-paint frame never flashes through, but still - // attached + sized (280dp) so the sim gets a WebGL context. alpha(0) is a compositor property — - // the view stays VISIBLE, so the page's requestAnimationFrame/autorun keep running. - modifier = Modifier.matchParentSize().alpha(0f), - ) - Surface( - modifier = Modifier.matchParentSize(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), - ) { - CircularWavyProgressIndicator() - Text(estimatingText) - TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } - } - } - } - } - LaunchedEffect(current) { - delay(SITE_PLANNER_TIMEOUT_MS) - Logger.withTag("SitePlanner").w { "Coverage estimate timed out after ${SITE_PLANNER_TIMEOUT_MS}ms" } - Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show() - onDismiss() - } - } -} - -/** - * Headless WebView that loads [url] (the planner with `run=1&bridge=1`), exposes a `window.__meshtasticNative` bridge, - * and reports the coverage GeoJSON via [onResult] once the planner posts it. Main-frame load failures go to [onError]. - * Construction rides out the system WebView provider-update race with one deferred retry, then [onWebViewUnavailable]. - */ -@Composable -private fun SitePlannerRunner( - url: String, - onResult: (String) -> Unit, - onError: (String) -> Unit, - onWebViewUnavailable: () -> Unit, - modifier: Modifier = Modifier, -) { - val onResultState by rememberUpdatedState(onResult) - val onErrorState by rememberUpdatedState(onError) - val onWebViewUnavailableState by rememberUpdatedState(onWebViewUnavailable) - AndroidView( - modifier = modifier, - factory = { context -> - SitePlannerWebViewContainer(context).apply { - attachPlannerWebView( - url = url, - retriesLeft = 1, - onResult = { onResultState(it) }, - onError = { onErrorState(it) }, - onUnavailable = { onWebViewUnavailableState() }, - ) - } - }, - onRelease = SitePlannerWebViewContainer::release, - ) -} - -// One deferred retry bridges the moment the system WebView provider package finishes updating. -private const val WEBVIEW_RETRY_DELAY_MS = 250L - -/** - * True for the exceptions WebView construction throws while the system WebView provider package updates underneath a - * running app: the AOSP ResourcesImpl redirect race and the AndroidRuntimeException wrapper around provider failures. - */ -internal fun isWebViewProviderUpdateException(throwable: Throwable): Boolean = - throwable is Resources.NotFoundException || - (throwable is AndroidRuntimeException && throwable.message?.contains("WebView", ignoreCase = true) == true) - -/** Hosts the planner WebView; owns the retry/bridge handler so [release] cancels anything still pending. */ -private class SitePlannerWebViewContainer(context: Context) : FrameLayout(context) { - val main = Handler(Looper.getMainLooper()) - - fun release() { - main.removeCallbacksAndMessages(null) - val webView = getChildAt(0) as? WebView - removeAllViews() // detach from the view system before destroy(), per the WebView docs - webView?.destroy() - } -} - -/** Builds and attaches the planner WebView, absorbing the provider-update race with a single deferred retry. */ -private fun SitePlannerWebViewContainer.attachPlannerWebView( - url: String, - retriesLeft: Int, - onResult: (String) -> Unit, - onError: (String) -> Unit, - onUnavailable: () -> Unit, -) { - // Only bare construction races the provider update; configure outside the try so no half-built view leaks. - val webView = - try { - WebView(context) - } catch (e: Resources.NotFoundException) { - if (!isWebViewProviderUpdateException(e)) throw e - retryOrFailSoft(e, retriesLeft, onUnavailable) { - attachPlannerWebView(url, retriesLeft - 1, onResult, onError, onUnavailable) - } - return - } catch (e: AndroidRuntimeException) { - if (!isWebViewProviderUpdateException(e)) throw e - retryOrFailSoft(e, retriesLeft, onUnavailable) { - attachPlannerWebView(url, retriesLeft - 1, onResult, onError, onUnavailable) - } - return - } - addView(configurePlannerWebView(webView, main, url, onResult, onError)) -} - -private fun SitePlannerWebViewContainer.retryOrFailSoft( - cause: Throwable, - retriesLeft: Int, - onUnavailable: () -> Unit, - retry: () -> Unit, -) { - if (retriesLeft > 0) { - Logger.withTag("SitePlanner").w(cause) { "WebView provider is updating; retrying construction once" } - main.postDelayed({ retry() }, WEBVIEW_RETRY_DELAY_MS) - } else { - Logger.withTag("SitePlanner").e(cause) { "WebView unavailable after retry; failing soft" } - onUnavailable() - } -} - -@SuppressLint("SetJavaScriptEnabled") -private fun configurePlannerWebView( - webView: WebView, - main: Handler, - url: String, - onResult: (String) -> Unit, - onError: (String) -> Unit, -): WebView = webView.apply { - setBackgroundColor(android.graphics.Color.TRANSPARENT) // no opaque black backing before first paint - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - addJavascriptInterface( - object { - // Called from the planner's JS thread; hop to main before touching Compose state. - @JavascriptInterface fun onCoverage(geoJson: String) = main.post { onResult(geoJson) } - }, - "__meshtasticNative", - ) - webViewClient = - object : WebViewClient() { - // Keep the __meshtasticNative bridge exclusive to the planner's own origin: block any navigation - // elsewhere (redirect/compromise/open-redirect) so foreign content can never reach onCoverage(). - // Sub-resource fetches (tiles, XHR) aren't navigations, so this doesn't affect the sim itself. - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - val target = request?.url ?: return false - val trusted = SITE_PLANNER_URL.toUri() - return target.scheme != trusted.scheme || target.host != trusted.host - } - - override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { - // Only a failed main-frame load is fatal; a stray tile/asset 404 is not. - if (request?.isForMainFrame == true) { - main.post { onError("${error?.errorCode} ${error?.description}") } - } - } - } - loadUrl(url) -} diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt index 3f1590feb43..74b51044b43 100644 --- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt +++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt @@ -28,11 +28,11 @@ import kotlin.test.assertTrue class ModuleBoundaryTest { /** - * The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles; coverage samples - * the same elevation tiles. + * The MapLibre renderer draws feature:map's shared map model and map-terrain's elevation tiles. Coverage samples + * the same elevation tiles and computes the estimate feature:map's Site Planner form describes. */ private val allowedFeatureImports = - mapOf("map-maplibre" to setOf("map", "map-terrain"), "coverage" to setOf("map-terrain")) + mapOf("map-maplibre" to setOf("map", "map-terrain"), "coverage" to setOf("map", "map-terrain")) private val sourceFiles = Konsist.scopeFromProject().files.filterNot { it.isNestedAgentWorktree() } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index e9180b6db25..f067b297dc8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1779,15 +1779,15 @@ Site Planner Antenna gain (dBi) Antenna height (m) - The estimate opens in your browser. To bring it onto this map, click the transmitter pin there, choose Export › GeoJSON, then add the downloaded file under Manage Map Layers › Add Layer. Use the GeoJSON export — the KML one is a ground-overlay image this map cannot draw. Color palette Estimate coverage Estimating coverage… - Coverage estimate failed + Couldn't estimate coverage. The terrain may not have downloaded, so check your connection and try again. Frequency (MHz) - High-resolution terrain + Enter 30 to 6000 MHz Enter −90 to 90 Enter −180 to 180 + Enter 1 to 150 km Must be greater than 0 Enter −150 to −30 dBm Max range (km) @@ -1802,7 +1802,6 @@ Use current location Use map center Use current node location - The system WebView is updating. Please try again in a moment. Skip Slot SNR diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index eb4d4c13cf8..0eceb633ce8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -383,8 +383,6 @@ dependencies { // The MapLibre map surfaces, shared with the F-Droid Android flavor. implementation(projects.feature.mapMaplibre) - // TerrainTileStore: the site planner keeps its downloaded terrain between launches. - implementation(projects.feature.mapTerrain) // Exactly one native runtime, the one matching this build host (see maplibreDesktopRuntime()). maplibreDesktopRuntime() diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 67a2fbf623d..040d8cbb5a8 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -16,76 +16,24 @@ */ package org.meshtastic.desktop.map -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.withContext -import okio.FileSystem import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.feature.coverage.CoverageGrid -import org.meshtastic.feature.coverage.CoverageStyle -import org.meshtastic.feature.coverage.LocalCoverage -import org.meshtastic.feature.coverage.MapterhornElevation -import org.meshtastic.feature.coverage.Site -import org.meshtastic.feature.coverage.sweepGrid -import org.meshtastic.feature.coverage.toGeoJson +import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.SharedMapViewModel -import org.meshtastic.feature.map.component.SitePlannerParams -import org.meshtastic.feature.map.component.SitePlannerSheet +import org.meshtastic.feature.map.component.SitePlannerHost import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.maplibre.SitePlannerSession -import org.meshtastic.feature.map.maplibre.terrain.terrainStorageDirectory -import org.meshtastic.feature.map.terrain.TerrainTileStore -import kotlin.math.log10 -import kotlin.math.roundToInt -/** - * Site Planner on the desktop — computed **in this app**, not in a browser. - * - * Previously this opened site.meshtastic.org in the system browser and asked the user to export a `.geojson` and - * re-import it by hand: desktop has no embedded browser, and putting JCEF back into the jlink'd runtime measured at - * roughly three and a half times the size of the whole application. - * - * Now `feature:coverage` runs ITU-R P.1812 (`org.meshtastic:kp1812`) against the same Mapterhorn elevation the map - * already uses for hillshade and contours. No network call to the planner, no WebView, no export/re-import round trip. - * - * Note this is a different propagation model from the hosted planner's SPLAT!/ITM, so predictions will not match it - * pixel for pixel. - */ +/** Runs the Site Planner for the desktop map; the estimate becomes a GeoJSON map layer and the map moves to it. */ @Composable fun DesktopSitePlannerSlot(session: SitePlannerSession) { val sharedViewModel: SharedMapViewModel = koinViewModel() val layersManager: MapLayersManager = koinInject() - val dispatchers: CoroutineDispatchers = koinInject() val ourNode by sharedViewModel.ourNodeInfo.collectAsStateWithLifecycle() val channelSet by sharedViewModel.channelSet.collectAsStateWithLifecycle() @@ -94,200 +42,16 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { // A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to. val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode - var params by remember(subject) { mutableStateOf(subject.toSitePlannerParams(channelSet)) } - var running by remember { mutableStateOf(null) } - var result by remember { mutableStateOf(null) } - var failure by remember { mutableStateOf(null) } - // Its own directory beside the map's offline regions, not inside one: coverage downloads should - // not silently inflate the size and tile count a downloaded region reports. - val terrainStore = remember { TerrainTileStore(FileSystem.SYSTEM, terrainStorageDirectory().resolve("coverage")) } - - val current = running - val coverage = result - - when { - coverage != null -> - CoverageResultDialog(coverage) { - result = null - session.onDismiss() - } - - current != null -> { - ComputingDialog(current.name) { - running = null - session.onDismiss() - } - LaunchedEffect(current) { - val swept = estimateCoverage(current, terrainStore, dispatchers) { failure = it } - if (swept != null) { - // Persist and draw it on the map, the same path the F-Droid flavour uses for - // the WebView's GeoJSON — so the coverage survives the dialog closing and - // shows up in the layers list like any other import. - layersManager.addGeoJsonLayer(current.name, swept.toGeoJson(current.toCoverageStyle())) - session.moveTo(Position(longitude = current.longitude, latitude = current.latitude)) - result = swept - } - running = null - } - } - - else -> - SitePlannerSheet( - initial = params, - onSubmit = { submitted -> - params = submitted - running = submitted - }, - onDismiss = session.onDismiss, - note = - failure?.let { "Coverage failed: $it" } - ?: "Computed on this device with ITU-R P.1812 — no browser, works offline once terrain is cached.", - onUseNodeLocation = - subject - ?.takeIf { it.validPosition != null } - ?.let { node -> - { params = params.copy(latitude = node.latitude, longitude = node.longitude) } - }, - onUseMapCenter = { - session.mapCenter().let { params = params.copy(latitude = it.latitude, longitude = it.longitude) } - }, - ) - } -} - -/** The planner's flat params, as the coverage module's site. */ -private fun SitePlannerParams.toSite(): Site = Site( - name = name, - latitude = latitude, - longitude = longitude, - frequencyMhz = txFreqMhz, - // The planner carries watts; P.1812 wants dBm. - txPowerDbm = 10.0 * log10(txPowerWatts * MILLIWATTS_PER_WATT), - rxSensitivityDbm = rxSensitivityDbm, - txHeightM = txHeightMeters, - rxHeightM = rxHeightMeters, - txGainDbi = txGainDbi, - radiusKm = maxRangeKm, -) - -/** The sheet's Display section: palette, the dBm range the ramp spans, and overlay transparency. */ -private fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle.fromTransparency( - palette = colorScale, - minDbm = minDbm, - maxDbm = maxDbm, - transparencyPercent = overlayTransparency, -) - -@Composable -private fun ComputingDialog(name: String, onCancel: () -> Unit) { - Dialog(onDismissRequest = onCancel) { - Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { - Column( - modifier = Modifier.size(300.dp).padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), - ) { - CircularProgressIndicator() - Text("Estimating coverage for $name", style = MaterialTheme.typography.bodyLarge) - Text("ITU-R P.1812 · on this device", style = MaterialTheme.typography.bodySmall) - TextButton(onClick = onCancel) { Text("Cancel") } - } - } - } -} - -@Composable -private fun CoverageResultDialog(coverage: CoverageGrid, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { - Column( - modifier = Modifier.padding(20.dp).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(coverage.site.name, style = MaterialTheme.typography.titleMedium) - Text("Added to the map as a coverage layer", style = MaterialTheme.typography.bodySmall) - Text( - "ITU-R P.1812 · ${coverage.site.frequencyMhz.roundToInt()} MHz · " + - "${coverage.site.txPowerDbm.roundToInt()} dBm", - style = MaterialTheme.typography.bodySmall, - ) - CoveragePlot(coverage, modifier = Modifier.fillMaxWidth().aspectRatio(1f)) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text("${coverage.width}×${coverage.height} grid", style = MaterialTheme.typography.bodySmall) - Text( - "${(coverage.reachableFraction * PERCENT).roundToInt()}% reachable", - style = MaterialTheme.typography.bodySmall, - ) - Text("max ${coverage.maxRangeKm.roundToInt()} km", style = MaterialTheme.typography.bodySmall) - } - TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("Done") } - } - } - } -} - -/** Top-down preview of the grid, colored by predicted signal. */ -@Composable -private fun CoveragePlot(coverage: CoverageGrid, modifier: Modifier = Modifier) { - val sensitivity = coverage.site.rxSensitivityDbm - val strongest = coverage.dbm.filter { !it.isNaN() }.maxOrNull() ?: sensitivity - - Box(modifier) { - Canvas(Modifier.fillMaxWidth().aspectRatio(1f)) { - drawRect(PLOT_BACKGROUND) - val cw = size.width / coverage.width - val ch = size.height / coverage.height - for (y in 0 until coverage.height) { - for (x in 0 until coverage.width) { - val v = coverage.at(x, y) - if (v.isNaN() || v < sensitivity) continue - drawRect( - signalColor(v, sensitivity, strongest), - topLeft = Offset(x * cw, y * ch), - size = androidx.compose.ui.geometry.Size(cw + 1, ch + 1), - ) - } - } - } - } + SitePlannerHost( + initialParams = subject.toSitePlannerParams(channelSet), + estimate = rememberCoverageEstimate(), + onDismiss = session.onDismiss, + onImport = { name, geoJson, latitude, longitude -> + layersManager.addGeoJsonLayer(name, geoJson) + session.moveTo(Position(longitude = longitude, latitude = latitude)) + }, + onUseNodeLocation = + subject?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }, + onUseMapCenter = { session.mapCenter().let { it.latitude to it.longitude } }, + ) } - -private fun signalColor(dbm: Double, sensitivity: Double, strongest: Double): Color { - if (dbm < sensitivity) return BELOW_SENSITIVITY - val t = ((dbm - sensitivity) / (strongest - sensitivity)).coerceIn(0.0, 1.0).toFloat() - val r = if (t < HALF) 1f else (1f - (t - HALF) * 2f).coerceIn(0f, 1f) - val g = if (t < HALF) (t * 2f).coerceIn(0f, 1f) else 1f - return Color(r, g, GREEN_FLOOR) -} - -/** Null when the estimate failed, with the reason passed to [onFailure]; cancellation propagates. */ -private suspend fun estimateCoverage( - params: SitePlannerParams, - store: TerrainTileStore, - dispatchers: CoroutineDispatchers, - onFailure: (String?) -> Unit, -): CoverageGrid? = try { - withContext(dispatchers.default) { - // A fresh source per estimate is free: decoded terrain lives in a shared - // cache, on disk under the store, and the HTTP client is shared too. - MapterhornElevation(store = store).use { source -> - source.prefetch(params.toSite()) - LocalCoverage(source).sweepGrid(params.toSite(), resolution = GRID) - } - } -} catch (e: CancellationException) { - throw e -} catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - onFailure(e.message ?: e::class.simpleName) - null -} - -private const val GRID = 256 -private const val MILLIWATTS_PER_WATT = 1000.0 -private const val PERCENT = 100 -private const val HALF = 0.5f -private const val GREEN_FLOOR = 0.24f - -// The preview's own dark canvas, and the shade of a cell the receiver cannot hear. -private val PLOT_BACKGROUND = Color(0xFF14151C) -private val BELOW_SENSITIVITY = Color(0xFF22232C) diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts index ce7dd005412..40bf9aa44cb 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -14,17 +14,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + plugins { alias(libs.plugins.meshtastic.kmp.feature) } -// SPIKE: local RF coverage, replacing the headless-WebView hand-off to the hosted Site Planner. -// Pure computation over org.meshtastic:kp1812 (ITU-R P.1812) and an ElevationSource — no Compose -// UI, no rendering, no network. The app supplies elevation from feature/map-terrain's Mapterhorn -// tiles; tests supply a lambda. +// Site Planner coverage computed on the device with org.meshtastic:kp1812 (ITU-R P.1812) over +// Mapterhorn terrain, returned as GeoJSON for the map's layer list. kotlin { jvm() - // kp1812 publishes no androidTarget - Android resolves its jvm artifact, the same way this - // repo already consumes takpacket-sdk-jvm. + // kp1812 has no Android target; Android resolves its jvm artifact. @Suppress("UnstableApiUsage") android { namespace = "org.meshtastic.feature.coverage" @@ -35,14 +33,11 @@ kotlin { commonMain.dependencies { implementation(libs.kp1812) implementation(libs.kotlinx.coroutines.core) - // Elevation comes from the same Mapterhorn archives the map already uses for hillshade - // and contours. Over flat synthetic ground a coverage plot is a bullseye and proves - // nothing; against real terrain it has to show ridges shadowing valleys. + implementation(projects.feature.map) implementation(projects.feature.mapTerrain) implementation(projects.core.common) + implementation(projects.core.di) implementation(libs.okio) - // Terrain comes over plain XYZ requests now; ktor suspends rather than blocking, so a - // sweep's fetches never occupy the compute dispatcher. implementation(libs.ktor.client.core) } @@ -54,7 +49,7 @@ kotlin { } } -// SPIKE: run a real prediction and write a PNG + GeoJSON, so the replacement can be *seen*. +// Runs a real prediction against Mapterhorn terrain and writes a PNG and the GeoJSON. // ./gradlew :feature:coverage:coverageDemo -PuseMavenLocal tasks.register("coverageDemo") { group = "verification" diff --git a/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt new file mode 100644 index 00000000000..c06b5a77c1b --- /dev/null +++ b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import okio.Path +import okio.Path.Companion.toPath +import org.meshtastic.core.common.ContextServices +import java.io.File + +/** App-internal storage, so cached terrain is private to the app and removed with it. */ +internal actual fun coverageTerrainDirectory(): Path = + File(ContextServices.app.filesDir, "terrain/coverage").absolutePath.toPath() diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt new file mode 100644 index 00000000000..0d58e99a3d9 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import okio.FileSystem +import okio.Path +import org.koin.compose.koinInject +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.feature.map.component.SitePlannerParams +import org.meshtastic.feature.map.terrain.TerrainTileStore +import kotlin.math.log10 + +/** + * Computes a Site Planner estimate on this device: Mapterhorn terrain for the coverage disc, cached under [store], + * then the ITU-R P.1812 sweep, returned as the styled GeoJSON iso-bands a map layer takes. + * + * Throws when terrain can't be fetched or a value is outside what P.1812 accepts. Cancellation propagates. + */ +suspend fun estimateCoverageGeoJson( + params: SitePlannerParams, + store: TerrainTileStore, + dispatcher: CoroutineDispatcher, +): String = withContext(dispatcher) { + val site = params.toSite() + MapterhornElevation(store = store).use { source -> + source.prefetch(site) + LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) + } +} + +/** [estimateCoverageGeoJson] bound to the app's compute dispatcher and its coverage terrain cache. */ +@Composable +fun rememberCoverageEstimate(): suspend (SitePlannerParams) -> String { + val dispatchers: CoroutineDispatchers = koinInject() + return remember(dispatchers) { + val store = TerrainTileStore(FileSystem.SYSTEM, coverageTerrainDirectory()) + val estimate: suspend (SitePlannerParams) -> String = { params -> + estimateCoverageGeoJson(params, store, dispatchers.default) + } + estimate + } +} + +/** + * Where coverage terrain is cached between launches. It's kept apart from downloaded offline map regions so a coverage + * estimate never changes the size and tile count a region reports. + */ +internal expect fun coverageTerrainDirectory(): Path + +/** The planner form's transmitter as the coverage model's site. */ +internal fun SitePlannerParams.toSite(): Site = Site( + name = name, + latitude = latitude, + longitude = longitude, + frequencyMhz = txFreqMhz, + txPowerDbm = wattsToDbm(txPowerWatts), + rxSensitivityDbm = rxSensitivityDbm, + txHeightM = txHeightMeters, + rxHeightM = rxHeightMeters, + txGainDbi = txGainDbi, + radiusKm = maxRangeKm, +) + +/** The form's Display section: palette, the dBm range the ramp spans, and overlay transparency. */ +internal fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle.fromTransparency( + palette = colorScale, + minDbm = minDbm, + maxDbm = maxDbm, + transparencyPercent = overlayTransparency, +) + +internal fun wattsToDbm(watts: Double): Double = DBM_PER_DECADE * log10(watts * MILLIWATTS_PER_WATT) + +private const val DBM_PER_DECADE = 10.0 +private const val MILLIWATTS_PER_WATT = 1000.0 diff --git a/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt new file mode 100644 index 00000000000..5584dafbd47 --- /dev/null +++ b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import okio.Path +import okio.Path.Companion.toPath +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask + +/** The app's Documents directory. Compiled for target validation; no iOS host calls it. */ +internal actual fun coverageTerrainDirectory(): Path { + val documents = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).first() as String + return "$documents/terrain/coverage".toPath() +} diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt new file mode 100644 index 00000000000..f8d816579cd --- /dev/null +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import okio.Path +import okio.Path.Companion.toPath + +/** Beside the desktop's offline terrain in `~/.meshtastic/terrain`. */ +internal actual fun coverageTerrainDirectory(): Path = + "${System.getProperty("user.home")}/.meshtastic/terrain/coverage".toPath() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt deleted file mode 100644 index 03868c49fe4..00000000000 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.map.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalUriHandler -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.site_planner_browser_note - -/** The hosted Site Planner. One definition, shared by the embedded host and the browser hand-off. */ -const val SITE_PLANNER_URL: String = "https://site.meshtastic.org" - -/** - * The Site Planner as a form that hands the finished parameters to the system browser. - * - * Used where there is no embedded browser to run the planner in. The whole configuration step is the same - * [SitePlannerSheet] the Android hosts show, so the transmitter is still seeded from the connected radio and the - * shortcuts still work; only the last step differs — instead of loading the planner in place, the parameters are - * encoded into its URL and opened outside the app. - * - * The coverage estimate therefore stays in the browser rather than returning as a map layer. Bringing it back - * automatically needs a JavaScript bridge, which needs an embedded browser: on Desktop that meant bundling Chromium, - * which measured at roughly three and a half times the size of the whole application. - * - * It comes back by hand instead, which the sheet's note spells out: the planner's own export writes a `.geojson`, and - * the layers sheet on this same host imports it. The note names the format on purpose — the planner's KML export is a - * bare `` over a sibling PNG that arrives without its image, so a user who reached for it would import a - * file that draws nothing. - */ -@Composable -fun SitePlannerBrowserSheet( - initial: SitePlannerParams, - onDismiss: () -> Unit, - onUseNodeLocation: (() -> Pair)? = null, - onUseMapCenter: (() -> Pair)? = null, -) { - val uriHandler = LocalUriHandler.current - // Held here rather than passed straight through: the sheet re-seeds its coordinate fields from `initial`, so a - // location shortcut only shows up if the caller actually moves the value it was given. - var params by remember(initial) { mutableStateOf(initial) } - - SitePlannerSheet( - initial = params, - onSubmit = { submitted -> - uriHandler.openUri(submitted.toQueryUrl(SITE_PLANNER_URL, useHostBridge = false)) - onDismiss() - }, - onDismiss = onDismiss, - note = stringResource(Res.string.site_planner_browser_note), - onUseNodeLocation = - onUseNodeLocation?.let { node -> - { - val (latitude, longitude) = node() - params = params.copy(latitude = latitude, longitude = longitude) - } - }, - onUseMapCenter = - onUseMapCenter?.let { center -> - { - val (latitude, longitude) = center() - params = params.copy(latitude = latitude, longitude = longitude) - } - }, - ) -} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt new file mode 100644 index 00000000000..86a8605b1e1 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.site_planner_estimating +import org.meshtastic.core.resources.site_planner_failed + +/** + * The Site Planner flow every map host shares: the [SitePlannerSheet] form, then [estimate] behind a progress dialog, + * then [onImport] with the estimate's GeoJSON and the transmitter position so the host can add a map layer and move + * the camera to it. + * + * Canceling the progress dialog stops the estimate and returns to the form with the same values. A failed estimate + * returns there too, with an error note. The location shortcuts re-seed the coordinates from the device + * ([onRequestCurrentLocation]), the node ([onUseNodeLocation]), or the map ([onUseMapCenter]) when a host offers them. + */ +@Composable +fun SitePlannerHost( + initialParams: SitePlannerParams, + estimate: suspend (SitePlannerParams) -> String, + onDismiss: () -> Unit, + onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit, + onRequestCurrentLocation: (suspend () -> Pair?)? = null, + onUseNodeLocation: (() -> Pair)? = null, + onUseMapCenter: (() -> Pair)? = null, +) { + var params by remember(initialParams) { mutableStateOf(initialParams) } + var running by remember { mutableStateOf(null) } + var failed by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val currentEstimate by rememberUpdatedState(estimate) + val currentOnImport by rememberUpdatedState(onImport) + val currentOnDismiss by rememberUpdatedState(onDismiss) + + val current = running + if (current == null) { + SitePlannerSheet( + initial = params, + onSubmit = { submitted -> + params = submitted + failed = false + running = submitted + }, + onDismiss = onDismiss, + onUseCurrentLocation = + onRequestCurrentLocation?.let { fetch -> + { + scope.launch { + fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) } + } + } + }, + onUseNodeLocation = + onUseNodeLocation?.let { node -> + { + val (lat, lon) = node() + params = params.copy(latitude = lat, longitude = lon) + } + }, + onUseMapCenter = + onUseMapCenter?.let { center -> + { + val (lat, lon) = center() + params = params.copy(latitude = lat, longitude = lon) + } + }, + note = if (failed) stringResource(Res.string.site_planner_failed) else null, + ) + } else { + EstimatingDialog(onCancel = { running = null }) + LaunchedEffect(current) { + val geoJson = + try { + currentEstimate(current) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.withTag("SitePlanner").e(e) { "Coverage estimate failed" } + null + } + if (geoJson == null) { + failed = true + running = null + } else { + currentOnImport(current.name, geoJson, current.latitude, current.longitude) + currentOnDismiss() + } + } + } +} + +@Composable +private fun EstimatingDialog(onCancel: () -> Unit) { + Dialog(onDismissRequest = onCancel) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.size(280.dp).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + CircularWavyProgressIndicator() + Text(stringResource(Res.string.site_planner_estimating)) + TextButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) } + } + } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt index 0aaed373456..8a087704f87 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt @@ -16,11 +16,7 @@ */ package org.meshtastic.feature.map.component -/** - * Transmitter parameters for a Meshtastic Site Planner coverage estimate. Maps onto the planner's flat query contract - * (`?lat=&lon=&name=&tx_power=&tx_freq=&tx_height=&tx_gain=`), so [toQueryUrl] can hand a configured, auto-running view - * to the planner without knowing its internal parameter schema. - */ +/** Transmitter, receiver, and display settings for a Site Planner coverage estimate. */ data class SitePlannerParams( val name: String, val latitude: Double, @@ -30,59 +26,15 @@ data class SitePlannerParams( val txHeightMeters: Double = DEFAULT_TX_HEIGHT_METERS, val txGainDbi: Double = DEFAULT_TX_GAIN_DBI, val colorScale: String = DEFAULT_COLOR_SCALE, - // Advanced fields — default to the planner's own values, so sending them is behaviorally identical to omitting - // them (they just make the hand-off explicit). Surfaced behind an "Advanced" section in the form. val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM, val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS, val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM, - val highResolution: Boolean = false, val minDbm: Double = DEFAULT_MIN_DBM, val maxDbm: Double = DEFAULT_MAX_DBM, val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY, ) { - /** The hosted planner's own ceiling for the current mode. */ - private val hostedMaxRangeKm: Double - get() = if (highResolution) MAX_RANGE_HIGH_RES_KM else MAX_RANGE_STANDARD_KM - - /** - * Build the planner URL that prefills these params and auto-runs the simulation (`run=1`). - * - * [useHostBridge] asks the planner to hand its result to a native host bridge rather than to its own share sheet. - * Only a host that actually provides one should set it: opened in an ordinary browser, `bridge=1` points the - * finished estimate at something that is not there, and the user's own way of saving it is what they need instead. - * - * Advanced params map onto the planner's flat query contract (receiver / simulation / display sections). - */ - fun toQueryUrl(baseUrl: String, useHostBridge: Boolean = true): String { - val query = buildString { - append("lat=").append(latitude) - append("&lon=").append(longitude) - append("&name=").append(encodeQueryComponent(name)) - append("&tx_power=").append(txPowerWatts) - append("&tx_freq=").append(txFreqMhz) - append("&tx_height=").append(txHeightMeters) - append("&tx_gain=").append(txGainDbi) - append("&color_scale=").append(encodeQueryComponent(colorScale)) - append("&rx_sensitivity=").append(rxSensitivityDbm) - append("&rx_height=").append(rxHeightMeters) - // Free-text field, so this can exceed what the hosted planner's SPLAT!-in-wasm accepts — - // lower still in high-resolution mode. Only the URL is clamped; the local engine has no - // such ceiling. - append("&max_range=").append(maxRangeKm.coerceAtMost(hostedMaxRangeKm)) - if (highResolution) append("&high_res=1") - append("&min_dbm=").append(minDbm) - append("&max_dbm=").append(maxDbm) - append("&overlay_transparency=").append(overlayTransparency) - append("&run=1") - if (useHostBridge) append("&bridge=1") - } - val separator = if (baseUrl.endsWith("/")) "" else "/" - return "$baseUrl$separator?$query" - } - companion object { - // Meshtastic-typical stock defaults; all editable in the form before submission. Values mirror the planner's - // own factory defaults (src/store.ts defaultParams()) so an untouched form matches a fresh planner session. + // Meshtastic-typical stock defaults; all editable in the form before submission. const val DEFAULT_TX_POWER_WATTS: Double = 0.1 const val DEFAULT_TX_FREQ_MHZ: Double = 907.0 const val DEFAULT_TX_HEIGHT_METERS: Double = 2.0 @@ -95,17 +47,17 @@ data class SitePlannerParams( const val DEFAULT_MAX_DBM: Double = -80.0 const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50 - // Validation ranges mirroring the planner's input constraints (src/components/*.vue). - const val MIN_FREQ_MHZ: Double = 20.0 - const val MAX_FREQ_MHZ: Double = 20_000.0 + // ITU-R P.1812 is defined from 30 MHz to 6 GHz; the model rejects anything outside it. + const val MIN_FREQ_MHZ: Double = 30.0 + const val MAX_FREQ_MHZ: Double = 6_000.0 const val MIN_RX_SENSITIVITY_DBM: Double = -150.0 const val MAX_RX_SENSITIVITY_DBM: Double = -30.0 - const val MAX_RANGE_STANDARD_KM: Double = 150.0 - const val MAX_RANGE_HIGH_RES_KM: Double = 70.0 + const val MIN_RANGE_KM: Double = 1.0 + const val MAX_RANGE_KM: Double = 150.0 const val MIN_TRANSPARENCY: Int = 0 const val MAX_TRANSPARENCY: Int = 100 - /** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */ + /** Coverage palettes (value → label), matching the hosted Site Planner's picker. */ val COLOR_SCALES: List> = listOf( "plasma" to "Plasma", @@ -117,22 +69,3 @@ data class SitePlannerParams( ) } } - -private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~" -private val HEX = "0123456789ABCDEF".toCharArray() -private const val BYTE_MASK = 0xFF -private const val ASCII_LIMIT = 0x80 -private const val NIBBLE_BITS = 4 -private const val NIBBLE_MASK = 0xF - -/** Percent-encode a query-component value (UTF-8), so names with spaces/non-ASCII survive the round trip. */ -internal fun encodeQueryComponent(value: String): String = buildString { - for (byte in value.encodeToByteArray()) { - val code = byte.toInt() and BYTE_MASK - if (code < ASCII_LIMIT && code.toChar() in UNRESERVED) { - append(code.toChar()) - } else { - append('%').append(HEX[code shr NIBBLE_BITS]).append(HEX[code and NIBBLE_MASK]) - } - } -} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt index acab5b67d5d..dab7ee1f315 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt @@ -51,7 +51,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable @@ -84,9 +83,10 @@ import org.meshtastic.core.resources.site_planner_antenna_height_meters import org.meshtastic.core.resources.site_planner_color_scale import org.meshtastic.core.resources.site_planner_estimate import org.meshtastic.core.resources.site_planner_frequency_mhz -import org.meshtastic.core.resources.site_planner_high_resolution +import org.meshtastic.core.resources.site_planner_invalid_frequency import org.meshtastic.core.resources.site_planner_invalid_latitude import org.meshtastic.core.resources.site_planner_invalid_longitude +import org.meshtastic.core.resources.site_planner_invalid_max_range import org.meshtastic.core.resources.site_planner_invalid_positive import org.meshtastic.core.resources.site_planner_invalid_rx_sensitivity import org.meshtastic.core.resources.site_planner_max_range_km @@ -129,7 +129,6 @@ private class SiteFormState(initial: SitePlannerParams) { var rxSensitivity by mutableStateOf(initial.rxSensitivityDbm.toString()) var rxHeight by mutableStateOf(initial.rxHeightMeters.toString()) var maxRange by mutableStateOf(initial.maxRangeKm.toString()) - var highResolution by mutableStateOf(initial.highResolution) // Validation — computed from the (observable) string fields, so callers just read the booleans. private val latValue @@ -148,7 +147,16 @@ private class SiteFormState(initial: SitePlannerParams) { get() = (NumberFormatter.parseDecimalOrNull(power) ?: 0.0) <= 0.0 val freqBad - get() = (NumberFormatter.parseDecimalOrNull(freq) ?: 0.0) <= 0.0 + get() = + NumberFormatter.parseDecimalOrNull(freq).let { + it == null || it !in SitePlannerParams.MIN_FREQ_MHZ..SitePlannerParams.MAX_FREQ_MHZ + } + + val maxRangeBad + get() = + NumberFormatter.parseDecimalOrNull(maxRange).let { + it == null || it !in SitePlannerParams.MIN_RANGE_KM..SitePlannerParams.MAX_RANGE_KM + } val rxSensBad get() = @@ -158,7 +166,14 @@ private class SiteFormState(initial: SitePlannerParams) { // Guard the null-island (0,0) case so an empty ocean run can't be submitted. val canSubmit - get() = !latBad && !lonBad && !powerBad && !freqBad && !rxSensBad && (latValue != 0.0 || lonValue != 0.0) + get() = + !latBad && + !lonBad && + !powerBad && + !freqBad && + !rxSensBad && + !maxRangeBad && + (latValue != 0.0 || lonValue != 0.0) } /** @@ -166,8 +181,7 @@ private class SiteFormState(initial: SitePlannerParams) { * params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter]) * re-seed the coordinate fields when provided, preserving edits to the other fields. * - * [note] is shown above the submit button. A host whose planner runs somewhere this app cannot read the result back - * from uses it to say so, and to say what the user should do instead. + * [note] is shown above the submit button, such as the reason the last estimate didn't complete. */ @Composable fun SitePlannerSheet( @@ -286,7 +300,7 @@ private fun TransmitterSection( latError = if (state.latBad) stringResource(Res.string.site_planner_invalid_latitude) else null, lonError = if (state.lonBad) stringResource(Res.string.site_planner_invalid_longitude) else null, powerError = if (state.powerBad) posMsg else null, - freqError = if (state.freqBad) posMsg else null, + freqError = if (state.freqBad) stringResource(Res.string.site_planner_invalid_frequency) else null, ) } } @@ -309,15 +323,12 @@ private fun ReceiverSection(state: SiteFormState) { @Composable private fun SimulationSection(state: SiteFormState) { FormSection(stringResource(Res.string.site_planner_section_simulation), defaultExpanded = false) { - SiteField(state.maxRange, { state.maxRange = it }, Res.string.site_planner_max_range_km) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(Res.string.site_planner_high_resolution)) - Switch(checked = state.highResolution, onCheckedChange = { state.highResolution = it }) - } + SiteField( + state.maxRange, + { state.maxRange = it }, + Res.string.site_planner_max_range_km, + error = if (state.maxRangeBad) stringResource(Res.string.site_planner_invalid_max_range) else null, + ) } } @@ -478,7 +489,6 @@ private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): rxSensitivityDbm = NumberFormatter.parseDecimalOrNull(state.rxSensitivity) ?: initial.rxSensitivityDbm, rxHeightMeters = NumberFormatter.parseDecimalOrNull(state.rxHeight) ?: initial.rxHeightMeters, maxRangeKm = NumberFormatter.parseDecimalOrNull(state.maxRange) ?: initial.maxRangeKm, - highResolution = state.highResolution, minDbm = initial.minDbm, maxDbm = initial.maxDbm, overlayTransparency = initial.overlayTransparency, diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt deleted file mode 100644 index 05a9065bd46..00000000000 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.feature.map.component - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SitePlannerParamsTest { - - @Test - fun `toQueryUrl emits the planner's flat contract with advanced defaults and run and bridge flags`() { - val url = - SitePlannerParams( - name = "Tower A", - latitude = 51.05, - longitude = -114.07, - txPowerWatts = 0.5, - txFreqMhz = 915.0, - txHeightMeters = 12.0, - txGainDbi = 5.5, - colorScale = "turbo", - ) - .toQueryUrl("http://localhost:5173") - - // Advanced params carry their (planner-matching) defaults; high_res is omitted while false. - assertEquals( - "http://localhost:5173/?lat=51.05&lon=-114.07&name=Tower%20A" + - "&tx_power=0.5&tx_freq=915.0&tx_height=12.0&tx_gain=5.5&color_scale=turbo" + - "&rx_sensitivity=-130.0&rx_height=1.0&max_range=30.0" + - "&min_dbm=-130.0&max_dbm=-80.0&overlay_transparency=50&run=1&bridge=1", - url, - ) - } - - @Test - fun `toQueryUrl emits advanced overrides and high_res only when enabled`() { - val url = - SitePlannerParams( - name = "N", - latitude = 1.0, - longitude = 2.0, - rxSensitivityDbm = -139.0, - maxRangeKm = 60.0, - highResolution = true, - ) - .toQueryUrl("http://localhost:5173") - - assertTrue(url.contains("&rx_sensitivity=-139.0"), url) - assertTrue(url.contains("&max_range=60.0"), url) - assertTrue(url.contains("&high_res=1"), url) - } - - @Test - fun `the host bridge is asked for only when a host offers one`() { - // Opened in an ordinary browser there is no native bridge to hand the estimate to, and pointing the planner at - // one that is not there loses the result the user just waited for. - val params = SitePlannerParams("N", 1.0, 2.0) - - assertTrue(params.toQueryUrl("https://planner.example").contains("bridge=1")) - assertFalse(params.toQueryUrl("https://planner.example", useHostBridge = false).contains("bridge=1")) - // The simulation still runs either way. - assertTrue(params.toQueryUrl("https://planner.example", useHostBridge = false).contains("run=1")) - } - - @Test - fun `toQueryUrl does not double the slash when the base already ends in one`() { - val url = SitePlannerParams("N", 1.0, 2.0).toQueryUrl("https://planner.example/") - assertTrue(url.startsWith("https://planner.example/?lat=1.0&lon=2.0&name=N")) - } - - @Test - fun `encodeQueryComponent percent-encodes spaces and non-ASCII`() { - assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð")) - assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~")) - } - - @Test - fun `toQueryUrl clamps max_range to what the hosted planner accepts in each mode`() { - // Free text, so a user can ask for more than the hosted planner's SPLAT!-in-wasm handles. - // Only the URL is clamped - the local engine has no such ceiling. - val wide = SitePlannerParams(latitude = 51.05, longitude = -114.07, name = "Tower A", maxRangeKm = 400.0) - - assertTrue(wide.toQueryUrl("http://localhost:5173").contains("&max_range=150.0")) - assertTrue(wide.copy(highResolution = true).toQueryUrl("http://localhost:5173").contains("&max_range=70.0")) - } -} From 931c1242c9fb949016731b9eaba7e326dfc24b29 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:56:46 -0500 Subject: [PATCH 23/27] refactor(coverage): drop the WebView recovery test and spike-era docs --- .../kotlin/org/meshtastic/app/map/MapView.kt | 2 +- .../app/map/SitePlannerWebViewRecoveryTest.kt | 63 ------------------- feature/coverage/README.md | 20 +++--- .../feature/coverage/CoverageGrid.kt | 5 +- .../feature/coverage/CoveragePalette.kt | 3 +- .../feature/coverage/LocalCoverage.kt | 13 +--- .../feature/coverage/MapterhornElevation.kt | 2 +- .../feature/coverage/SitePlannerEstimate.kt | 4 +- .../feature/coverage/CoveragePaletteTest.kt | 3 +- .../feature/coverage/LocalCoverageTest.kt | 3 +- .../feature/coverage/CoverageDemo.kt | 5 +- .../feature/map/component/SitePlannerHost.kt | 4 +- 12 files changed, 29 insertions(+), 98 deletions(-) delete mode 100644 androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index b949456825b..1cb20c30305 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -193,6 +193,7 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.core.ui.util.rememberLocationPermissionState import org.meshtastic.core.ui.util.showToast +import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.MapBounds import org.meshtastic.feature.map.MapNodePolicy @@ -202,7 +203,6 @@ import org.meshtastic.feature.map.component.ClusterMembersDialog import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.DeleteWaypointDialog import org.meshtastic.feature.map.component.EditWaypointDialog -import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.component.MapFilterSheet diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt deleted file mode 100644 index 943d927f7cf..00000000000 --- a/androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.app.map - -import android.app.Application -import android.content.res.Resources -import android.util.AndroidRuntimeException -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Classifier guard for the Site Planner WebView provider-update mitigation: only the two exception shapes the AOSP - * provider-update race produces may be absorbed and retried; anything else must keep propagating. - */ -@RunWith(RobolectricTestRunner::class) -// Bare Application: booting the real MeshUtilApplication leaks its scopes across tests (#6644). -@Config(application = Application::class, sdk = [34]) -class SitePlannerWebViewRecoveryTest { - - @Test - fun `resources redirect race is recoverable`() { - val race = Resources.NotFoundException("failed to redirect ResourcesImpl") - assertTrue(isWebViewProviderUpdateException(race)) - } - - @Test - fun `webview provider load failure wrapper is recoverable`() { - val wrapper = - AndroidRuntimeException( - "android.webkit.WebViewFactory\$MissingWebViewPackageException: Failed to load WebView provider: No WebView installed", - ) - assertTrue(isWebViewProviderUpdateException(wrapper)) - } - - @Test - fun `unrelated AndroidRuntimeException is not swallowed`() { - val unrelated = AndroidRuntimeException("Calling startActivity() from outside of an Activity context") - assertFalse(isWebViewProviderUpdateException(unrelated)) - } - - @Test - fun `unrelated RuntimeException is not swallowed`() { - assertFalse(isWebViewProviderUpdateException(IllegalStateException("boom"))) - } -} diff --git a/feature/coverage/README.md b/feature/coverage/README.md index 1650224422c..74c161784f4 100644 --- a/feature/coverage/README.md +++ b/feature/coverage/README.md @@ -7,21 +7,27 @@ cached. ## Where it runs -| Host | Coverage | +Every map host runs the same flow. `feature:map`'s `SitePlannerHost` shows the form, then calls +`rememberCoverageEstimate()` from this module. The host adds the resulting GeoJSON as a map layer +and moves the map to the transmitter. + +| Host | Caller | | --- | --- | -| Desktop | `DesktopSitePlannerSlot` runs `LocalCoverage` and adds the result to the map as a GeoJSON layer | -| Android | Not wired. Both flavors still drive the hosted Site Planner in a hidden `WebView` (`SitePlannerRunner`) | +| Android, Google flavor | `MapView` | +| Android, F-Droid flavor | `SitePlannerSlot` | +| Desktop | `DesktopSitePlannerSlot` | ## Shape ```text -Site + ElevationSource -> LocalCoverage.sweepGrid() -> CoverageGrid -> toGeoJson(CoverageStyle) - | - +-- org.meshtastic:kp1812 (ITU-R P.1812) +SitePlannerParams -> Site + ElevationSource -> LocalCoverage.sweepGrid() -> CoverageGrid -> toGeoJson(CoverageStyle) + | + +-- org.meshtastic:kp1812 (ITU-R P.1812) ``` The model, the `ElevationSource` seam, and the GeoJSON export live in `commonMain`. Only the -`java.awt` demo renderer is in `jvmMain`. `ElevationSource` is a single suspend method: the app +`java.awt` demo renderer is in `jvmMain`. Terrain is cached under `terrain/coverage` in the app's +storage, apart from downloaded offline map regions. `ElevationSource` is a single suspend method: the app backs it with Mapterhorn tiles, and tests back it with a lambda, so the suite needs no network, `WebView`, or terrain download. diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt index 570d0eff0f5..a84118ab97a 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -19,9 +19,8 @@ package org.meshtastic.feature.coverage /** * Received signal strength on a regular lat/lon grid. * - * A grid, not the polar sweep the first version produced, because coverage has to be drawn as **filled iso-bands** — - * the hosted planner runs marching squares over exactly this shape. Polar samples exported as GeoJSON `Point` features - * get clustered by the map and render as a swarm of identical node markers, which is what they are. + * Coverage is drawn as **filled iso-bands**, which need a regular grid. Polar samples exported as GeoJSON `Point` + * features would be clustered by the map and render as a swarm of identical node markers. * * `NaN` marks a cell that was not computed. */ diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt index c173b3f9668..0863d6ab2b6 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt @@ -84,8 +84,7 @@ enum class CoveragePalette(val key: String, private val anchors: List) { } /** - * How a [CoverageGrid] is drawn — the planner's Display section, which the local engine used to ignore entirely while - * the sheet still offered it. + * How a [CoverageGrid] is drawn: the form's Display section. * * [minDbm] and [maxDbm] fix the ends of the ramp rather than letting the strongest cell define them, so two sites are * comparable and the picker means what it says. diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index 9c5ab920881..82ae856975c 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -28,16 +28,9 @@ import kotlin.math.max import kotlin.math.sin /** - * Spike: compute RF coverage locally, replacing the headless-WebView hand-off to the hosted Site Planner. - * - * Today `SitePlannerRunner` loads site.meshtastic.org in a hidden WebView, waits up to 45 s for a JavaScript bridge to - * hand back GeoJSON, and needs the network. Desktop cannot even do that — it opens a browser and asks the user to - * export and re-import a file by hand. - * - * This computes the same answer in-process from `org.meshtastic:kp1812` and an [ElevationSource], so it works offline - * once terrain is cached and produces a result on every platform the app runs on. The propagation model is ITU-R P.1812 - * rather than the planner's SPLAT!/ITM — a different model, so predictions will not match the hosted planner pixel for - * pixel. + * RF coverage computed on the device from `org.meshtastic:kp1812` (ITU-R P.1812) and an [ElevationSource]. It works + * offline once terrain is cached. P.1812 is a different model from the hosted planner's SPLAT! ITM, so predictions + * don't match it pixel for pixel. */ class LocalCoverage(private val elevation: ElevationSource, private val atmosphere: Atmosphere = Atmosphere()) { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 6dfbcd1e7c5..13f75b84d4d 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -161,7 +161,7 @@ class MapterhornElevation( } } -/** One endpoint now, but the cache is keyed by it so a second source would not collide. */ +/** The cache is keyed by endpoint, so a second terrain source wouldn't collide with this one. */ private const val ARCHIVE = "tiles.mapterhorn.com" /** Pack a tile's coordinates into one cache key. */ diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index 0d58e99a3d9..2c67264eff1 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt @@ -29,8 +29,8 @@ import org.meshtastic.feature.map.terrain.TerrainTileStore import kotlin.math.log10 /** - * Computes a Site Planner estimate on this device: Mapterhorn terrain for the coverage disc, cached under [store], - * then the ITU-R P.1812 sweep, returned as the styled GeoJSON iso-bands a map layer takes. + * Computes a Site Planner estimate on this device: Mapterhorn terrain for the coverage disc, cached under [store], then + * the ITU-R P.1812 sweep, returned as the styled GeoJSON iso-bands a map layer takes. * * Throws when terrain can't be fetched or a value is outside what P.1812 accepts. Cancellation propagates. */ diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt index 60d4f486d7e..1694aadeaba 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt @@ -22,8 +22,7 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue /** - * The Display section's palette picker, which the local engine ignored entirely while the sheet went on offering it — - * every estimate came out in the same hardcoded red-amber-green whatever was chosen. + * The Display section's palette picker: every palette the form offers resolves, and the chosen one colors the bands. */ class CoveragePaletteTest { diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt index aab66b336fa..4d2bfd62ded 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt @@ -23,8 +23,7 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Spike verification: the local model behaves the way a coverage prediction must, without needing a network, a WebView - * or a terrain download. + * The local model behaves the way a coverage prediction must, without a network or a terrain download. * * These are behavioral assertions, not conformance ones: `kp1812`'s own suite checks the model against the ITU * reference. What matters here is that this module drives it correctly. diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index c60eff999b1..229c27e36a4 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -30,9 +30,8 @@ import kotlin.system.measureTimeMillis /** * Runs a real coverage prediction end to end and writes a PNG plus the GeoJSON the app imports. * - * This exists so the spike can be *seen* rather than only asserted: real Mapterhorn terrain, the ITU-R P.1812 model - * from `org.meshtastic:kp1812`, no network call to site.meshtastic.org, no WebView, no browser. The same - * `LocalCoverage` the desktop app would call. + * It runs the same `LocalCoverage` the apps call, over real Mapterhorn terrain, so a change can be seen rather than + * only asserted. * * `./gradlew :feature:coverage:coverageDemo -PuseMavenLocal` */ diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt index 86a8605b1e1..3f5bedcd154 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt @@ -48,8 +48,8 @@ import org.meshtastic.core.resources.site_planner_failed /** * The Site Planner flow every map host shares: the [SitePlannerSheet] form, then [estimate] behind a progress dialog, - * then [onImport] with the estimate's GeoJSON and the transmitter position so the host can add a map layer and move - * the camera to it. + * then [onImport] with the estimate's GeoJSON and the transmitter position so the host can add a map layer and move the + * camera to it. * * Canceling the progress dialog stops the estimate and returns to the form with the same values. A failed estimate * returns there too, with an error note. The location shortcuts re-seed the coordinates from the device From 8fbe1a3e168f8118c9accf3f1325946e820adfbd Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:22:53 -0500 Subject: [PATCH 24/27] docs(coverage): restructure comments without em dashes --- .../feature/coverage/CoverageContours.kt | 9 +++------ .../feature/coverage/CoveragePalette.kt | 4 ++-- .../feature/coverage/LocalCoverage.kt | 4 ++-- .../feature/coverage/MapterhornElevation.kt | 18 +++++++++--------- .../feature/coverage/MapterhornTiles.kt | 8 ++++---- .../feature/coverage/PolarCoverage.kt | 10 +++++----- .../feature/coverage/TerrainCache.kt | 10 +++++----- .../feature/coverage/PolarCoverageTest.kt | 4 ++-- .../feature/coverage/CoverageDemo.kt | 4 ++-- .../feature/map/terrain/MapterhornEndpoints.kt | 2 +- 10 files changed, 35 insertions(+), 38 deletions(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index c886c8ad105..5b649ee7611 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -21,7 +21,7 @@ import kotlin.math.roundToInt import kotlin.math.roundToLong /** - * Turns a [CoverageGrid] into filled iso-bands — "signal ≥ X dBm" polygons — as GeoJSON. + * Turns a [CoverageGrid] into filled iso-bands ("signal ≥ X dBm" polygons) as GeoJSON. * * This is the shape a map can actually draw coverage with, and the shape the hosted Site Planner exports. Emitting the * sample points instead produces a swarm of markers that the map clusters, which is what the first version did. @@ -89,7 +89,7 @@ private fun CoverageGrid.inBand(x: Int, y: Int, range: ClosedFloatingPointRange< internal class CoverageBand(val thresholdDbm: Double, val rings: List>>) /** - * The coverage as GeoJSON polygons with simplestyle-spec fills — what the map draws as a layer. + * The coverage as GeoJSON polygons with simplestyle-spec fills: what the map draws as a layer. * * Bands run from the receiver's sensitivity upward, so the outermost polygon is "a node here can hear this site at all" * and the inner ones are progressively stronger signal. @@ -145,10 +145,7 @@ private const val DEFAULT_BANDS = 6 private const val EMPTY_FEATURE_COLLECTION = """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}""" -/** - * One decimal place, without `String.format` — which is JVM-only and does not exist on Kotlin/Native or wasm. Adding - * the native targets is what surfaced that. - */ +/** One decimal place, without `String.format`, which is JVM-only and does not exist on Kotlin/Native or wasm. */ internal fun Double.toFixed1(): String { val scaled = (this * TENTHS).roundToLong() val sign = if (scaled < 0) "-" else "" diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt index 0863d6ab2b6..3a86b1b1ff8 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt @@ -69,7 +69,7 @@ enum class CoveragePalette(val key: String, private val anchors: List) { } companion object { - /** The palette the planner names, or [PLASMA] — its own default — when the name is unknown. */ + /** The palette the planner names, or [PLASMA] (its own default) when the name is unknown. */ fun forKey(key: String?): CoveragePalette = entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: PLASMA @@ -93,7 +93,7 @@ class CoverageStyle( val palette: CoveragePalette = CoveragePalette.PLASMA, val minDbm: Double = DEFAULT_MIN_DBM, val maxDbm: Double = DEFAULT_MAX_DBM, - /** 0 is invisible, 1 is solid — the planner's transparency percentage inverted. */ + /** 0 is invisible, 1 is solid: the planner's transparency percentage inverted. */ val opacity: Double = DEFAULT_OPACITY, ) { init { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt index 82ae856975c..5759d4d449f 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt @@ -39,7 +39,7 @@ class LocalCoverage(private val elevation: ElevationSource, private val atmosphe * * Terrain profile resolution and receiver spacing are deliberately **decoupled**. P.1812 evaluates diffraction * across the whole profile between transmitter and receiver, so the profile has to be sampled near the terrain's - * own resolution — Mapterhorn at z11 is ~75 m/px. Sampling it at the receiver spacing instead (900 m over a 30 km + * own resolution (Mapterhorn at z11 is ~75 m/px). Sampling it at the receiver spacing instead (900 m over a 30 km * radius) makes the model see a jagged, aliased profile and invent diffraction loss that is not there: visible as * spurious rings and spokes in the plot, and a badly depressed reachable fraction. * @@ -216,7 +216,7 @@ data class CoveragePoint(val latitude: Double, val longitude: Double, val rxDbm: /** The result of a sweep. */ data class Coverage(val site: Site, val points: List) { - /** Points at or above the receiver's sensitivity — where a node would actually hear this site. */ + /** Points at or above the receiver's sensitivity: where a node would actually hear this site. */ val reachable: List get() = points.filter { it.rxDbm >= site.rxSensitivityDbm } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 13f75b84d4d..605725d7e9f 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -81,7 +81,7 @@ class MapterhornElevation( val tile = TerrainTileMath.tileAt(zoom, latitude, longitude) val key = tileKey(tile.x, tile.y) val snapshot = cache().snapshot() - // Ocean, or outside the endpoint's data, decodes to null — sea level, as the planner assumes too. + // Ocean, or outside the endpoint's data, decodes to null: sea level, as the planner assumes too. val decoded = (if (snapshot.containsKey(key)) snapshot[key] else awaitTile(key, tile.x, tile.y)) ?: return 0.0 // Fractional position of this coordinate within its tile. @@ -100,7 +100,7 @@ class MapterhornElevation( /** * Warm every tile inside [area] before anything asks for one, many at a time. * - * Optional — sampling fetches on demand without it — and returns how many tiles it warmed. Worth calling anyway: + * Optional (sampling fetches on demand without it), and returns how many tiles it warmed. Worth calling anyway: * on-demand misses arrive one radial at a time, while this issues the whole disc at once and lets the requests * overlap. */ @@ -131,7 +131,7 @@ class MapterhornElevation( return warmed.size } - /** How many tiles are decoded and resident — useful when reporting a run. */ + /** How many tiles are decoded and resident, useful when reporting a run. */ val tilesResident: Int get() = cache?.size ?: 0 @@ -154,7 +154,7 @@ class MapterhornElevation( * ~37 m per pixel at mid latitudes, which matches the 50 m terrain profile step. * * Measured against z13 on the Seattle demo: identical reachable fraction and max range, and an rx range of - * -147.8..-41.3 against -148.2..-41.3 — for 72 tiles instead of 256. The extra detail lands below the step the + * -147.8..-41.3 against -148.2..-41.3, for 72 tiles instead of 256. The extra detail lands below the step the * model samples at. The endpoint serves up to [MapterhornEndpoints.TILES_MAX_ZOOM] for anyone who wants it. */ const val DEFAULT_ZOOM = 12 @@ -194,12 +194,12 @@ private const val DEG_TO_RAD = 0.017453292519943295 * The deepest zoom at or below [wanted] whose tiles for [bounds] still fit the shared cache. * * Tiles scale with the square of the sampled radius and with 1/cos(latitude), so a fixed zoom is only ever right for - * one area. At z12 a 30 km disc is ~120 tiles at mid latitudes but ~360 above 65°N, and the radius is a free-text field - * — 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful: eviction is insertion order, so - * the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured once at - * ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds. + * one area. At z12 a 30 km disc is ~120 tiles at mid latitudes but ~360 above 65°N, and the radius is a free-text + * field: 70 km asks for 500 to 1800. Past the cache's capacity the failure is not graceful: eviction is insertion + * order, so the sweep evicts the very tiles it is about to read and re-decodes the whole disc on every pass. Measured + * once at ~1800 tiles: 133 MB downloaded and seconds per estimate instead of hundreds of milliseconds. * - * Dropping a zoom quarters the tile count, so this converges immediately and leaves the common case untouched — a 30 km + * Dropping a zoom quarters the tile count, so this converges immediately and leaves the common case untouched: a 30 km * disc at mid latitudes still samples at z12. */ internal fun zoomFitting(wanted: Int, bounds: GeoBounds?): Int { diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index f7a6892bd0d..cb9ded8bc50 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -35,12 +35,12 @@ import org.meshtastic.feature.map.terrain.TileIndex * Terrarium tile bytes, from local storage if they are there and from Mapterhorn's XYZ endpoint if they are not. * * Plain per-tile requests rather than range reads into `planet.pmtiles`. The archive is one seekable channel, so its - * tiles come back strictly one at a time — measured at ~300 ms each, which for a 25 km disc was the whole cost of a + * tiles come back strictly one at a time, measured at ~300 ms each, which for a 25 km disc was the whole cost of a * coverage sweep. These are independent, Cloudflare-cached, and suspend rather than block, so the only limit is how * many we choose to have in flight. * - * Anything fetched is written back to [store] when there is one, so the next run — or the next launch — pays nothing - * for the same ground. + * Anything fetched is written back to [store] when there is one, so the next run (or the next launch) pays nothing for + * the same ground. */ internal class MapterhornTiles( private val http: HttpClient, @@ -57,7 +57,7 @@ internal class MapterhornTiles( } /** - * Only 404 means "no tile here" — ocean, or past the endpoint's deepest zoom — and only 404 is allowed to return + * Only 404 means "no tile here" (ocean, or past the endpoint's deepest zoom), and only 404 is allowed to return * null, because null is cached as flat ground for the rest of the process. A throttled or failed request that * returned null would quietly turn a mountain into sea level, so it throws and the caller surfaces a failed * estimate instead. diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt index 6605ef91d92..a0d9dc90be9 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt @@ -27,8 +27,8 @@ import kotlin.math.floor * Received power on a polar lattice centered on the site: [radials] bearings by [rings] ranges. * * Coverage is computed in polar and only then rasterised, because a receiver's prediction needs the whole terrain - * profile back to the transmitter. On a radial every receiver shares one profile — the far one is the near one plus a - * few points — so a bearing's worth of predictions costs a single walk. Sampling the cartesian grid directly re-walks a + * profile back to the transmitter. On a radial every receiver shares one profile (the far one is the near one plus a + * few points), so a bearing's worth of predictions costs a single walk. Sampling the cartesian grid directly re-walks a * profile per cell and re-reads the same terrain thousands of times over. * * `dbm` is indexed `bearing * rings + ring`; ring `r` sits at `firstKm + r * ringStepKm`. @@ -60,7 +60,7 @@ class PolarCoverage( * * [profileStepKm] is the terrain sampling interval and should track the elevation source's own resolution; [rings] is * how many of those steps carry a receiver. They are independent because the cost is the [P1812.predict] calls, not the - * profile length — a dense profile with sparse receivers is both cheaper and more accurate than matching the two. + * profile length: a dense profile with sparse receivers is both cheaper and more accurate than matching the two. */ suspend fun LocalCoverage.sweepPolar( site: Site, @@ -120,8 +120,8 @@ suspend fun LocalCoverage.sweepPolar( * Resample the polar lattice onto the regular grid the contour tracer needs. * * Bilinear in (bearing, range), so the grid is smooth rather than showing the lattice's own spokes. The grid can be far - * finer than the lattice for free — it costs no predictions — which is what lets the bands follow terrain instead of - * the sampling pattern. + * finer than the lattice for free (it costs no predictions), which is what lets the bands follow terrain instead of the + * sampling pattern. */ fun PolarCoverage.toGrid(resolution: Int = DEFAULT_GRID): CoverageGrid { require(resolution >= MIN_GRID) { "resolution must be >= $MIN_GRID, got $resolution" } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index 023a001c278..b039a70656e 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -26,7 +26,7 @@ import kotlin.concurrent.Volatile /** * Decoded terrain for one archive at one zoom, shared by every [MapterhornElevation] that reads it. * - * A tile is immutable data keyed by archive, zoom and position, so there is nothing to scope it to a single instance — + * A tile is immutable data keyed by archive, zoom and position, so there is nothing to scope it to a single instance, * and scoping it to one was measurably wrong. The desktop planner's composable is disposed when its sheet closes, so a * second estimate paid the full terrain download again for tiles it had already decoded seconds earlier: 8.8 s where * the sweep itself is 0.2 s. @@ -55,14 +55,14 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { * What is resident right now. * * Handed out as one map rather than as `contains` plus `get`, because between two calls an eviction could drop the - * key and the second would answer null — which a sampler reads as ocean. + * key and the second would answer null, which a sampler reads as ocean. */ fun snapshot(): Map = resolved /** * The decoded tile, starting [produce] only if nobody else already has. * - * [produce] returns a [Deferred] rather than the tile so that the caller owns the scope the fetch runs in — the + * [produce] returns a [Deferred] rather than the tile so that the caller owns the scope the fetch runs in. The * cache outlives any one [MapterhornElevation] and must not hold its scope. */ suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? { @@ -95,7 +95,7 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { companion object { /** * A 25 km disc is ~70 tiles at z12 and a decoded tile is ~256 KB, so this holds a working set with room to pan. - * Asking for a deeper zoom will evict — 256 tiles at z13 — which the disk cache makes a decode rather than a + * Asking for a deeper zoom will evict (256 tiles at z13), which the disk cache makes a decode rather than a * download, but it is why z12 is the default. */ const val DEFAULT_CAPACITY = 192 @@ -120,7 +120,7 @@ internal object SharedTerrain { /** * One HTTP client for the process. * - * Building one per estimate cost seconds — the engine brings up its own selector threads — for a client that is + * Building one per estimate cost seconds (the engine brings up its own selector threads) for a client that is * stateless once connected. Never closed: it is shared, and an estimate that closed it would break the next one. */ val http: HttpClient by lazy { HttpClient() } diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt index b61883936ae..f569c4d45cb 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt @@ -93,8 +93,8 @@ class PolarCoverageTest { @Test fun resamplingFinerDoesNotMoveThePrediction() = runTest { - // The grid is free of the prediction budget, so a finer one has to show the same coverage — - // if it did not, the extra cells would be the resampler inventing detail. + // The grid is free of the prediction budget, so a finer one has to show the same coverage. + // If it did not, the extra cells would be the resampler inventing detail. val polar = LocalCoverage(flat).sweepPolar(site(), radials = 32, rings = 16) val coarse = polar.toGrid(resolution = 32).reachableFraction val fine = polar.toGrid(resolution = 192).reachableFraction diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt index 229c27e36a4..9cce9815662 100644 --- a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt @@ -103,7 +103,7 @@ object CoverageDemo { } /** - * What a repeat estimate costs once terrain is decoded — the number that decides whether the planner can update + * What a repeat estimate costs once terrain is decoded: the number that decides whether the planner can update * interactively. * * The second run builds a *fresh* source, because that is the app's real shape: the planner's composable is @@ -176,7 +176,7 @@ object CoverageDemo { return java.awt.Color(r, gc, 60) } - private const val DEFAULT_LAT = 47.6062 // Seattle — real relief nearby + private const val DEFAULT_LAT = 47.6062 // Seattle, real relief nearby private const val DEFAULT_LON = -122.3321 private const val GRID = 256 } diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt index d8891165bf1..47eb87855db 100644 --- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt @@ -33,7 +33,7 @@ object MapterhornEndpoints { * * Probed 2026-09-16 at Seattle: z0–16 all 200 (~56–95 KB each, ~130 ms, Cloudflare-cached for a week, * `access-control-allow-origin: *`), z17+ 404. So it serves the regional detail the per-z6-tile archives were meant - * to, and does it as independent cacheable requests rather than range reads into one seekable channel — which is + * to, and does it as independent cacheable requests rather than range reads into one seekable channel, which is * what made bulk terrain sampling serial. */ fun tileUrl(zoom: Int, x: Int, y: Int): String = "https://tiles.mapterhorn.com/$zoom/$x/$y.webp" From 220d0d3a9608b6d80e858e5e75b81e437c3ddbef Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:48:02 -0500 Subject: [PATCH 25/27] ci: run feature:coverage tests in the feature shard --- .github/workflows/reusable-check.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 1d693f7268d..11f2cf06d24 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -238,6 +238,7 @@ jobs: tasks: >- :core:service:allTests :feature:connections:allTests + :feature:coverage:allTests :feature:discovery:allTests :feature:docs:allTests :feature:firmware:allTests @@ -252,6 +253,7 @@ jobs: kover: >- :core:service:koverXmlReport :feature:connections:koverXmlReport + :feature:coverage:koverXmlReport :feature:discovery:koverXmlReport :feature:docs:koverXmlReport :feature:firmware:koverXmlReport From 2e84b571d969320a5205ad3c05b1235e39b1e93c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:06:02 -0500 Subject: [PATCH 26/27] fix(coverage): address review findings - Escape the site name in the exported GeoJSON. - A tile that fails to decode is downloaded once more, then fails the estimate, instead of reading as sea level. - A caller still running retries a shared tile fetch another estimate canceled. - Reject non-positive transmit power before the dBm conversion. - Keep the planner form from being reseeded by node updates mid-edit. --- .../feature/coverage/CoverageContours.kt | 3 +- .../feature/coverage/MapterhornElevation.kt | 9 ++- .../feature/coverage/MapterhornTiles.kt | 10 ++- .../feature/coverage/SitePlannerEstimate.kt | 5 +- .../feature/coverage/TerrainCache.kt | 37 +++++++--- .../feature/coverage/CoverageContoursTest.kt | 51 +++++++++++++ .../feature/coverage/TerrainCacheTest.kt | 71 +++++++++++++++++++ .../feature/map/component/SitePlannerHost.kt | 4 +- .../meshtastic/feature/map/kml/KmlGeoJson.kt | 2 +- 9 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index 5b649ee7611..e8dffe8b5b2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.coverage +import org.meshtastic.feature.map.kml.jsonString import kotlin.math.abs import kotlin.math.roundToInt import kotlin.math.roundToLong @@ -132,7 +133,7 @@ fun CoverageGrid.toGeoJson(style: CoverageStyle = CoverageStyle(), bandCount: In return """{ "type": "FeatureCollection", - "properties": {"generator": "meshtastic-kp1812", "name": "${site.name}", "model": "ITU-R P.1812", + "properties": {"generator": "meshtastic-kp1812", "name": ${site.name.jsonString()}, "model": "ITU-R P.1812", "palette": "${style.palette.key}", "min_dbm": ${style.minDbm.toFixed1()}, "max_dbm": ${style.maxDbm.toFixed1()}}, "features": [ $features diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 605725d7e9f..9c9c6b32b59 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -138,8 +138,13 @@ class MapterhornElevation( private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = cache().getOrFetch(key) { scope.async { decode(x, y) } } - private suspend fun decode(x: Int, y: Int): ElevationTile? = - tiles.bytes(zoom, x, y)?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } + // Null means the endpoint has no tile there, so it must never stand in for a failed decode: a corrupt cached + // tile gets one fresh download, and bytes that still don't decode fail the estimate. + private suspend fun decode(x: Int, y: Int): ElevationTile? { + val cached = tiles.bytes(zoom, x, y) ?: return null + return runCatching { decodeTerrariumTile(cached) }.getOrNull() + ?: tiles.bytes(zoom, x, y, fresh = true)?.let { decodeTerrariumTile(it) } + } override fun close() = scope.cancel() diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index cb9ded8bc50..3960c06b9d5 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -49,11 +49,15 @@ internal class MapterhornTiles( ) { private val gate = Semaphore(concurrency) - /** Raw Terrarium WebP for one tile, or null where the endpoint has no data (ocean, out of range). */ - suspend fun bytes(zoom: Int, x: Int, y: Int): ByteArray? { + /** + * Raw Terrarium WebP for one tile, or null where the endpoint has no data (ocean, out of range). [fresh] skips the + * stored copy and overwrites it, for a stored tile that didn't decode. + */ + suspend fun bytes(zoom: Int, x: Int, y: Int, fresh: Boolean = false): ByteArray? { val tile = TileIndex(zoom, x, y) val source = sourceFor(zoom) - return readLocal(source, tile) ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } + val stored = if (fresh) null else readLocal(source, tile) + return stored ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } } /** diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index 2c67264eff1..84dad4c2158 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt @@ -87,7 +87,10 @@ internal fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle. transparencyPercent = overlayTransparency, ) -internal fun wattsToDbm(watts: Double): Double = DBM_PER_DECADE * log10(watts * MILLIWATTS_PER_WATT) +internal fun wattsToDbm(watts: Double): Double { + require(watts > 0.0) { "transmit power must be positive, got $watts W" } + return DBM_PER_DECADE * log10(watts * MILLIWATTS_PER_WATT) +} private const val DBM_PER_DECADE = 10.0 private const val MILLIWATTS_PER_WATT = 1000.0 diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index b039a70656e..98cbe8883b2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -17,7 +17,10 @@ package org.meshtastic.feature.coverage import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.meshtastic.feature.map.terrain.ElevationTile @@ -65,16 +68,34 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { * [produce] returns a [Deferred] rather than the tile so that the caller owns the scope the fetch runs in. The * cache outlives any one [MapterhornElevation] and must not hold its scope. */ + @Suppress("SuspendFunSwallowedCancellation") // ensureActive() rethrows this caller's own cancellation suspend fun getOrFetch(key: Long, produce: () -> Deferred): ElevationTile? { - // A canceled fetch must not be handed to the next caller: the scope producing it belongs to - // one MapterhornElevation, and canceling that (the planner sheet being dismissed mid-run) - // would otherwise leave a dead Deferred that fails every later sweep touching this tile. - val pending = lock.withLock { - inFlight[key]?.takeUnless { it.isCancelled } ?: produce().also { inFlight[key] = it } + while (true) { + // A canceled fetch must not be handed to the next caller: the scope producing it belongs to + // one MapterhornElevation, and canceling that (the planner sheet being dismissed mid-run) + // would otherwise leave a dead Deferred that fails every later sweep touching this tile. + var started = false + val pending = lock.withLock { + inFlight[key]?.takeUnless { it.isCancelled } + ?: produce().also { + inFlight[key] = it + started = true + } + } + val tile = + try { + pending.await() + } catch (e: CancellationException) { + // Another instance's fetch can be canceled under a caller that is still running; that caller + // starts its own. Its own cancellation, or its own fetch being canceled, still ends the wait. + currentCoroutineContext().ensureActive() + if (started) throw e + lock.withLock { if (inFlight[key] === pending) inFlight.remove(key) } + continue + } + publish(listOf(key to tile)) + return tile } - val tile = pending.await() - publish(listOf(key to tile)) - return tile } /** Record tiles fetched in bulk, as a prefetch does. */ diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt new file mode 100644 index 00000000000..b93bbb5e6a8 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlin.test.Test +import kotlin.test.assertTrue + +class CoverageContoursTest { + + @Test + fun aSiteNameWithQuotesAndBackslashesStaysOneJsonString() { + val site = + Site( + name = "Tom's \"Hill\" \\ relay", + latitude = 47.6, + longitude = -122.2, + frequencyMhz = 915.0, + txPowerDbm = 30.0, + rxSensitivityDbm = -130.0, + ) + val grid = + CoverageGrid( + site = site, + width = 2, + height = 2, + north = 47.61, + south = 47.59, + east = -122.19, + west = -122.21, + dbm = DoubleArray(4) { -90.0 }, + ) + + val geoJson = grid.toGeoJson() + + assertTrue("\"name\": \"Tom's \\\"Hill\\\" \\\\ relay\"" in geoJson, geoJson.take(300)) + } +} diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt new file mode 100644 index 00000000000..105a1452b62 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.coverage + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.feature.map.terrain.ElevationTile +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TerrainCacheTest { + + private val tile = ElevationTile(1, 1, floatArrayOf(5f)) + + @Test + fun aFetchCanceledUnderAStillActiveCallerIsStartedAgain() = runTest { + val cache = TerrainCache() + val owner = CoroutineScope(Job()) + val ownerFetch = owner.async { awaitCancellation() } + + // The owner starts the fetch, then a second caller joins it rather than starting its own. + val first = launch { runCatching { cache.getOrFetch(KEY) { ownerFetch } } } + runCurrent() + val second = async { cache.getOrFetch(KEY) { CompletableDeferred(tile) } } + runCurrent() + + owner.cancel() + + assertSame(tile, second.await()) + first.join() + assertTrue(ownerFetch.isCancelled) + } + + @Test + fun aCallerWhoseOwnFetchIsCanceledStopsWaiting() = runTest { + val cache = TerrainCache() + val owner = CoroutineScope(Job()) + val outcome = async { runCatching { cache.getOrFetch(KEY) { owner.async { awaitCancellation() } } } } + runCurrent() + + owner.cancel() + + assertTrue(outcome.await().isFailure) + } + + private companion object { + const val KEY = 42L + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt index 3f5bedcd154..bac92e833cc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt @@ -65,7 +65,9 @@ fun SitePlannerHost( onUseNodeLocation: (() -> Pair)? = null, onUseMapCenter: (() -> Pair)? = null, ) { - var params by remember(initialParams) { mutableStateOf(initialParams) } + // Seeded once per launch: hosts recompute initialParams on every node update, and a position fix must not + // overwrite what the user has typed or submitted. + var params by remember { mutableStateOf(initialParams) } var running by remember { mutableStateOf(null) } var failed by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt index ab7bd7a9625..534076d30db 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt @@ -72,7 +72,7 @@ internal fun String.toCssColor(): Pair? { } /** Minimal JSON string escaping — KML descriptions routinely carry quotes, newlines and CDATA-wrapped HTML. */ -internal fun String.jsonString(): String { +fun String.jsonString(): String { val escaped = StringBuilder("\"") forEach { character -> when { From fa7d81708d8f5cfa09091b3805593b05e154ad46 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:20:21 -0500 Subject: [PATCH 27/27] fix(coverage): fit the terrain zoom to the estimate's disc Without bounds the zoom guard never ran, so a wide range decoded more tiles than the cache holds. --- .../org/meshtastic/feature/coverage/SitePlannerEstimate.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index 84dad4c2158..8ff24c81a8f 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt @@ -40,7 +40,8 @@ suspend fun estimateCoverageGeoJson( dispatcher: CoroutineDispatcher, ): String = withContext(dispatcher) { val site = params.toSite() - MapterhornElevation(store = store).use { source -> + // The bounds let the zoom drop for a wide disc, so its tiles fit the decoded cache. + MapterhornElevation(bounds = site.coverageBounds(), store = store).use { source -> source.prefetch(site) LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) }