Skip to content

feat(coverage): compute Site Planner coverage on device with kp1812 - #7264

Merged
jamesarich merged 27 commits into
mainfrom
feat/kp1812-local-coverage
Oct 3, 2026
Merged

jamesarich merged 27 commits into
mainfrom
feat/kp1812-local-coverage

Conversation

@jamesarich

@jamesarich jamesarich commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

The Site Planner computes coverage on the device instead of driving the hosted planner. On Android a hidden WebView loaded site.meshtastic.org, waited up to 45 s for a JavaScript bridge, and needed the network. Desktop couldn't run it at all, so it opened a browser and asked the user to export a .geojson and import it by hand. All three map hosts now run org.meshtastic:kp1812, a Kotlin Multiplatform implementation of ITU-R P.1812, over Mapterhorn terrain. The result lands on the map as a coverage layer, offline once the terrain is cached.

P.1812 is a different model from SPLAT! ITM, so predictions don't match the hosted planner pixel for pixel.

🌟 New Features

  • feature:coverage computes received signal on a polar lattice (one terrain profile per bearing), resamples it onto a grid, and exports disjoint dBm bands as GeoJSON MultiPolygon features with simplestyle fills. The form's palette, dBm range, and transparency drive the render.
  • One SitePlannerHost in feature:map serves the Google and F-Droid flavors and desktop: the form, an estimating dialog whose Cancel returns 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.

🛠️ Refactoring & Architecture

  • rememberCoverageEstimate() binds the estimate to the app's compute dispatcher and a terrain/coverage disk cache kept apart from downloaded map regions.
  • The model, the ElevationSource seam, and the GeoJSON export are commonMain. Android takes kp1812's jvm artifact. iOS is compile-checked through kmpSmokeCompile, not run.
  • A failed terrain request is a failed estimate, not sea level: only 404 means no tile.
  • core:konsist allows coverage to import feature:map (for the form's params) and feature:map-terrain, and allowlists the scope MapterhornElevation owns and cancels on close().

🧹 Chores

  • Removed SitePlannerRunner (the WebView), SitePlannerBrowserSheet, the planner URL builder and its tests, the WebView recovery test, the high-resolution switch, and the three strings only they used.
  • kp1812 0.1.0 added to the version catalog.

Testing Performed

  • feature:coverage tests check behavior, since kp1812 checks itself against the ITU reference: decay with distance, a 400 m ridge shadowing what's behind it, transmit power, receiver sensitivity, equal bearings, geodesy, GeoJSON structure, palettes, and the zoom guard.
  • Full baseline (spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests kmpSmokeCompile, -PuseMavenLocal), then the androidApp unit tests after removing the WebView test.
  • F-Droid debug on an emulator against a simulated radio: the form pre-fills from the node, the estimate draws a terrain-shaped coverage layer, the map moves to it, and the layer survives an app restart. Not yet run on the Google flavor or on a physical phone.
  • Timing on that emulator (debuggable build, 4 cores, 3 GB and swapping) at the default 30 km: terrain 2.8 s for 72 tiles, sweep 39 s, GeoJSON 0.3 s, import under 1 ms. The desktop JVM sweeps 25 km in 0.3 s. A phone figure needs a release build on hardware.

Summary by CodeRabbit

  • New Features
    • Added in-app site planning with terrain-based radio coverage estimates displayed as a map layer.
    • Added color-coded signal-strength bands and terrain caching, allowing repeat estimates to work offline once terrain is cached.
  • Changes
    • Replaced the browser-based planner flow and high-resolution option with in-app estimates and a maximum-range setting.
    • Added frequency validation (30–6,000 MHz) and maximum-range validation (1–150 km), with clearer error messages.
    • Updated failure guidance to recommend checking terrain downloads and retrying.

@jamesarich

Copy link
Copy Markdown
Collaborator Author

Baseline on this head (9d7e0542d), run locally with -PuseMavenLocal so org.meshtastic:kp1812:0.1.0 resolves from ~/.m2:

./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests kmpSmokeCompile --continue
BUILD SUCCESSFUL in 4m 16s (171 executed, 4 from cache, 1736 up-to-date after an earlier run of the same tree)

Spotless made no changes. CI on this PR is expected to fail at dependency resolution until kp1812's first release is on Maven Central; that is the one thing standing between draft and ready.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (5)
.github/instructions/kmp-common.instructions.md — configured
.skills/code-review/SKILL.md — configured
AGENTS.md — configured
.skills/navigation-and-di/SKILL.md — configured
.skills/implement-feature/SKILL.md — configured

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: meshtastic/Meshtastic-Android/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 39f88523-c7e0-4dcc-81e7-17c41c8b93da
📥 Commits

Reviewing files that changed from the base of the PR and between 2e84b57 and fa7d817.

📒 Files selected for processing (1)
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a multiplatform feature for local RF coverage estimation using terrain data. It replaces the Site Planner browser handoff with an in-app estimate flow that imports generated GeoJSON into map layers.

Changes

Local RF coverage and Site Planner integration

Layer / File(s) Summary
Coverage module setup and demo
feature/coverage/build.gradle.kts, settings.gradle.kts, gradle/libs.versions.toml, build-logic/convention/..., .github/workflows/reusable-check.yml, feature/coverage/README.md, feature/coverage/src/jvmMain/...
The new multiplatform module is registered in Gradle and root checks. It includes a JVM demo, module documentation, and test and coverage-report tasks.
Coverage prediction and GeoJSON
feature/coverage/src/commonMain/.../LocalCoverage.kt, PolarCoverage.kt, CoverageGrid.kt, CoverageContours.kt, CoveragePalette.kt, feature/coverage/src/commonTest/...
The feature adds P.1812 sweeps, polar-to-grid resampling, reachability measures, coverage bands, and palettes. Common tests cover prediction, geometry, formatting, GeoJSON escaping, and styling.
Terrain tile access and caching
feature/coverage/src/commonMain/.../MapterhornElevation.kt, MapterhornTiles.kt, TerrainCache.kt, feature/coverage/src/.../CoverageTerrainDirectory.*.kt, feature/map-terrain/.../MapterhornEndpoints.kt, core/konsist/...
MapterhornElevation samples terrain through shared decoded-tile caches and optional disk storage. Tile downloads, bounded zoom selection, platform cache directories, and cache tests support terrain access.
Planner validation and estimate flow
feature/coverage/src/commonMain/.../SitePlannerEstimate.kt, feature/map/src/commonMain/.../SitePlannerHost.kt, SitePlannerParams.kt, SitePlannerSheet.kt, core/resources/.../strings.xml, .skills/compose-ui/strings-index.txt, androidApp/src/main/.../SitePlannerRunner.kt, feature/map/src/commonTest/.../SitePlannerParamsTest.kt
The Site Planner validates frequency and range, then submits parameters to the local coverage estimator. The shared host shows estimate progress, handles cancellation or failure, and imports successful GeoJSON. The browser handoff and its related URL and WebView tests were removed.
Platform Site Planner integration
androidApp/build.gradle.kts, androidApp/src/fdroid/.../SitePlannerSlot.kt, androidApp/src/google/.../MapView.kt, desktopApp/build.gradle.kts, desktopApp/src/main/.../DesktopSitePlannerSlot.kt
Android and desktop callers provide the coverage estimator to SitePlannerHost. The desktop caller adds imported GeoJSON as a map layer and moves the map to the supplied coordinates.

Priority: ⬇️ Low

Merge Risk: 🟡 Moderate · up to fa7d8

Coverage output can still be inaccurate for a corrupt terrain cache, while the demo exposes locations and invalid band settings can silently discard coverage.

Security Architecture Review

Security architecture risk: 🔵 Low · up to fa7d8

Local computation removes the hosted planner handoff, and the inspected cancellation and failure paths contain unsuccessful estimates. The main privacy concern is limited to the explicitly invoked developer demo, which prints precise site coordinates. Terrain-cache recovery and downstream rendering remain incompletely established.

Retained concerns

  • Low · security · inferred: The new developer demo prints precise caller-supplied site coordinates to task output. If that output is retained or shared, it exposes those locations outside the generated coverage files. This is an explicitly invoked developer-task exposure; automatic application or workflow invocation was not observed.
Security review details

Security Blast Radius

  • inferred — The inspected production flow affects terrain processing and coverage-layer storage in participating client applications. The concrete diagnostic disclosure is narrower: coordinates explicitly supplied to the developer demo become visible to readers of its task output. No production-coordinate logging through that demo was established.

Security Findings and Attack Paths

  • inferred — The location-disclosure path requires invocation of the new demo with sensitive coordinates followed by access to its output. Coordinate emission is observed, but shared-log retention and access by an unintended reader are conditional, not demonstrated.

Trust Boundaries and Controls

  • observed — Terrain retrieval uses a fixed HTTPS origin rather than a user-supplied URL. Only HTTP 404 returns absent terrain; other unsuccessful statuses fail the estimate. A failed initial decode triggers a fresh fetch, and invalid replacement bytes propagate an error. A replacement 404 can still become absent terrain and therefore zero elevation.
  • observed — Current platform callers use the local generator rather than an externally supplied GeoJSON producer. Free-form site names pass through JSON string escaping, and persisted layer names are sanitized and UUID-suffixed. Import writes the resulting bytes without a schema-validation step at that boundary; complete downstream parsing was not established.

Resilience and Maintainability Implications

  • observed — The estimator lexically owns and closes its terrain source, while the shared HTTP client outlives individual estimates. Cancellation does not become a successful import, and another active cache waiter can replace a cancelled fetch. These controls limit cross-estimate cancellation effects; disk-write atomicity remains outside the demonstrated guarantee.

Hardening Proposals

  • proposed — Omit precise coordinates from default demo output. For the shared terrain cache, consider validated temporary-file writes followed by atomic replacement and coordination of same-tile writes, reducing dependence on network refetch after interruption or concurrent access.
🚥 Pre-merge checks | ✅ 5 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Tests Prove The Path, Not The End State ⚠️ Warning Three added tests do not verify the production path they claim to cover. CoveragePaletteTest.bandsSpanTheWholeRampIncludingItsBrightestEnd builds colors with its own index formula at lines 98–104; i… Make bandsSpanTheWholeRampIncludingItsBrightestEnd build a grid with signal values in each band, call toGeoJson, and assert the actual feature fill values include the palette endpoints and distinct band colors. Change `roundTripsThrou…
Regression Coverage For Changed Behavior ⚠️ Warning The PR adds useful tests for the propagation model, polar resampling, palette helpers, cache cancellation, and zoom fitting. Several new user-visible paths remain untested: 1. `SitePlannerSheet.SiteFo… Add regression tests for the planner form boundaries and submit gating; planner-to-model and display-style mapping; SitePlannerHost cancel, failure, and success state transitions; terrain HTTP and persistent-cache behavior; parsed GeoJSON…
Moved Code Diffed Against Its Original ⚠️ Warning The change widens String.jsonString() from internal to public in feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt. The new feature:coverage module calls it from `… Keep jsonString() internal and add an equivalent coverage-local escaping helper for CoverageContours, or place the shared helper behind an internal boundary that both implementations can use without widening this API.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: computing Site Planner coverage on-device with kp1812.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sibling Call Sites And Presence Semantics ✅ Passed PASS. The reviewed diff changes no NodeItem or telemetry model files and adds no RSSI, temperature, current, voltage, particulate, humidity, or app-level SNR field defaulting to zero. The new `Site.rx…
Full details: Tests Prove The Path, Not The End State

Explanation

Three added tests do not verify the production path they claim to cover. CoveragePaletteTest.bandsSpanTheWholeRampIncludingItsBrightestEnd builds colors with its own index formula at lines 98–104; it never calls CoverageGrid.toGeoJson, where band colors are assigned. It passes if that production color assignment is reverted. ToFixed1Test.roundTripsThroughGeoJson only calls toFixed1() at lines 138–143; it never serializes or inspects GeoJSON, so it passes if toGeoJson stops using the formatter for feature properties. LocalCoverageTest.sweepCoversEveryBearing at lines 102–108 asserts only point-count divisibility and a minimum count. It does not inspect sample coordinates or prove that all eight bearings were covered, so a sweep that repeats one bearing can pass.

Resolution

Make bandsSpanTheWholeRampIncludingItsBrightestEnd build a grid with signal values in each band, call toGeoJson, and assert the actual feature fill values include the palette endpoints and distinct band colors. Change roundTripsThroughGeoJson to call toGeoJson with values that produce known bands, then assert the emitted feature properties contain the correctly formatted dbm values. Change sweepCoversEveryBearing to derive bearings from the returned sample coordinates and assert that all requested bearings are represented, rather than inferring coverage from list size.

Full details: Regression Coverage For Changed Behavior

Explanation

The PR adds useful tests for the propagation model, polar resampling, palette helpers, cache cancellation, and zoom fitting. Several new user-visible paths remain untested: 1. SitePlannerSheet.SiteFormState changes frequency limits to 30–6000 MHz, adds range limits of 1–150 km, and uses both values to gate submission. This affects Compose form state on Android and desktop. Invalid boundary values could still be submitted, or valid limits could be rejected. No replacement test covers these validations; the deleted SitePlannerParamsTest tested URL construction only. Add a form test for inclusive lower and upper bounds, values just outside each bound, and submit-button gating. 2. SitePlannerParams.toSite(), toCoverageStyle(), and wattsToDbm() connect the form to the predictor and map style. Wrong field mapping or power conversion changes the predicted coverage; wrong display mapping changes the rendered layer. No tests reference these adapters. Add common tests that assert representative parameter mappings, 0.1 W to 20 dBm, palette/range/opacity propagation, and rejection of non-positive power. 3. SitePlannerHost now owns submission, estimation, cancellation, failure recovery, and import/dismiss callbacks. This affects Compose UI state across Android and desktop. Cancellation or failure could lose edited parameters, leave the progress dialog active, or import/dismiss at the wrong time. No tests cover the host. Add host tests for cancel returning to the populated form, an estimate exception returning to the form with an error, and success invoking import once before dismiss. 4. MapterhornTiles and MapterhornElevation add HTTP retry/status handling, disk caching, decoding, and elevation lookup. Only the decoded-cache cancellation and zoom guard have tests. A network or server failure could be treated as sea level, or cached terrain could fail to support an offline repeat estimate. Add deterministic transport/store tests proving that 404 returns no tile, other failures propagate, transient failures retry, successful downloads are persisted and reused, and corrupt cached bytes trigger a fresh fetch. 5. CoverageGrid.toGeoJson() creates band assignments and MultiPolygon coordinates for the map layer. CoverageContoursTest checks only escaping of a site name. Incorrect nesting can make MapLibre draw nothing; boundary or NaN handling can create missing or wrongly colored coverage. Add controlled-grid tests that parse the output and verify geometry nesting, exact band thresholds, strongest-band values above maxDbm, non-overlapping assignments, and empty-grid output. 6. DesktopSitePlannerSlot replaces browser export with MapLayersManager.addGeoJsonLayer() and moves the map to the transmitter. This is a new desktop-app integration surface. No test verifies the callback wiring. Add a desktop integration test that verifies one layer is added and the camera moves to the submitted coordinates after success, with no import on cancellation or failure.

Resolution

Add regression tests for the planner form boundaries and submit gating; planner-to-model and display-style mapping; SitePlannerHost cancel, failure, and success state transitions; terrain HTTP and persistent-cache behavior; parsed GeoJSON band geometry and boundary cases; and desktop layer insertion and recentering. Keep the tests deterministic by using controlled form inputs, an injected estimate callback, synthetic coverage grids, and fake HTTP/tile storage.

Full details: Moved Code Diffed Against Its Original

Explanation

The change widens String.jsonString() from internal to public in feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt. The new feature:coverage module calls it from CoverageContours.kt, so this refactor exposes a previously module-internal helper as public API. The method body is unchanged. The reviewed call sites of the removed planner APIs have been migrated; no remaining callers rely on the old URL or browser flow.

  • Fix all pre-merge checks with AI
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added desktop Desktop target enhancement New feature or request build Build system changes labels Sep 19, 2026
@jamesarich
jamesarich force-pushed the feat/kp1812-local-coverage branch 2 times, most recently from 17f4882 to c1b7e26 Compare October 3, 2026 01:25
@jamesarich jamesarich changed the title feat(coverage): compute RF coverage locally with kp1812 (ITU-R P.1812) feat(coverage): compute Site Planner coverage on device with kp1812 Oct 3, 2026
@jamesarich
jamesarich force-pushed the feat/kp1812-local-coverage branch from c1b7e26 to f1d41e0 Compare October 3, 2026 01:48
@github-actions github-actions Bot added the repo Repository maintenance label Oct 3, 2026
@codecov

codecov Bot commented Oct 3, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

@jamesarich
jamesarich marked this pull request as ready for review October 3, 2026 12:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: meshtastic/Meshtastic-Android/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 42f1100b-ae0e-467b-b366-e2006696fbdc
📥 Commits

Reviewing files that changed from the base of the PR and between f942008 and f1d41e0.

📒 Files selected for processing (40)
  • .github/workflows/reusable-check.yml
  • .skills/compose-ui/strings-index.txt
  • androidApp/build.gradle.kts
  • androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt
  • androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
  • androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt
  • androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt
  • build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
  • core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CoroutineScopeConstructionTest.kt
  • core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/ModuleBoundaryTest.kt
  • core/resources/src/commonMain/composeResources/values/strings.xml
  • desktopApp/build.gradle.kts
  • desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt
  • feature/coverage/README.md
  • feature/coverage/build.gradle.kts
  • feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.android.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoveragePalette.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/LocalCoverage.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoveragePaletteTest.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/LocalCoverageTest.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainZoomGuardTest.kt
  • feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.ios.kt
  • feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageDemo.kt
  • feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageTerrainDirectory.jvm.kt
  • feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/MapterhornEndpoints.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt
  • feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
  • gradle/libs.versions.toml
  • settings.gradle.kts
💤 Files with no reviewable changes (4)
  • feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/component/SitePlannerParamsTest.kt
  • androidApp/src/test/kotlin/org/meshtastic/app/map/SitePlannerWebViewRecoveryTest.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerBrowserSheet.kt
  • androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRunner.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

jamesarich and others added 17 commits October 3, 2026 08:11
…View

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.
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.
…mmonMain

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.
… 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.
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.
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.
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.
… 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.
…ncelled 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.
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.
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.
…de 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.
…overage 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.
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
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.
…ed 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.
…form

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.
- 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.
@jamesarich
jamesarich force-pushed the feat/kp1812-local-coverage branch from f1d41e0 to 2e84b57 Compare October 3, 2026 13:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: meshtastic/Meshtastic-Android/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f4137236-2b7d-4256-be4e-4a034d5b2eaf
📥 Commits

Reviewing files that changed from the base of the PR and between f1d41e0 and 2e84b57.

📒 Files selected for processing (9)
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt
  • feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt
  • feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/TerrainCacheTest.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerHost.kt
  • feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/kml/KmlGeoJson.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review.

Without bounds the zoom guard never ran, so a wide range decoded more tiles than the cache holds.
@jamesarich
jamesarich added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 26d4532 Oct 3, 2026
18 checks passed
@jamesarich
jamesarich deleted the feat/kp1812-local-coverage branch October 3, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Build system changes desktop Desktop target enhancement New feature or request repo Repository maintenance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant