diff --git a/docs/band-advisor.md b/docs/band-advisor.md new file mode 100644 index 000000000..4f99041eb --- /dev/null +++ b/docs/band-advisor.md @@ -0,0 +1,266 @@ +# Band Advisor + +Band Advisor answers one question: **"What band should I work right now?"** +It combines VOACAP propagation predictions, live PSK Reporter observations +near the operator, and (optionally) the operator's own PSK Reporter reception +reports into a deterministic, explainable recommendation. There is no LLM or +generative model anywhere in the runtime path — a recommendation is a weighted +sum of named 0..1 inputs, and every score component is returned so the UI can +show exactly why a band won. + +Everything ships behind feature flags and is **off by default in release +builds**. A failure of Band Advisor, VOACAP, PSK Reporter, remote +configuration, or alerts can never interfere with decoding, transmission, +logging, USB audio, or CAT control: every network call is time-boxed, every +failure maps to a typed UI state or a silently skipped background round, and +nothing advisor-related runs at all while its flag is off. + +## Feature flags (`radio.ks3ckc.ft8af.flags`) + +Three flags gate the feature set: + +| Flag | Remote key | Gates | +|---|---|---| +| `BAND_ADVISOR` | `bandAdvisor` | Card, detail sheet, recommendation fetches, settings entry | +| `PERSONAL_PSK_ANALYTICS` | `personalPskAnalytics` | Callsign-specific PSK Reporter analytics (callsign is otherwise never sent) | +| `PROPAGATION_ALERTS` | `propagationAlerts` | Background alert checks, notifications, alert settings | + +Resolution precedence (deterministic, highest first): + +``` +Debug developer override (debug builds only) + ↓ +Cached remote configuration (only while its own expiresAt is in the future) + ↓ +BuildConfig default (debug: true, release: false) +``` + +Implementation map: + +- `FeatureFlag.kt` — flag enum, tri-state `FlagOverride`, `FlagValueSource`. +- `FeatureFlagRepository.kt` — the precedence logic (`DefaultFeatureFlagRepository`), + fully injected (stores, clock, `isDebugBuild`) for tests. +- `RemoteFlagConfig.kt` — versioned payload model + validating parser. Invalid + payloads are rejected whole; unknown flags ignored; missing flags fall back + per-flag to the build default. +- `RemoteFlagConfigFetcher.kt` — fetches `https://ft8af.app/api/feature-config` + (served by the band-advisor service, `GET /v1/feature-config`). Never blocks + startup: `FeatureFlags.init` (Application.onCreate) only wires + SharedPreferences; the refresh is kicked from `ComposeMainActivity` on a + background coroutine and is rate-limited to one attempt per 6 hours. +- `FeatureFlags.kt` — process-wide singleton (the app's no-DI pattern). +- Debug controls: Settings → Advanced → *Developer: Feature flags* + (`ui/settings/FeatureFlagScreen.kt`), gated on `BuildConfig.DEBUG`. Per flag: + Default / Force on / Force off, plus the effective value and its source. + Release builds never consult the override store — defense in depth on top of + the screen simply not existing there. + +> **Rollout controls, not access control.** A modified client can flip these +> flags. Any future paid feature must ALSO be validated server-side; the +> backend must refuse to serve data the account is not entitled to. + +## Client architecture (`radio.ks3ckc.ft8af.bandadvisor`) + +- `model/Models.kt` — framework-free domain models mirroring the versioned + JSON contract (below), plus `Freshness` (FRESH / STALE after `validUntil` / + EXPIRED after +45 min grace). +- `model/RecommendationParser.kt` — `org.json` parser that validates before + anything reaches the UI or the tuning path (score/confidence ∈ [0,1], + plausible frequency, valid grid, validUntil > generatedAt). +- `model/Validation.kt` — 4-char grid normalization (`normalizeAdvisorGrid`) + and structural callsign check (`isPlausibleCallsign`) that gates personal + analytics. +- `scoring/BandScorer.kt` — the client-side reference implementation of the + deterministic scoring model (the production copy runs server-side in + `server/band-advisor/app/scoring.py`; changes must be mirrored). Weights per + goal, renormalization when inputs are missing, and a separate confidence + function (observations, freshness, source availability, VOACAP-vs-observed + agreement, profile completeness). +- `BandAdvisorRepository.kt` — the repository contract. Implementations never + throw; failures are typed (`NO_GRID`, `OFFLINE`, `HTTP_ERROR`, + `INVALID_RESPONSE`, `RATE_LIMITED`). +- `ApiBandAdvisorRepository.kt` — production: `GET /v1/recommendation`, 60 s + client cooldown (force-refresh bypasses it, a 429/503 back-off doesn't), + last-valid-payload cache in SharedPreferences reused as STALE/EXPIRED when + offline. +- `FixtureBandAdvisorRepository.kt` — deterministic fixtures for offline UI + development, built through the real scorer. Selected only when + `BuildConfig.DEBUG` **and** the debug "Use fixture data" toggle are on + (`BandAdvisor.repository`), so fabricated data cannot ship as live data. +- `personal/` — pure computation of personal PSK Reporter analytics + (`computePersonalAnalytics`), small-sample suppression + (`baselineComparison`: needs ≥10 current reports and ≥3 stored sessions), + and the bounded on-device baseline history (`PersonalAnalyticsStore`, + 10 sessions/band, clearable from settings). +- `alerts/` — see below. + +UI (`radio.ks3ckc.ft8af.ui.bandadvisor`): + +- `BandAdvisorCard.kt` — compact card at the top of the Band & Mode sheet + (injected via a null-able slot so the sheet stays decoupled). +- `BandAdvisorSheet.kt` — detail overlay: goal chips (Contact default / DX / + Target / POTA), recommendation + dial + confidence, freshness and + partial-source lines, expandable "Why this band" (evidence + score + components), alternatives with the reason each ranked lower, and the + personal panel. +- `BandAdvisorState.kt` / `PersonalPanelLoader.kt` — state holder + pure + decision helpers. The personal panel reuses the existing + `PskReporterClient` (its 5-minute cooldown and 429 back-off apply). +- `BandAdvisorTuning.kt` — one-tap tune. Reuses `selectBandIndex` (the same + path as manual band picks: RigDialTarget protection, persistence, CAT push + only in CAT/RTS/DTR modes). Refuses frequencies not in the band plan for + the active mode rather than inventing dial entries; without CAT the button + degrades to "Set radio to 14.074 MHz" and only updates app state. It never + transmits and never schedules follow-up tuning — no unattended band hopping. + +## Recommendation contract + +Served by `GET /v1/recommendation?grid=EM28&goal=DX&mode=FT8[&callsign=K1AF][&targetRegion=EUROPE]`: + +```json +{ + "generatedAt": "2026-09-27T12:00:00Z", + "validUntil": "2026-09-27T12:15:00Z", + "grid": "EM28", "callsign": "K1AF", "mode": "FT8", "goal": "DX", + "recommendedBand": "20m", "recommendedFrequencyHz": 14074000, + "score": 0.91, "confidence": 0.87, + "summary": "Best combination of live activity and long-distance reliability.", + "destinations": ["Europe", "South America"], + "evidence": [{"type": "VOACAP", "message": "..."}], + "scoreComponents": [{"name": "voacapReliability", "weight": 0.3, "value": 0.8, "contribution": 0.24}], + "personalAnalytics": {"enabled": true, "reportsReceived": 23, "...": "..."}, + "alternatives": [{"band": "15m", "frequencyHz": 21074000, "score": 0.82, "reason": "..."}], + "sources": {"voacapAvailable": true, "regionalPskReporterAvailable": true, "personalPskReporterAvailable": true} +} +``` + +Notes: `callsign`/`personalAnalytics` are omitted without a callsign param; +`scoreComponents[].weight` are the renormalized weights (sum = 1); upstream +outages produce HTTP 200 with `sources.*Available=false` and lower confidence, +never a 5xx. Score says "how good is this band"; confidence says "how much to +trust that claim" — they are computed separately. + +## Scoring model + +Base weights (`MAKE_CONTACT`): `0.30·voacapReliability + +0.25·nearbyObservedSuccess + 0.15·normalizedLiveActivity + +0.10·distancePotential + 0.10·activityTrend + 0.10·personalStationPerformance`. +DX shifts weight to distance potential and directional diversity; TARGET to +target-specific reliability/observed paths; POTA to dependable regional +coverage. Missing components are dropped and the rest renormalized to sum 1. + +`normalizedLiveActivity` is activity **relative to that band-hour's own +baseline** — never raw spot volume, so 20 m can't win every recommendation +just by being busy. + +## Propagation alerts (`bandadvisor/alerts`) + +Opt-in, behind `PROPAGATION_ALERTS`, checked ~every 30 minutes by a +WorkManager periodic worker (network-constrained; WorkManager was added for +this — the app previously had no periodic-background mechanism). + +- The phone does **no aggregation**: the worker downloads one small + pre-scored document from `GET /v1/conditions?grid=…` and runs the pure + policy in `PropagationAlertPolicy.kt`. +- Evidence bar: ≥10 observations, confidence ≥0.6, activity ≥1.5× the + band-hour baseline, data ≤20 min old, and VOACAP agreement (or an + explicitly-flagged unusual observed opening). +- Alert identity = `grid|band|targetRegion|90-min-bucket`; delivered + identities persist for 48 h (`AlertHistoryStore`) so one opening never + notifies twice. Cooldowns: ≥3 h between alerts for the same band, ≥30 min + between any two alerts, at most one notification per check. +- Quiet hours (local, midnight-wrap aware), per-type opt-ins (general + opening / watched band / watched region / personal improvement / unusual + opening), watched band + region pickers — all in Settings → Band Advisor. +- `PropagationAlertScheduler.sync()` reconciles scheduled work on app start + and after every settings/flag change: disabling the flag or the preference + **cancels** pending work. The worker also re-checks the gates itself. +- Notifications use their own `propagation_alerts` channel; POST_NOTIFICATIONS + is requested when the master toggle turns on (Android 13+) and re-checked + before posting. Tapping opens the Band Advisor detail sheet. + +## Privacy + +- Recommendations send only the **4-character** grid square (~100×200 km) — + 6-char grids are truncated client-side (`normalizeAdvisorGrid`). GPS + coordinates never leave the device and are never logged. +- The callsign is sent only while `PERSONAL_PSK_ANALYTICS` is on **and** the + configured callsign passes structural validation; otherwise requests are + anonymous. +- Personal analytics are reception reports (who *heard* you) — the UI says so + explicitly and never implies completed contacts. Baseline comparisons are + suppressed below the sample-size floor instead of shown as noise. +- Settings → Band Advisor → "Clear cached analytics" wipes the local + recommendation cache and the personal baseline history. +- Server-side retention is documented in `server/band-advisor/README.md` + (aggregates only; per-callsign cache ≤1 h; no long-term per-user storage). +- Local telemetry (`BandAdvisorTelemetry`) logs coarse event names to the + on-device debug.log only; there is no remote analytics backend. + +## Backend (`server/band-advisor/`) + +A containerized FastAPI service (Python 3.11+) that runs +[voacapl](https://github.com/jawatson/voacapl) (invoked as an external GPL +tool — not linked or vendored) and centralizes PSK Reporter access so phones +never poll `retrieve.pskreporter.info` directly. Endpoints: `/healthz`, +`/v1/feature-config`, `/v1/recommendation`, `/v1/conditions`. See its README +for deck construction, the cache keying +(grid4 × month × UTC-hour × power × antenna × noise × region), PSK Reporter +etiquette (5-min per-parameter-set interval, gzip, `appcontact`, defusedxml), +deployment (Dockerfile builds voacapl from source), and the honest +not-yet-implemented list. + +**FT8 SNR profile:** VOACAP's `REQ.SNR` is dB·Hz (1 Hz reference bandwidth). +The commonly quoted FT8 threshold of −21 dB is referenced to 2500 Hz, so the +conversion is −21 + 10·log₁₀(2500) ≈ **13 dB·Hz**, plus a configurable 3 dB +real-world margin (fading, QRM, imperfect audio chains) → default REQ.SNR 16. +Do not plan against the deeper AP/subtraction decode limits. + +## Testing + +Android unit tests (JUnit4 + Truth; Robolectric where Android types are +involved; MockWebServer for HTTP; injected clocks everywhere time matters — +no real network calls): + +- `flags/` — precedence, build defaults, debug overrides, release ignoring + overrides, remote-config parse/validation/expiry, fetcher rate-limiting, + store round-trips. +- `bandadvisor/` — contract parsing (including the full example payload), + score normalization, goal weights, confidence, freshness transitions, + repository cache/cooldown/back-off/offline fallback, fixture determinism, + grid/callsign validation, personal analytics math + small-sample + suppression, baseline store bounds. +- `ui/bandadvisor`, `ui/settings` — UI decision helpers, tune-index lookup + (never invents dials), panel mapping, flag-screen lines. +- `bandadvisor/alerts/` — thresholds, quiet hours (midnight wrap), identity + bucketing, dedup, per-band/global cooldowns, history pruning, prefs + round-trip, conditions parsing, and WorkManager schedule/cancel via + `work-testing`. + +Server: `cd server/band-advisor && python3 -m venv .venv && .venv/bin/pip +install -e '.[dev]' && .venv/bin/pytest` (190 tests, no network). + +## Rollout + +1. Ship with release defaults off (already the case). +2. Deploy the band-advisor service; point a staging build at it (debug builds + have the flags on by default). +3. Enable `bandAdvisor` for a cohort via `/v1/feature-config` (the app caches + the payload and honors its `expiresAt`). +4. `personalPskAnalytics` next, `propagationAlerts` last (it creates + background work and notifications). +5. Rollback = flip the remote flag off; clients revert on their next config + refresh, and the alert scheduler cancels its work on next app start / sync. + +### Integration safeguards + +Recommendation and conditions handlers run synchronous upstream work in the +FastAPI thread pool, keeping health checks responsive. Service locks protect +shared PSK rate-limit/baseline state and VOACAP cache population. Regional +observers share the raw PSK response for the upstream minimum interval, then +aggregate their own nearby statistics; each raw window trains the baseline once. + +The Target goal includes a persisted concrete-region selector (Europe by +default). Recommendations echo `targetRegion`, which participates in client +cache matching. Background alert workers read the saved grid from `data.db` +without needing an Activity or FT8 engine to initialize process globals. diff --git a/ft8af/app/build.gradle b/ft8af/app/build.gradle index f1895bfd2..842fa88bd 100644 --- a/ft8af/app/build.gradle +++ b/ft8af/app/build.gradle @@ -109,6 +109,15 @@ android { debug{ versionNameSuffix '-dev' buildConfigField("String", "apkBuildTime", "\"${currentTime}\"") + // Band Advisor rollout flags (radio.ks3ckc.ft8af.flags): build-time + // defaults, overridable per-developer from the debug flag screen and + // by the cached remote feature configuration. Debug ships enabled so + // the feature is exercised during development; release ships disabled + // until intentionally rolled out. These are rollout controls, not + // access control — see FeatureFlag.kt. + buildConfigField "boolean", "FEATURE_BAND_ADVISOR_DEFAULT", "true" + buildConfigField "boolean", "FEATURE_PERSONAL_PSK_ANALYTICS_DEFAULT", "true" + buildConfigField "boolean", "FEATURE_PROPAGATION_ALERTS_DEFAULT", "true" } release { if (project.hasProperty('RELEASE_STORE_FILE')) { @@ -123,6 +132,11 @@ android { shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' buildConfigField("String", "apkBuildTime", "\"${currentTime}\"") + // Off in release until each feature is intentionally rolled out via + // the remote feature configuration (see FeatureFlag.kt). + buildConfigField "boolean", "FEATURE_BAND_ADVISOR_DEFAULT", "false" + buildConfigField "boolean", "FEATURE_PERSONAL_PSK_ANALYTICS_DEFAULT", "false" + buildConfigField "boolean", "FEATURE_PROPAGATION_ALERTS_DEFAULT", "false" } } @@ -264,6 +278,12 @@ dependencies { // process death + app updates). implementation 'androidx.datastore:datastore-preferences:1.1.7' + // WorkManager schedules the opt-in Band Advisor propagation-alert checks + // (periodic, network-constrained, cancellable when the flag/preference is + // off). The app had no periodic-background mechanism before this; nothing + // else may piggyback on it without the same opt-in rigor. + implementation 'androidx.work:work-runtime-ktx:2.9.1' + // --- JVM unit + Robolectric tests --- testImplementation 'junit:junit:4.13.2' // 4.14.1 ships android-all for SDK 35; older versions fail with @@ -273,6 +293,8 @@ dependencies { testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation 'com.google.truth:truth:1.4.2' testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + // Synchronous WorkManager harness for the propagation-alert scheduler tests. + testImplementation 'androidx.work:work-testing:2.9.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'androidx.compose.ui:ui-test-junit4' diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt index b5f0722b1..c098bc1b3 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ComposeMainActivity.kt @@ -37,6 +37,9 @@ import com.k1af.ft8af.GeneralVariables import com.k1af.ft8af.MainViewModel import com.k1af.ft8af.R import radio.ks3ckc.ft8af.crash.CrashReporting +import radio.ks3ckc.ft8af.bandadvisor.alerts.PropagationAlertNotifier +import radio.ks3ckc.ft8af.bandadvisor.alerts.PropagationAlertScheduler +import radio.ks3ckc.ft8af.flags.FeatureFlags import radio.ks3ckc.ft8af.sync.QsoAutoSync import radio.ks3ckc.ft8af.util.bluetoothAdapter import com.k1af.ft8af.service.RxForegroundService @@ -194,6 +197,19 @@ class ComposeMainActivity : AppCompatActivity() { syncNow("app-start") } + // Opportunistic remote feature-config refresh (fire-and-forget on IO, + // internally rate-limited). Lives here rather than Application.onCreate + // so process start — including Robolectric test runs — never attempts + // network. A failure changes nothing: flags keep their cached/default + // values. + FeatureFlags.refreshRemoteConfigAsync() + + // Reconcile the propagation-alert background work with the current + // flag + preference state: schedules when both are on, and — the + // important direction — cancels leftover work when either turned off + // since the last run (e.g. a remote-config rollback). + PropagationAlertScheduler.sync(this) + // Set Compose UI — splash plays once per cold start, then crossfades into the app. setContent { FT8AFTheme { @@ -264,6 +280,11 @@ class ComposeMainActivity : AppCompatActivity() { * so the Decode screen can switch to itself and scroll to + highlight that station. */ private fun handleAlertIntent(intent: Intent?) { + // A tapped propagation alert asks for the Band Advisor detail sheet. + if (intent?.getBooleanExtra(PropagationAlertNotifier.EXTRA_OPEN_ADVISOR, false) == true) { + fileLog("handleAlertIntent: open band advisor") + PropagationAlertNotifier.openAdvisorRequest.postValue(System.currentTimeMillis()) + } val callsign = intent?.getStringExtra( com.k1af.ft8af.alert.DxAlertNotifier.EXTRA_CALLSIGN, ) ?: return diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index cb0f8f03d..d0402e469 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -72,6 +72,13 @@ import radio.ks3ckc.ft8af.ui.components.OperateStatusRow import radio.ks3ckc.ft8af.ui.components.OperateDrawerPeekHeight import radio.ks3ckc.ft8af.ui.components.VoiceCommandButton import radio.ks3ckc.ft8af.ui.components.selectBandIndex +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorCard +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorSheet +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorStateHolder +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorTelemetry +import radio.ks3ckc.ft8af.ui.bandadvisor.tuneToAdvisorFrequency import radio.ks3ckc.ft8af.ui.decode.DecodeScreen import radio.ks3ckc.ft8af.ui.logbook.LogbookScreen import radio.ks3ckc.ft8af.ui.map.MapScreen @@ -250,6 +257,33 @@ fun FT8AFApp(mainViewModel: MainViewModel) { java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) in 8..17 } + // Band Advisor (feature-flagged): compact card inside the Band & Mode + // sheet, detail overlay below. The flag is evaluated per composition of + // the sheet, so a debug-screen flip takes effect on next open. When off, + // no advisor UI exists and no recommendation/personal requests are made. + val bandAdvisorScope = androidx.compose.runtime.rememberCoroutineScope() + val bandAdvisor = remember { BandAdvisorStateHolder(context.applicationContext) } + var showBandAdvisor by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(showBandModeSheet, showBandAdvisor) { + if (showBandModeSheet || showBandAdvisor) { + bandAdvisor.load(bandAdvisorScope, force = false) + } + if (showBandAdvisor) { + BandAdvisorTelemetry.event("opened", null) + bandAdvisor.loadPersonal(bandAdvisorScope) + } + } + // A tapped propagation-alert notification opens the advisor detail sheet + // (ComposeMainActivity posts the request from the notification intent). + val advisorOpenRequest by radio.ks3ckc.ft8af.bandadvisor.alerts.PropagationAlertNotifier + .openAdvisorRequest.observeAsState() + LaunchedEffect(advisorOpenRequest) { + if (advisorOpenRequest != null && FeatureFlags.isEnabled(FeatureFlag.BAND_ADVISOR)) { + BandAdvisorTelemetry.event("alert_opened", null) + showBandAdvisor = true + } + } + // A tapped Needed-DX notification asks us to jump to the Decode tab (DecodeScreen // then scrolls to + highlights the alerted station). val preselectCallsign by mainViewModel.mutablePreselectCallsign.observeAsState() @@ -751,8 +785,51 @@ fun FT8AFApp(mainViewModel: MainViewModel) { showBandModeSheet = false showFrequencyPicker = true }, + advisorCard = if (FeatureFlags.isEnabled(FeatureFlag.BAND_ADVISOR)) { + { + BandAdvisorCard( + state = bandAdvisor.state, + catAvailable = catState == CatConnectionState.CONNECTED, + onOpenDetails = { + showBandModeSheet = false + showBandAdvisor = true + }, + onTune = { freqHz -> + if (tuneToAdvisorFrequency(mainViewModel, context, freqHz)) { + showBandModeSheet = false + } + }, + ) + } + } else { + null + }, ) + // Band Advisor detail — recommendation, goal, evidence, alternatives, + // and the personal PSK Reporter panel. Sibling overlay like the other + // sheets. Never composed while the flag is off. + if (FeatureFlags.isEnabled(FeatureFlag.BAND_ADVISOR)) { + BandAdvisorSheet( + visible = showBandAdvisor, + state = bandAdvisor.state, + goal = bandAdvisor.goal, + targetRegion = bandAdvisor.targetRegion, + onSelectTargetRegion = { bandAdvisor.setTargetRegion(bandAdvisorScope, it) }, + catAvailable = catState == CatConnectionState.CONNECTED, + nowMs = System.currentTimeMillis(), + personalPanel = bandAdvisor.personalPanel, + onDismiss = { showBandAdvisor = false }, + onSelectGoal = { goal -> bandAdvisor.setGoal(bandAdvisorScope, goal) }, + onRefresh = { bandAdvisor.load(bandAdvisorScope, force = true) }, + onTune = { freqHz -> + if (tuneToAdvisorFrequency(mainViewModel, context, freqHz)) { + showBandAdvisor = false + } + }, + ) + } + // DXpedition Hound setup — collects the Fox call + call frequency, then // starts calling (disabling Hunt, which is mutually exclusive). HoundSetupSheet( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApplication.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApplication.kt index c980a9b0a..42db4b0e8 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApplication.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApplication.kt @@ -2,6 +2,7 @@ package radio.ks3ckc.ft8af import android.app.Application import radio.ks3ckc.ft8af.crash.CrashReporting +import radio.ks3ckc.ft8af.flags.FeatureFlags /** * Process-wide entry point. Exists so opt-in Sentry crash reporting can be @@ -15,5 +16,9 @@ class FT8AFApplication : Application() { override fun onCreate() { super.onCreate() CrashReporting.init(this) + // Synchronous wiring only (SharedPreferences reads); the remote + // feature-config refresh it kicks off runs on a background coroutine + // and can never block or fail startup. + FeatureFlags.init(this) } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepository.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepository.kt new file mode 100644 index 000000000..721c83763 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepository.kt @@ -0,0 +1,208 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8af.bandadvisor.model.BandRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.freshnessOf +import radio.ks3ckc.ft8af.bandadvisor.model.isPlausibleCallsign +import radio.ks3ckc.ft8af.bandadvisor.model.normalizeAdvisorGrid +import radio.ks3ckc.ft8af.bandadvisor.model.parseBandRecommendation +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** Persistence for the last valid recommendation JSON (offline/stale reuse). */ +interface RecommendationCacheStore { + fun load(): String? + fun save(json: String) + fun clear() +} + +/** In-memory store for tests and as a default when persistence is unwanted. */ +class InMemoryRecommendationCacheStore : RecommendationCacheStore { + private var json: String? = null + override fun load(): String? = json + override fun save(json: String) { + this.json = json + } + override fun clear() { + json = null + } +} + +/** + * Production repository: fetches recommendations from the band-advisor + * service and caches the last valid payload for stale/offline reuse. + * + * Network discipline: a fresh cached recommendation for the same request is + * served without any request; otherwise attempts are spaced at least + * [MIN_FETCH_INTERVAL_MS] apart unless the user forces a refresh, and a 429/503 + * answer imposes a [RATE_LIMIT_BACKOFF_MS] back-off that even forced refreshes + * honor. Every failure path degrades to the cache (marked STALE/EXPIRED) or a + * typed [AdvisorResult.Unavailable] — never an exception. + */ +class ApiBandAdvisorRepository( + private val cacheStore: RecommendationCacheStore, + private val clock: () -> Long = { System.currentTimeMillis() }, + private val baseUrl: String = DEFAULT_BASE_URL, +) : BandAdvisorRepository { + + @Volatile + private var lastAttemptMs = 0L + + @Volatile + private var rateLimitedUntilMs = 0L + + override suspend fun recommendation( + request: AdvisorRequest, + force: Boolean, + ): AdvisorResult = withContext(Dispatchers.IO) { + val grid = normalizeAdvisorGrid(request.grid) + ?: return@withContext AdvisorResult.Unavailable(UnavailableReason.NO_GRID) + val now = clock() + + // A still-fresh cached answer to the same question needs no network. + val cachedRec = cachedRecommendation() + if (cachedRec != null && !force && + freshnessOf(cachedRec, now) == Freshness.FRESH && + recommendationMatchesRequest(cachedRec, grid, request) + ) { + return@withContext AdvisorResult.Available(cachedRec, Freshness.FRESH, fromCache = true) + } + + if (now < rateLimitedUntilMs) { + return@withContext cachedOrUnavailable( + now, + grid, + request, + UnavailableReason.RATE_LIMITED, + "server asked us to back off", + ) + } + if (!force && lastAttemptMs != 0L && now - lastAttemptMs < MIN_FETCH_INTERVAL_MS) { + return@withContext cachedOrUnavailable(now, grid, request, UnavailableReason.RATE_LIMITED, "cooldown") + } + lastAttemptMs = now + + val url = buildUrl(grid, request) + when (val outcome = fetch(url)) { + is FetchOutcome.Body -> { + val parsed = parseBandRecommendation(outcome.body) + if (parsed == null) { + Log.w(TAG, "invalid recommendation payload") + cachedOrUnavailable(now, grid, request, UnavailableReason.INVALID_RESPONSE, null) + } else { + cacheStore.save(outcome.body) + AdvisorResult.Available(parsed, freshnessOf(parsed, now), fromCache = false) + } + } + is FetchOutcome.HttpError -> { + if (outcome.code == 429 || outcome.code == 503) { + rateLimitedUntilMs = clock() + RATE_LIMIT_BACKOFF_MS + } + cachedOrUnavailable(now, grid, request, UnavailableReason.HTTP_ERROR, "http ${outcome.code}") + } + is FetchOutcome.Transport -> + cachedOrUnavailable(now, grid, request, UnavailableReason.OFFLINE, outcome.message) + } + } + + override fun cached(): AdvisorResult.Available? { + val rec = cachedRecommendation() ?: return null + return AdvisorResult.Available(rec, freshnessOf(rec, clock()), fromCache = true) + } + + override fun clearCache() { + cacheStore.clear() + } + + private fun cachedRecommendation(): BandRecommendation? = + cacheStore.load()?.let { parseBandRecommendation(it) } + + /** Cache hit (marked with its real freshness) beats a typed failure. */ + private fun cachedOrUnavailable( + nowMs: Long, + grid: String, + request: AdvisorRequest, + reason: UnavailableReason, + detail: String?, + ): AdvisorResult { + val rec = cachedRecommendation()?.takeIf { recommendationMatchesRequest(it, grid, request) } + ?: return AdvisorResult.Unavailable(reason, detail) + return AdvisorResult.Available(rec, freshnessOf(rec, nowMs), fromCache = true) + } + + private fun wantsPersonal(request: AdvisorRequest): Boolean = + isPlausibleCallsign(request.callsign) + + private fun buildUrl(grid: String, request: AdvisorRequest): String { + val sb = StringBuilder(baseUrl) + .append("/v1/recommendation?grid=").append(encode(grid)) + .append("&goal=").append(encode(request.goal.wireName)) + .append("&mode=").append(encode(request.mode)) + if (wantsPersonal(request)) { + sb.append("&callsign=").append(encode(request.callsign!!.trim().uppercase())) + } + request.targetRegion?.takeIf { it.isNotBlank() }?.let { + sb.append("&targetRegion=").append(encode(it)) + } + return sb.toString() + } + + private sealed interface FetchOutcome { + data class Body(val body: String) : FetchOutcome + data class HttpError(val code: Int) : FetchOutcome + data class Transport(val message: String?) : FetchOutcome + } + + private fun fetch(url: String): FetchOutcome { + var conn: HttpURLConnection? = null + return try { + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = IO_TIMEOUT_MS + readTimeout = IO_TIMEOUT_MS + setRequestProperty("Accept", "application/json") + } + val code = conn.responseCode + if (code != HttpURLConnection.HTTP_OK) { + FetchOutcome.HttpError(code) + } else { + FetchOutcome.Body( + conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }, + ) + } + } catch (e: IOException) { + FetchOutcome.Transport(e.message) + } finally { + conn?.disconnect() + } + } + + companion object { + private const val TAG = "BandAdvisorApi" + const val DEFAULT_BASE_URL = "https://ft8af.app/api/band-advisor" + const val MIN_FETCH_INTERVAL_MS = 60_000L + const val RATE_LIMIT_BACKOFF_MS = 15L * 60_000 + private const val IO_TIMEOUT_MS = 10_000 + + private fun encode(s: String): String = + URLEncoder.encode(s, StandardCharsets.UTF_8.name()) + } +} + +/** An answer to a different station, goal or target cannot satisfy this request. */ +internal fun recommendationMatchesRequest( + rec: BandRecommendation, + grid: String, + request: AdvisorRequest, +): Boolean = + rec.grid.equals(grid, ignoreCase = true) && + rec.goal == request.goal && + rec.mode.equals(request.mode, ignoreCase = true) && + rec.callsign == request.callsign?.takeIf { isPlausibleCallsign(it) }?.trim()?.uppercase() && + rec.targetRegion == request.targetRegion diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisor.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisor.kt new file mode 100644 index 000000000..f007c0363 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisor.kt @@ -0,0 +1,91 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import android.content.Context +import android.content.SharedPreferences +import com.k1af.ft8af.BuildConfig +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal + +/** + * Manual-singleton wiring for Band Advisor (matches the app's no-DI pattern): + * owns the repository choice, the persisted goal, and the local caches. + */ +object BandAdvisor { + private const val PREFS_NAME = "ft8af_band_advisor" + private const val KEY_GOAL = "goal" + private const val KEY_USE_FIXTURES = "use_fixtures" + private const val KEY_CACHED_RECOMMENDATION = "cached_recommendation_json" + + @Volatile + private var repository: BandAdvisorRepository? = null + + private fun prefs(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** + * The active repository. Fixture data is a DEBUG-ONLY development aid: + * the toggle is invisible and ignored in release builds, so fabricated + * recommendations can never be presented as live data to users. + */ + fun repository(context: Context): BandAdvisorRepository { + repository?.let { return it } + synchronized(this) { + repository?.let { return it } + val repo = if (BuildConfig.DEBUG && useFixtures(context)) { + FixtureBandAdvisorRepository() + } else { + ApiBandAdvisorRepository(SharedPrefsRecommendationCacheStore(prefs(context))) + } + repository = repo + return repo + } + } + + /** Debug toggle: serve deterministic fixture data instead of the API. */ + fun useFixtures(context: Context): Boolean = + BuildConfig.DEBUG && prefs(context).getBoolean(KEY_USE_FIXTURES, false) + + fun setUseFixtures(context: Context, value: Boolean) { + if (!BuildConfig.DEBUG) return + prefs(context).edit().putBoolean(KEY_USE_FIXTURES, value).apply() + synchronized(this) { repository = null } // Rebuild with the new source. + } + + /** The operator's selected goal, persisted across sessions. */ + fun goal(context: Context): OperatingGoal = + OperatingGoal.fromWireName(prefs(context).getString(KEY_GOAL, null)) + + fun setGoal(context: Context, goal: OperatingGoal) { + prefs(context).edit().putString(KEY_GOAL, goal.wireName).apply() + } + + fun targetRegion(context: Context): AdvisorTargetRegion = + AdvisorTargetRegion.fromWireName(prefs(context).getString("target_region", null)) + + fun setTargetRegion( + context: Context, + region: AdvisorTargetRegion, + ) { + prefs(context).edit().putString("target_region", region.name).apply() + } + + /** Clear locally cached recommendation + personal analytics (privacy). */ + fun clearLocalData(context: Context) { + repository(context).clearCache() + prefs(context).edit().remove(KEY_CACHED_RECOMMENDATION).apply() + radio.ks3ckc.ft8af.bandadvisor.personal.PersonalAnalyticsStore.clear(context) + } + + /** Prefs-backed cache so the last recommendation survives restarts. */ + private class SharedPrefsRecommendationCacheStore( + private val prefs: SharedPreferences, + ) : RecommendationCacheStore { + override fun load(): String? = prefs.getString(KEY_CACHED_RECOMMENDATION, null) + override fun save(json: String) { + prefs.edit().putString(KEY_CACHED_RECOMMENDATION, json).apply() + } + override fun clear() { + prefs.edit().remove(KEY_CACHED_RECOMMENDATION).apply() + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisorRepository.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisorRepository.kt new file mode 100644 index 000000000..6977cf3a9 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/BandAdvisorRepository.kt @@ -0,0 +1,77 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import radio.ks3ckc.ft8af.bandadvisor.model.BandRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal + +/** Parameters for a recommendation request. */ +data class AdvisorRequest( + /** Raw grid as configured (any precision); normalized to 4 chars on send. */ + val grid: String?, + /** Only sent when personal analytics are enabled AND the callsign is valid. */ + val callsign: String?, + val goal: OperatingGoal = OperatingGoal.MAKE_CONTACT, + val mode: String = "FT8", + /** Optional region for the TARGET goal (server region enum name). */ + val targetRegion: String? = null, +) + +/** Why no recommendation could be produced. */ +enum class UnavailableReason { + /** No valid Maidenhead grid configured — the advisor can't run at all. */ + NO_GRID, + + /** Transport failure (offline, DNS, timeout). */ + OFFLINE, + + /** Server answered with a non-200. */ + HTTP_ERROR, + + /** Server answered 200 but the payload failed validation. */ + INVALID_RESPONSE, + + /** Client-side cooldown suppressed the request and no cache exists. */ + RATE_LIMITED, +} + +/** Outcome of a recommendation request. */ +sealed interface AdvisorResult { + /** + * A recommendation is available. [freshness] tells the UI whether to show + * it plainly (FRESH), with a stale marker (STALE), or as a last-known + * value with a strong warning (EXPIRED). Partial data shows up inside + * [BandRecommendation.sources], not as a separate result type. + */ + data class Available( + val recommendation: BandRecommendation, + val freshness: Freshness, + val fromCache: Boolean, + ) : AdvisorResult + + data class Unavailable( + val reason: UnavailableReason, + val detail: String? = null, + ) : AdvisorResult +} + +/** + * Source of Band Advisor recommendations. Implementations must never throw + * from [recommendation] — every failure mode maps to [AdvisorResult.Unavailable] + * (or a cached [AdvisorResult.Available]) so a backend outage can never + * disturb FT8 operation. + */ +interface BandAdvisorRepository { + /** + * Current recommendation for [request]. [force] bypasses the client-side + * refresh cooldown (manual refresh button) but never a server rate-limit + * back-off. Serves the cached recommendation without touching the network + * while it is still fresh for the same request. + */ + suspend fun recommendation(request: AdvisorRequest, force: Boolean = false): AdvisorResult + + /** Last known recommendation (any freshness) without any network. */ + fun cached(): AdvisorResult.Available? + + /** Drop any locally cached recommendation/analytics (privacy control). */ + fun clearCache() +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepository.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepository.kt new file mode 100644 index 000000000..1dcc895c7 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepository.kt @@ -0,0 +1,216 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import radio.ks3ckc.ft8af.bandadvisor.model.BandAlternative +import radio.ks3ckc.ft8af.bandadvisor.model.BandRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.Evidence +import radio.ks3ckc.ft8af.bandadvisor.model.EvidenceType +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.bandadvisor.model.PersonalAnalyticsSummary +import radio.ks3ckc.ft8af.bandadvisor.model.SourceAvailability +import radio.ks3ckc.ft8af.bandadvisor.model.freshnessOf +import radio.ks3ckc.ft8af.bandadvisor.model.isPlausibleCallsign +import radio.ks3ckc.ft8af.bandadvisor.model.normalizeAdvisorGrid +import radio.ks3ckc.ft8af.bandadvisor.scoring.BandConditions +import radio.ks3ckc.ft8af.bandadvisor.scoring.ConfidenceInputs +import radio.ks3ckc.ft8af.bandadvisor.scoring.confidenceFor +import radio.ks3ckc.ft8af.bandadvisor.scoring.rankBands +import java.util.Calendar +import java.util.TimeZone + +/** + * Deterministic fixture-backed repository for offline UI development, tests, + * and the debug "use fixture data" toggle. Conditions vary only with the UTC + * hour (so the demo feels alive across a day) and the requested goal — same + * inputs, same output, always. + * + * NEVER wired up in release builds: [BandAdvisor.repository] only selects this + * class when BuildConfig.DEBUG is true and the developer toggle is on, so + * fabricated recommendations cannot ship as live data. + */ +class FixtureBandAdvisorRepository( + private val clock: () -> Long = { System.currentTimeMillis() }, +) : BandAdvisorRepository { + + override suspend fun recommendation(request: AdvisorRequest, force: Boolean): AdvisorResult { + val grid = normalizeAdvisorGrid(request.grid) + ?: return AdvisorResult.Unavailable(UnavailableReason.NO_GRID) + val rec = buildFixtureRecommendation(grid, request, clock()) + return AdvisorResult.Available(rec, freshnessOf(rec, clock()), fromCache = false) + } + + override fun cached(): AdvisorResult.Available? = null + + override fun clearCache() = Unit +} + +/** + * Build the fixture recommendation through the real scoring engine, so the + * fixture path exercises exactly the code the tests pin down. + */ +internal fun buildFixtureRecommendation( + grid: String, + request: AdvisorRequest, + nowMs: Long, +): BandRecommendation { + val utcHour = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + .apply { timeInMillis = nowMs } + .get(Calendar.HOUR_OF_DAY) + val daytime = utcHour in 13..22 // Rough mid-US afternoon in UTC. + val personal = isPlausibleCallsign(request.callsign) + + // Handcrafted-but-plausible conditions: high bands live by day, low by night. + fun cond( + band: String, + freqHz: Long, + rel: Double, + nearby: Double, + activity: Double, + distance: Double, + trend: Double, + diversity: Double, + obs: Int, + destinations: List, + ) = BandConditions( + band = band, + frequencyHz = freqHz, + voacapReliability = rel, + nearbyObservedSuccess = nearby, + normalizedLiveActivity = activity, + distancePotential = distance, + activityTrend = trend, + personalPerformance = if (personal) 0.68 else null, + directionalDiversity = diversity, + targetReliability = rel * 0.9, + targetObservedPaths = nearby * 0.8, + observationCount = obs, + destinations = destinations, + ) + + val bands = if (daytime) { + listOf( + cond("40m", 7_074_000, 0.55, 0.50, 0.55, 0.35, 0.45, 0.40, 42, listOf("North America")), + cond("20m", 14_074_000, 0.86, 0.82, 0.74, 0.70, 0.60, 0.80, 63, listOf("Europe", "South America")), + cond("15m", 21_074_000, 0.72, 0.61, 0.58, 0.85, 0.68, 0.65, 24, listOf("South America", "Africa")), + cond("10m", 28_074_000, 0.44, 0.30, 0.35, 0.90, 0.72, 0.35, 8, listOf("South America")), + ) + } else { + listOf( + cond("80m", 3_573_000, 0.68, 0.60, 0.62, 0.25, 0.50, 0.35, 30, listOf("North America")), + cond("40m", 7_074_000, 0.84, 0.78, 0.72, 0.55, 0.58, 0.70, 55, listOf("Europe", "North America")), + cond("30m", 10_136_000, 0.74, 0.62, 0.55, 0.60, 0.52, 0.60, 26, listOf("Europe", "Asia")), + cond("20m", 14_074_000, 0.52, 0.40, 0.38, 0.65, 0.35, 0.45, 12, listOf("Oceania")), + ) + } + + val ranked = rankBands(bands, request.goal) + val winner = ranked.first() + val alternatives = ranked.drop(1).take(2).map { alt -> + BandAlternative( + band = alt.conditions.band, + frequencyHz = alt.conditions.frequencyHz, + score = alt.score, + reason = alternativeReason(winner, alt), + ) + } + val confidence = confidenceFor( + ConfidenceInputs( + observationCount = winner.conditions.observationCount, + dataAgeMs = 3 * 60_000, + sourceAvailability = if (personal) 1.0 else 2.0 / 3.0, + modelAgreement = winner.conditions.voacapReliability?.let { rel -> + winner.conditions.nearbyObservedSuccess?.let { obs -> 1.0 - kotlin.math.abs(rel - obs) } + }, + personalSampleSize = if (personal) 23 else 0, + stationProfileCompleteness = 0.7, + ), + ) + + val evidence = buildList { + add( + Evidence( + EvidenceType.VOACAP, + "Predicted reliability into ${winner.conditions.destinations.joinToString(" and ")} " + + "is ${(100 * (winner.conditions.voacapReliability ?: 0.0)).toInt()}% this hour.", + ), + ) + add( + Evidence( + EvidenceType.REGIONAL_PSK_REPORTER, + "Stations near $grid completed ${winner.conditions.observationCount} reception paths " + + "on ${winner.conditions.band} in the last 15 minutes.", + ), + ) + if (personal) { + add( + Evidence( + EvidenceType.PERSONAL_PSK_REPORTER, + "Your recent ${winner.conditions.band} reports reach farther than your usual range.", + ), + ) + } + } + + return BandRecommendation( + targetRegion = request.targetRegion, + generatedAtMs = nowMs, + validUntilMs = nowMs + 15 * 60_000, + grid = grid, + callsign = request.callsign?.trim()?.uppercase().takeIf { personal }, + mode = request.mode, + goal = request.goal, + recommendedBand = winner.conditions.band, + recommendedFrequencyHz = winner.conditions.frequencyHz, + score = winner.score, + confidence = confidence, + summary = summaryFor(request.goal, winner.conditions.band), + destinations = winner.conditions.destinations, + evidence = evidence, + scoreComponents = winner.components, + personalAnalytics = if (personal) { + PersonalAnalyticsSummary( + enabled = true, + reportsReceived = 23, + uniqueReceivers = 18, + countriesReached = 7, + maximumDistanceKm = 8420, + medianSnrDb = -11, + bestSnrDb = -3, + performanceComparedToBaseline = 0.18, + ) + } else { + null + }, + alternatives = alternatives, + sources = SourceAvailability( + voacapAvailable = true, + regionalPskReporterAvailable = true, + personalPskReporterAvailable = personal, + ), + ) +} + +private fun summaryFor(goal: OperatingGoal, band: String): String = when (goal) { + OperatingGoal.MAKE_CONTACT -> "$band has the best mix of activity and reliable paths right now." + OperatingGoal.DX -> "$band offers the strongest long-distance paths right now." + OperatingGoal.TARGET -> "$band is your best route to the selected region right now." + OperatingGoal.POTA -> "$band gives the most dependable coverage for an activation." +} + +private fun alternativeReason( + winner: radio.ks3ckc.ft8af.bandadvisor.scoring.ScoredBand, + alt: radio.ks3ckc.ft8af.bandadvisor.scoring.ScoredBand, +): String { + val w = winner.conditions + val a = alt.conditions + return when { + (a.distancePotential ?: 0.0) > (w.distancePotential ?: 0.0) -> + "Greater maximum distance, but fewer observed paths." + (a.nearbyObservedSuccess ?: 0.0) > (w.nearbyObservedSuccess ?: 0.0) -> + "Better for dependable regional contacts, less DX reach." + a.observationCount < w.observationCount -> + "Less live activity observed nearby right now." + else -> + "Slightly weaker predicted reliability this hour." + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertHistoryStore.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertHistoryStore.kt new file mode 100644 index 000000000..bb0acbb5e --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertHistoryStore.kt @@ -0,0 +1,50 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONObject + +/** + * Persisted delivery history (opening identity → delivered-at epoch ms) so + * dedup and cooldowns survive process restarts. Bounded by [pruneHistory]'s + * 48-hour retention on every write. + */ +object AlertHistoryStore { + private const val PREFS_NAME = "ft8af_prop_alert_history" + private const val KEY_DELIVERED = "delivered" + + private fun prefs(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun deliveredAt(context: Context): Map = + parse(prefs(context).getString(KEY_DELIVERED, null)) + + fun recordDelivery(context: Context, identity: String, nowMs: Long) { + val pruned = pruneHistory(deliveredAt(context), nowMs) + (identity to nowMs) + prefs(context).edit().putString(KEY_DELIVERED, encode(pruned)).apply() + } + + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } + + internal fun parse(json: String?): Map { + json ?: return emptyMap() + return try { + val root = JSONObject(json) + buildMap { + for (key in root.keys()) { + put(key, root.optLong(key)) + } + } + } catch (_: Exception) { + emptyMap() + } + } + + internal fun encode(map: Map): String { + val root = JSONObject() + for ((k, v) in map) root.put(k, v) + return root.toString() + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertPreferences.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertPreferences.kt new file mode 100644 index 000000000..ec98ffe7e --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertPreferences.kt @@ -0,0 +1,78 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import android.content.SharedPreferences + +/** The opt-in alert categories. Every one defaults OFF. */ +enum class AlertType(val id: String) { + /** Any band opening near the operator's grid. */ + BAND_OPENING("band_opening"), + + /** One of the watched bands becoming active. */ + BAND_ACTIVE("band_active"), + + /** One of the watched regions becoming reachable. */ + REGION_REACHABLE("region_reachable"), + + /** The operator's own signal outperforming its baseline. */ + PERSONAL_IMPROVEMENT("personal_improvement"), + + /** Observed conditions substantially exceeding the VOACAP baseline. */ + UNUSUAL_OPENING("unusual_opening"), +} + +/** + * Immutable snapshot of the alert preferences, separated from storage so the + * decision policy is pure and unit-testable. + */ +data class AlertPrefs( + /** Master switch. Everything below is moot while false. */ + val enabled: Boolean = false, + val enabledTypes: Set = emptySet(), + /** Local hour [0..23] quiet window start; equal start/end = no quiet hours. */ + val quietStartHour: Int = 22, + val quietEndHour: Int = 7, + /** Bands to watch for BAND_ACTIVE ("20m" names); empty = all bands. */ + val watchedBands: Set = emptySet(), + /** Regions to watch for REGION_REACHABLE (server region names); empty = none. */ + val watchedRegions: Set = emptySet(), +) + +/** SharedPreferences persistence for [AlertPrefs]. */ +object AlertPreferencesStore { + private const val PREFS_NAME = "ft8af_prop_alerts" + private const val KEY_ENABLED = "enabled" + private const val KEY_TYPES = "types" + private const val KEY_QUIET_START = "quiet_start" + private const val KEY_QUIET_END = "quiet_end" + private const val KEY_BANDS = "bands" + private const val KEY_REGIONS = "regions" + + private fun prefs(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(context: Context): AlertPrefs { + val p = prefs(context) + return AlertPrefs( + enabled = p.getBoolean(KEY_ENABLED, false), + enabledTypes = p.getStringSet(KEY_TYPES, emptySet())!! + .mapNotNull { id -> AlertType.entries.firstOrNull { it.id == id } } + .toSet(), + quietStartHour = p.getInt(KEY_QUIET_START, 22).coerceIn(0, 23), + quietEndHour = p.getInt(KEY_QUIET_END, 7).coerceIn(0, 23), + watchedBands = p.getStringSet(KEY_BANDS, emptySet())!!.toSet(), + watchedRegions = p.getStringSet(KEY_REGIONS, emptySet())!!.toSet(), + ) + } + + fun save(context: Context, value: AlertPrefs) { + prefs(context).edit() + .putBoolean(KEY_ENABLED, value.enabled) + .putStringSet(KEY_TYPES, value.enabledTypes.map { it.id }.toSet()) + .putInt(KEY_QUIET_START, value.quietStartHour) + .putInt(KEY_QUIET_END, value.quietEndHour) + .putStringSet(KEY_BANDS, value.watchedBands) + .putStringSet(KEY_REGIONS, value.watchedRegions) + .apply() + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClient.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClient.kt new file mode 100644 index 000000000..fc6facf0b --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClient.kt @@ -0,0 +1,96 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import org.json.JSONObject +import radio.ks3ckc.ft8af.flags.parseIso8601UtcMs +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** + * Client for the lightweight `/v1/conditions` endpoint the alert worker polls. + * The backend does all expensive aggregation (VOACAP + PSK Reporter); the + * phone downloads one small pre-scored JSON document per check. + */ +object ConditionsClient { + const val DEFAULT_BASE_URL = "https://ft8af.app/api/band-advisor" + private const val IO_TIMEOUT_MS = 15_000 + + /** Null on any transport/HTTP/parse failure — the worker just skips a round. */ + fun fetch( + grid: String, + baseUrl: String = DEFAULT_BASE_URL, + nowMs: Long = System.currentTimeMillis(), + ): List? { + var conn: HttpURLConnection? = null + val body = try { + val url = "$baseUrl/v1/conditions?grid=" + + URLEncoder.encode(grid, StandardCharsets.UTF_8.name()) + conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = IO_TIMEOUT_MS + readTimeout = IO_TIMEOUT_MS + setRequestProperty("Accept", "application/json") + } + if (conn.responseCode != HttpURLConnection.HTTP_OK) return null + conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } catch (_: Exception) { + return null + } finally { + conn?.disconnect() + } + return parseConditions(body, nowMs) + } + + /** + * Parse the conditions document. Malformed entries are dropped; a document + * that yields nothing valid returns null so callers treat it as a failed + * fetch rather than "no openings". + */ + internal fun parseConditions(json: String, nowMs: Long): List? { + return try { + val root = JSONObject(json) + val generatedAtMs = parseIso8601UtcMs(root.optString("generatedAt")) ?: nowMs + val ageMs = (nowMs - generatedAtMs).coerceAtLeast(0) + val array = root.optJSONArray("bands") ?: return null + val out = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.optJSONObject(i) ?: continue + val band = obj.optString("band") + val score = obj.optDouble("score", Double.NaN) + val confidence = obj.optDouble("confidence", Double.NaN) + if (band.isBlank() || !score.isFinite() || !confidence.isFinite()) continue + val regionsArray = obj.optJSONArray("activeRegions") + val regions = if (regionsArray == null) { + emptyList() + } else { + (0 until regionsArray.length()).mapNotNull { j -> + (regionsArray.opt(j) as? String)?.takeIf { it.isNotBlank() } + } + } + out.add( + BandConditionSnapshot( + band = band, + score = score.coerceIn(0.0, 1.0), + confidence = confidence.coerceIn(0.0, 1.0), + observationCount = obj.optInt("observationCount", 0).coerceAtLeast(0), + activityRatio = obj.optDouble("activityRatio", 0.0).coerceAtLeast(0.0), + activeRegions = regions, + voacapSupported = obj.optBoolean("voacapSupported", false), + unusualOpening = obj.optBoolean("unusualOpening", false), + personalImprovement = + if (obj.has("personalImprovement")) { + obj.optBoolean("personalImprovement") + } else { + null + }, + dataAgeMs = ageMs, + ), + ) + } + out.ifEmpty { null } + } catch (_: Exception) { + null + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertNotifier.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertNotifier.kt new file mode 100644 index 000000000..1f6d87bd7 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertNotifier.kt @@ -0,0 +1,109 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.k1af.ft8af.R +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Posts propagation-alert notifications on their own channel (so users can + * silence them independently of DX/QSO alerts). Tap opens the app and asks it + * to show the Band Advisor detail for the opening. Mirrors DxAlertNotifier's + * permission handling: check POST_NOTIFICATIONS first, catch the revocation + * race around notify(). + */ +object PropagationAlertNotifier { + const val CHANNEL_ID = "propagation_alerts" + + /** Intent extra asking ComposeMainActivity to open the advisor detail. */ + const val EXTRA_OPEN_ADVISOR = "open_band_advisor" + + /** + * Posted (with a timestamp) when a propagation-alert notification is + * tapped; FT8AFApp observes it and opens the Band Advisor detail sheet. + */ + val openAdvisorRequest = androidx.lifecycle.MutableLiveData() + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.prop_alerts_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = context.getString(R.string.prop_alerts_channel_desc) + } + nm.createNotificationChannel(channel) + } + + fun canPost(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + /** Post one alert. Returns true when the notification was actually shown. */ + fun post(context: Context, decision: AlertDecision, grid: String): Boolean { + if (!canPost(context)) return false + ensureChannel(context) + + val title = context.getString(R.string.prop_alerts_notif_opening_title, decision.band) + val regionText = decision.region ?: context.getString(R.string.band_advisor_goal_dx) + val time = SimpleDateFormat("HH:mm", Locale.US).format(Date(decision.conditionsAtMs)) + val body = context.getString( + R.string.prop_alerts_notif_opening_text, + grid, + regionText, + decision.band, + ) + " ($time UTC)" + + val intent = Intent(context, radio.ks3ckc.ft8af.ComposeMainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP, + ) + putExtra(EXTRA_OPEN_ADVISOR, true) + } + var piFlags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + piFlags = piFlags or PendingIntent.FLAG_IMMUTABLE + } + val pendingIntent = PendingIntent.getActivity( + context, + decision.identity.hashCode(), + intent, + piFlags, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + return try { + NotificationManagerCompat.from(context) + .notify(decision.identity.hashCode(), notification) + true + } catch (_: SecurityException) { + // Permission revoked between check and notify — nothing was posted. + false + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicy.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicy.kt new file mode 100644 index 000000000..9ca68bd03 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicy.kt @@ -0,0 +1,178 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +/** + * Pure decision logic for propagation alerts: which observed band conditions + * deserve a notification, given the user's preferences and the delivery + * history. No Android dependencies — fully unit-tested. + * + * The bar is deliberately high: an alert must mean something. Openings need + * real evidence (observation count, above-baseline activity, confidence, + * freshness) and are deduplicated + cooled down aggressively. + */ + +/** One band's current conditions from the /v1/conditions endpoint. */ +data class BandConditionSnapshot( + val band: String, + val score: Double, + val confidence: Double, + val observationCount: Int, + /** Live activity relative to this band-hour's baseline (1.0 = typical). */ + val activityRatio: Double, + /** Regions currently reachable from near the operator's grid. */ + val activeRegions: List, + /** VOACAP agrees with the observed opening. */ + val voacapSupported: Boolean, + /** Observed conditions substantially exceed the VOACAP baseline. */ + val unusualOpening: Boolean, + /** Personal performance above own baseline (null = unknown/off). */ + val personalImprovement: Boolean?, + /** Age of the underlying observations, ms. */ + val dataAgeMs: Long, +) + +/** A decision to notify, carrying everything the notification needs. */ +data class AlertDecision( + val type: AlertType, + val band: String, + val region: String?, + val identity: String, + /** Timestamp of the conditions the alert is based on (shown to the user). */ + val conditionsAtMs: Long, +) + +// Evidence thresholds (documented in docs/band-advisor.md). +internal const val MIN_OBSERVATIONS = 10 +internal const val MIN_CONFIDENCE = 0.6 +internal const val MIN_ACTIVITY_RATIO = 1.5 +internal const val MAX_DATA_AGE_MS = 20L * 60 * 1000 + +/** One opening = one alert: identity buckets openings into 90-min windows. */ +internal const val OPENING_BUCKET_MS = 90L * 60 * 1000 + +/** Per-identity re-alert suppression window (also the history retention). */ +internal const val DEDUP_RETENTION_MS = 48L * 60 * 60 * 1000 + +/** Minimum gap between any two alerts for the same band. */ +internal const val PER_BAND_COOLDOWN_MS = 3L * 60 * 60 * 1000 + +/** Global minimum gap between any two alert notifications. */ +internal const val GLOBAL_COOLDOWN_MS = 30L * 60 * 1000 + +/** + * Stable identity for one opening: grid + band + target region + the 90-min + * bucket the opening started in. Persisted after delivery so the same opening + * never notifies twice, even across process restarts. + */ +fun alertIdentity(grid: String, band: String, region: String?, nowMs: Long): String { + val bucket = nowMs / OPENING_BUCKET_MS + return "$grid|$band|${region ?: "-"}|$bucket" +} + +/** + * True while the local hour is inside the quiet window. A window wraps + * midnight when start > end (22 → 7); start == end disables quiet hours. + */ +fun isInQuietHours(startHour: Int, endHour: Int, localHour: Int): Boolean { + if (startHour == endHour) return false + return if (startHour < endHour) { + localHour in startHour until endHour + } else { + localHour >= startHour || localHour < endHour + } +} + +/** Does this snapshot carry enough evidence to count as a real opening? */ +internal fun hasOpeningEvidence(c: BandConditionSnapshot): Boolean = + c.observationCount >= MIN_OBSERVATIONS && + c.confidence >= MIN_CONFIDENCE && + c.dataAgeMs in 0..MAX_DATA_AGE_MS && + c.activityRatio >= MIN_ACTIVITY_RATIO && + // Either the model agrees, or it's explicitly flagged as an unusual + // observed opening (which the notification labels as such). + (c.voacapSupported || c.unusualOpening) + +/** + * Evaluate all band conditions against preferences and history. Returns the + * alerts to deliver now, at most one per band, already deduplicated and + * cooldown-filtered; [deliveredAt] maps identity → delivery epoch ms. + */ +fun evaluateAlerts( + grid: String, + conditions: List, + prefs: AlertPrefs, + deliveredAt: Map, + nowMs: Long, + localHour: Int, +): List { + if (!prefs.enabled || prefs.enabledTypes.isEmpty()) return emptyList() + if (isInQuietHours(prefs.quietStartHour, prefs.quietEndHour, localHour)) return emptyList() + + val lastGlobal = deliveredAt.values.maxOrNull() ?: 0L + if (lastGlobal != 0L && nowMs - lastGlobal < GLOBAL_COOLDOWN_MS) return emptyList() + + val decisions = mutableListOf() + for (condition in conditions) { + if (!hasOpeningEvidence(condition)) continue + + // Per-band cooldown, across all identities for this band. + val bandRecent = deliveredAt.any { (identity, at) -> + identity.split('|').getOrNull(1) == condition.band && + nowMs - at < PER_BAND_COOLDOWN_MS + } + if (bandRecent) continue + + val decision = decisionFor(grid, condition, prefs, nowMs) ?: continue + // Dedup: the same opening (identity) never fires twice. + if (deliveredAt.containsKey(decision.identity)) continue + decisions.add(decision) + } + // One notification per check keeps the channel meaningful; the strongest + // (highest-score) opening wins, the rest will still qualify next check if + // they persist. + return decisions.sortedByDescending { d -> + conditions.first { it.band == d.band }.score + }.take(1) +} + +/** The highest-priority enabled alert type this condition satisfies. */ +private fun decisionFor( + grid: String, + c: BandConditionSnapshot, + prefs: AlertPrefs, + nowMs: Long, +): AlertDecision? { + val conditionsAt = nowMs - c.dataAgeMs + + fun decision(type: AlertType, region: String? = null) = AlertDecision( + type = type, + band = c.band, + region = region, + identity = alertIdentity(grid, c.band, region, nowMs), + conditionsAtMs = conditionsAt, + ) + + // Region reachability is most specific, then watched-band, then the rest. + if (AlertType.REGION_REACHABLE in prefs.enabledTypes) { + val hit = prefs.watchedRegions.firstOrNull { it in c.activeRegions } + if (hit != null) return decision(AlertType.REGION_REACHABLE, hit) + } + if (AlertType.BAND_ACTIVE in prefs.enabledTypes && + prefs.watchedBands.isNotEmpty() && c.band in prefs.watchedBands + ) { + return decision(AlertType.BAND_ACTIVE) + } + if (AlertType.UNUSUAL_OPENING in prefs.enabledTypes && c.unusualOpening) { + return decision(AlertType.UNUSUAL_OPENING) + } + if (AlertType.PERSONAL_IMPROVEMENT in prefs.enabledTypes && c.personalImprovement == true) { + return decision(AlertType.PERSONAL_IMPROVEMENT) + } + if (AlertType.BAND_OPENING in prefs.enabledTypes) { + return decision(AlertType.BAND_OPENING, c.activeRegions.firstOrNull()) + } + return null +} + +/** Drop history entries past retention so the store stays bounded. Pure. */ +fun pruneHistory(deliveredAt: Map, nowMs: Long): Map = + deliveredAt.filterValues { nowMs - it < DEDUP_RETENTION_MS } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertScheduler.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertScheduler.kt new file mode 100644 index 000000000..1a82ea49a --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertScheduler.kt @@ -0,0 +1,127 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import radio.ks3ckc.ft8af.bandadvisor.model.normalizeAdvisorGrid +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorTelemetry +import java.util.Calendar +import java.util.concurrent.TimeUnit + +/** + * Schedules (or cancels) the periodic propagation-alert check. The single + * decision — should work exist right now? — is the pure [shouldSchedule]; + * [sync] applies it. Called from app start and from every settings/flag + * change, so disabling the flag or the preference always cancels pending work. + */ +object PropagationAlertScheduler { + internal const val WORK_NAME = "band_advisor_prop_alerts" + + /** WorkManager's floor for periodic work is 15 min; 30 keeps wakeups modest. */ + internal const val INTERVAL_MINUTES = 30L + + /** Pure: alerts run only when the flag AND the user preference agree. */ + fun shouldSchedule(flagEnabled: Boolean, prefs: AlertPrefs): Boolean = + flagEnabled && prefs.enabled && prefs.enabledTypes.isNotEmpty() + + /** Reconcile scheduled work with the current flag + preferences. */ + fun sync(context: Context) { + val enabled = shouldSchedule( + FeatureFlags.isEnabled(FeatureFlag.PROPAGATION_ALERTS), + AlertPreferencesStore.load(context), + ) + val workManager = WorkManager.getInstance(context.applicationContext) + if (!enabled) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + val request = PeriodicWorkRequestBuilder( + INTERVAL_MINUTES, TimeUnit.MINUTES, + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + // KEEP: re-syncing must not reset the period timer on every app start. + workManager.enqueueUniquePeriodicWork( + WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request, + ) + } +} + +/** + * The periodic check: re-verify the gates (they may have flipped since + * scheduling), fetch pre-aggregated conditions from the backend, run the pure + * alert policy, notify, and record deliveries for dedup. Always returns + * success — a failed round is just a skipped round; alerts must never + * escalate into retry storms or interfere with the app. + */ +class PropagationAlertWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + + override suspend fun doWork(): Result { + val context = applicationContext + // Gates may have changed since scheduling; a disabled state also + // un-schedules so this worker stops running entirely. + val prefs = AlertPreferencesStore.load(context) + if (!PropagationAlertScheduler.shouldSchedule( + FeatureFlags.isEnabled(FeatureFlag.PROPAGATION_ALERTS), prefs, + ) + ) { + PropagationAlertScheduler.sync(context) + return Result.success() + } + if (!PropagationAlertNotifier.canPost(context)) return Result.success() + + val grid = + withContext(Dispatchers.IO) { loadAlertGrid(context) } ?: return Result.success() + + val nowMs = System.currentTimeMillis() + val conditions = ConditionsClient.fetch(grid, nowMs = nowMs) ?: return Result.success() + + val decisions = evaluateAlerts( + grid = grid, + conditions = conditions, + prefs = prefs, + deliveredAt = AlertHistoryStore.deliveredAt(context), + nowMs = nowMs, + localHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY), + ) + for (decision in decisions) { + if (PropagationAlertNotifier.post(context, decision, grid)) { + AlertHistoryStore.recordDelivery(context, decision.identity, nowMs) + BandAdvisorTelemetry.event( + "alert_delivered", + "${decision.type} ${decision.band} ${decision.region ?: ""}", + ) + } + } + return Result.success() + } +} + +/** Read persisted configuration without starting the Activity or FT8 engine. */ +internal fun loadAlertGrid(context: Context): String? = + runCatching { + val path = context.getDatabasePath("data.db") + if (!path.isFile) return@runCatching null + SQLiteDatabase.openDatabase(path.absolutePath, null, SQLiteDatabase.OPEN_READONLY).use { db -> + db.rawQuery("SELECT Value FROM config WHERE KeyName = ? COLLATE NOCASE", arrayOf("grid")).use { cursor -> + if (cursor.moveToFirst()) normalizeAdvisorGrid(cursor.getString(0)) else null + } + } + }.getOrNull() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Models.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Models.kt new file mode 100644 index 000000000..aaa7a1693 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Models.kt @@ -0,0 +1,150 @@ +package radio.ks3ckc.ft8af.bandadvisor.model + +/** + * Backend-neutral domain models for a Band Advisor recommendation. Plain + * Kotlin — no Retrofit/Gson/Moshi/UI dependencies — mirroring the versioned + * JSON contract served by the band-advisor service (see + * server/band-advisor/README.md and docs/band-advisor.md). + */ + +/** What the operator is trying to do right now. Default is MAKE_CONTACT. */ +enum class OperatingGoal(val wireName: String) { + MAKE_CONTACT("MAKE_CONTACT"), + DX("DX"), + TARGET("TARGET"), + POTA("POTA"), + ; + + companion object { + fun fromWireName(raw: String?): OperatingGoal = + entries.firstOrNull { it.wireName.equals(raw, ignoreCase = true) } ?: MAKE_CONTACT + } +} + +/** Concrete destinations supported by the backend TARGET goal. */ +enum class AdvisorTargetRegion { + EUROPE, + NORTH_AMERICA_EAST, + NORTH_AMERICA_WEST, + SOUTH_AMERICA, + AFRICA, + ASIA, + OCEANIA, + ; + + companion object { + fun fromWireName(raw: String?): AdvisorTargetRegion = entries.firstOrNull { it.name == raw } ?: EUROPE + } +} + +/** Where one piece of supporting evidence came from. */ +enum class EvidenceType(val wireName: String) { + VOACAP("VOACAP"), + REGIONAL_PSK_REPORTER("REGIONAL_PSK_REPORTER"), + PERSONAL_PSK_REPORTER("PERSONAL_PSK_REPORTER"), + OTHER("OTHER"), + ; + + companion object { + fun fromWireName(raw: String?): EvidenceType = + entries.firstOrNull { it.wireName.equals(raw, ignoreCase = true) } ?: OTHER + } +} + +/** One human-readable reason supporting the recommendation. */ +data class Evidence( + val type: EvidenceType, + val message: String, +) + +/** One weighted component of the score, for explainability ("why 20m won"). */ +data class ScoreComponent( + val name: String, + val weight: Double, + val value: Double, + val contribution: Double, +) + +/** + * Callsign-specific PSK Reporter analytics. These are RECEPTION REPORTS — + * stations that heard the operator — never completed QSOs, and the UI must + * present them as such. [performanceComparedToBaseline] is a signed fraction + * vs the operator's own historical norm (+0.18 = 18% above normal), or null + * when the sample is too small for an honest comparison. + */ +data class PersonalAnalyticsSummary( + val enabled: Boolean, + val reportsReceived: Int = 0, + val uniqueReceivers: Int = 0, + val countriesReached: Int = 0, + val maximumDistanceKm: Int = 0, + val medianSnrDb: Int? = null, + val bestSnrDb: Int? = null, + val performanceComparedToBaseline: Double? = null, +) + +/** A runner-up band with the reason it ranked below the recommendation. */ +data class BandAlternative( + val band: String, + val frequencyHz: Long, + val score: Double, + val reason: String, +) + +/** Which upstream data sources contributed. All false = fully degraded. */ +data class SourceAvailability( + val voacapAvailable: Boolean, + val regionalPskReporterAvailable: Boolean, + val personalPskReporterAvailable: Boolean, +) { + val anyAvailable: Boolean + get() = voacapAvailable || regionalPskReporterAvailable || personalPskReporterAvailable + + /** Partial = some but not all sources contributed. */ + val isPartial: Boolean + get() = anyAvailable && + !(voacapAvailable && regionalPskReporterAvailable && personalPskReporterAvailable) +} + +/** A complete Band Advisor recommendation. */ +data class BandRecommendation( + val targetRegion: String? = null, + val generatedAtMs: Long, + val validUntilMs: Long, + val grid: String, + val callsign: String?, + val mode: String, + val goal: OperatingGoal, + val recommendedBand: String, + val recommendedFrequencyHz: Long, + val score: Double, + val confidence: Double, + val summary: String, + val destinations: List, + val evidence: List, + val scoreComponents: List, + val personalAnalytics: PersonalAnalyticsSummary?, + val alternatives: List, + val sources: SourceAvailability, +) + +/** How current a recommendation is relative to its own validity window. */ +enum class Freshness { + /** Inside the validUntil window. */ + FRESH, + + /** Past validUntil but recent enough to still show (marked stale). */ + STALE, + + /** Too old to be useful; show only with a strong warning or not at all. */ + EXPIRED, +} + +/** How long past validUntil a recommendation may still be shown as STALE. */ +const val STALE_GRACE_MS: Long = 45L * 60 * 1000 + +fun freshnessOf(recommendation: BandRecommendation, nowMs: Long): Freshness = when { + nowMs < recommendation.validUntilMs -> Freshness.FRESH + nowMs < recommendation.validUntilMs + STALE_GRACE_MS -> Freshness.STALE + else -> Freshness.EXPIRED +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParser.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParser.kt new file mode 100644 index 000000000..a6a79eb5d --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParser.kt @@ -0,0 +1,122 @@ +package radio.ks3ckc.ft8af.bandadvisor.model + +import org.json.JSONArray +import org.json.JSONObject +import radio.ks3ckc.ft8af.flags.parseIso8601UtcMs + +/** + * Parse + validate a recommendation JSON document from the band-advisor + * service (or a fixture). Returns null for anything that fails validation — + * a recommendation with a bogus frequency or out-of-range score must never + * reach the UI or, worse, the tuning path. Never throws. + */ +fun parseBandRecommendation(json: String): BandRecommendation? { + return try { + val root = JSONObject(json) + val generatedAtMs = parseIso8601UtcMs(root.optString("generatedAt")) ?: return null + val validUntilMs = parseIso8601UtcMs(root.optString("validUntil")) ?: return null + if (validUntilMs <= generatedAtMs) return null + val grid = normalizeAdvisorGrid(root.optString("grid")) ?: return null + val band = root.optString("recommendedBand") + if (band.isBlank()) return null + val frequencyHz = root.optLong("recommendedFrequencyHz", -1) + // Sanity bounds: all amateur HF/VHF FT8 dials live well inside this. + if (frequencyHz < 100_000 || frequencyHz > 1_500_000_000) return null + val score = root.optDouble("score", Double.NaN) + val confidence = root.optDouble("confidence", Double.NaN) + if (!score.isFinite() || score < 0.0 || score > 1.0) return null + if (!confidence.isFinite() || confidence < 0.0 || confidence > 1.0) return null + + val callsign = root.optString("callsign").takeIf { it.isNotBlank() } + val sourcesObj = root.optJSONObject("sources") + val sources = SourceAvailability( + voacapAvailable = sourcesObj?.optBoolean("voacapAvailable", false) ?: false, + regionalPskReporterAvailable = + sourcesObj?.optBoolean("regionalPskReporterAvailable", false) ?: false, + personalPskReporterAvailable = + sourcesObj?.optBoolean("personalPskReporterAvailable", false) ?: false, + ) + + BandRecommendation( + targetRegion = root.optString("targetRegion").takeIf { it.isNotBlank() }, + generatedAtMs = generatedAtMs, + validUntilMs = validUntilMs, + grid = grid, + callsign = callsign, + mode = root.optString("mode", "FT8").ifBlank { "FT8" }, + goal = OperatingGoal.fromWireName(root.optString("goal")), + recommendedBand = band, + recommendedFrequencyHz = frequencyHz, + score = score, + confidence = confidence, + summary = root.optString("summary"), + destinations = stringList(root.optJSONArray("destinations")), + evidence = parseEvidence(root.optJSONArray("evidence")), + scoreComponents = parseScoreComponents(root.optJSONArray("scoreComponents")), + personalAnalytics = parsePersonalAnalytics(root.optJSONObject("personalAnalytics")), + alternatives = parseAlternatives(root.optJSONArray("alternatives")), + sources = sources, + ) + } catch (_: Exception) { + null + } +} + +private fun stringList(array: JSONArray?): List { + array ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + (array.opt(i) as? String)?.takeIf { it.isNotBlank() } + } +} + +private fun parseEvidence(array: JSONArray?): List { + array ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + val obj = array.optJSONObject(i) ?: return@mapNotNull null + val message = obj.optString("message") + if (message.isBlank()) return@mapNotNull null + Evidence(EvidenceType.fromWireName(obj.optString("type")), message) + } +} + +private fun parseScoreComponents(array: JSONArray?): List { + array ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + val obj = array.optJSONObject(i) ?: return@mapNotNull null + val name = obj.optString("name") + val weight = obj.optDouble("weight", Double.NaN) + val value = obj.optDouble("value", Double.NaN) + val contribution = obj.optDouble("contribution", Double.NaN) + if (name.isBlank() || !weight.isFinite() || !value.isFinite() || !contribution.isFinite()) { + return@mapNotNull null + } + ScoreComponent(name, weight, value, contribution) + } +} + +private fun parsePersonalAnalytics(obj: JSONObject?): PersonalAnalyticsSummary? { + obj ?: return null + val baseline = obj.optDouble("performanceComparedToBaseline", Double.NaN) + return PersonalAnalyticsSummary( + enabled = obj.optBoolean("enabled", false), + reportsReceived = obj.optInt("reportsReceived", 0).coerceAtLeast(0), + uniqueReceivers = obj.optInt("uniqueReceivers", 0).coerceAtLeast(0), + countriesReached = obj.optInt("countriesReached", 0).coerceAtLeast(0), + maximumDistanceKm = obj.optInt("maximumDistanceKm", 0).coerceAtLeast(0), + medianSnrDb = if (obj.has("medianSnrDb")) obj.optInt("medianSnrDb") else null, + bestSnrDb = if (obj.has("bestSnrDb")) obj.optInt("bestSnrDb") else null, + performanceComparedToBaseline = baseline.takeIf { it.isFinite() }, + ) +} + +private fun parseAlternatives(array: JSONArray?): List { + array ?: return emptyList() + return (0 until array.length()).mapNotNull { i -> + val obj = array.optJSONObject(i) ?: return@mapNotNull null + val band = obj.optString("band") + val freq = obj.optLong("frequencyHz", -1) + val score = obj.optDouble("score", Double.NaN) + if (band.isBlank() || freq <= 0 || !score.isFinite()) return@mapNotNull null + BandAlternative(band, freq, score.coerceIn(0.0, 1.0), obj.optString("reason")) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Validation.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Validation.kt new file mode 100644 index 000000000..469c47e9f --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/Validation.kt @@ -0,0 +1,44 @@ +package radio.ks3ckc.ft8af.bandadvisor.model + +/** + * Input validation for Band Advisor requests. Grid validation for decode paths + * already lives in MaidenheadGrid.isDecodableGrid; these helpers cover the + * advisor's narrower needs: a 4-char request grid and a structurally plausible + * callsign to gate personal analytics on. + */ + +private val GRID_4 = Regex("^[A-Ra-r]{2}[0-9]{2}$") + +// Structural amateur callsign check (not an allocation lookup): optional +// prefix ("VP2E/"), a body of 1-2 letters+digit(s) or digit+letters pattern +// with at least one digit and one letter, optional suffix ("/P", "/QRP"). +// Intentionally permissive — its job is to reject empty/garbage values like +// "NOCALL" placeholders' obvious junk, not to police license databases. +private val CALLSIGN_BODY = Regex("^[A-Z0-9]{1,3}[0-9][A-Z0-9]{0,3}[A-Z]$") + +/** + * Normalize any decodable grid to the 4-character square the advisor sends — + * the app never transmits 6-char precision for recommendations (privacy: + * a 4-char square is ~1°×2°). Returns null when the input can't be reduced + * to a valid 4-char square. + */ +fun normalizeAdvisorGrid(raw: String?): String? { + val trimmed = raw?.trim() ?: return null + if (trimmed.length < 4) return null + val four = trimmed.substring(0, 4) + if (!GRID_4.matches(four)) return null + return four.substring(0, 2).uppercase() + four.substring(2) +} + +/** + * True when [raw] looks like a real amateur callsign. Used to gate personal + * PSK Reporter analytics — no valid callsign means the feature stays off. + */ +fun isPlausibleCallsign(raw: String?): Boolean { + val call = raw?.trim()?.uppercase() ?: return false + if (call.length < 3 || call.length > 12) return false + // Split off portable prefix/suffix around "/" and validate the longest part + // as the body (covers "W1AW/P" and "VP2E/K1AF" alike). + val body = call.split('/').maxByOrNull { it.length } ?: return false + return CALLSIGN_BODY.matches(body) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalytics.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalytics.kt new file mode 100644 index 000000000..2434af1b0 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalytics.kt @@ -0,0 +1,149 @@ +package radio.ks3ckc.ft8af.bandadvisor.personal + +import radio.ks3ckc.ft8af.pskreporter.PskReporterSpot +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Pure computation of personal PSK Reporter analytics from reception reports + * where the operator was the SENDER (stations that heard the user). These are + * reception reports, not QSOs — presentation must never imply a completed + * contact. No Android or network dependencies; fully unit-testable. + */ + +/** Minimum current-sample size before any baseline comparison is shown. */ +const val MIN_REPORTS_FOR_COMPARISON = 10 + +/** Minimum stored history sessions before a personal baseline is meaningful. */ +const val MIN_BASELINE_SESSIONS = 3 + +/** Stats for one band (or the whole sample when band == null). */ +data class PersonalBandStats( + val band: String?, + val reports: Int, + val uniqueReceivers: Int, + val uniqueGridFields: Int, + /** Distinct countries, only when a resolver was available; 0 otherwise. */ + val countriesReached: Int, + val maxDistanceKm: Int, + val medianDistanceKm: Int, + val bestSnrDb: Int?, + val medianSnrDb: Int?, + /** Distinct 45° azimuth octants with at least one report (0..8). */ + val directionOctants: Int, + val mostRecentReportEpochSec: Long?, +) + +data class PersonalAnalyticsReport( + val overall: PersonalBandStats, + val perBand: Map, +) + +/** + * Compute analytics from [spots] (each spot = one station that heard us). + * [myLat]/[myLon] locate the operator for distance/azimuth; [bandOf] maps a + * frequency to a band label ("20m") or null to drop the spot; [countryResolver] + * maps a callsign to a country name or null when unknown (injected because the + * production lookup is DB-backed). + */ +fun computePersonalAnalytics( + spots: List, + myLat: Double, + myLon: Double, + bandOf: (Long) -> String?, + countryResolver: (String) -> String? = { null }, +): PersonalAnalyticsReport { + val byBand = spots.groupBy { bandOf(it.frequencyHz) } + .filterKeys { it != null } + .mapKeys { it.key!! } + val perBand = byBand.mapValues { (band, bandSpots) -> + statsFor(band, bandSpots, myLat, myLon, countryResolver) + } + val overall = statsFor(null, byBand.values.flatten(), myLat, myLon, countryResolver) + return PersonalAnalyticsReport(overall, perBand) +} + +private fun statsFor( + band: String?, + spots: List, + myLat: Double, + myLon: Double, + countryResolver: (String) -> String?, +): PersonalBandStats { + if (spots.isEmpty()) { + return PersonalBandStats(band, 0, 0, 0, 0, 0, 0, null, null, 0, null) + } + val distances = spots.map { haversineKm(myLat, myLon, it.lat, it.lon) } + val snrs = spots.map { it.snr } + val octants = spots.map { azimuthOctant(myLat, myLon, it.lat, it.lon) }.toSet() + val countries = spots.mapNotNull { countryResolver(it.callsign) }.toSet() + return PersonalBandStats( + band = band, + reports = spots.size, + uniqueReceivers = spots.map { it.callsign.uppercase() }.toSet().size, + uniqueGridFields = spots.map { it.grid.take(4).uppercase() }.toSet().size, + countriesReached = countries.size, + maxDistanceKm = distances.max().toInt(), + medianDistanceKm = medianOf(distances).toInt(), + bestSnrDb = snrs.max(), + medianSnrDb = medianOf(snrs.map { it.toDouble() }).toInt(), + directionOctants = octants.size, + mostRecentReportEpochSec = spots.maxOf { it.flowStartSeconds }, + ) +} + +/** + * Signed fraction of [currentMaxKm] vs the operator's own historical median + * max distance on this band (+0.18 = 18% farther than usual). Returns null — + * comparison suppressed — when the current sample is below + * [MIN_REPORTS_FOR_COMPARISON] or history has fewer than + * [MIN_BASELINE_SESSIONS] sessions: a percentile from three spots is noise, + * and showing it would be statistically dishonest. + */ +fun baselineComparison( + currentReports: Int, + currentMaxKm: Int, + historicalMaxKm: List, +): Double? { + if (currentReports < MIN_REPORTS_FOR_COMPARISON) return null + if (historicalMaxKm.size < MIN_BASELINE_SESSIONS) return null + val baseline = medianOf(historicalMaxKm.map { it.toDouble() }) + if (baseline <= 0.0) return null + return (currentMaxKm - baseline) / baseline +} + +internal fun medianOf(values: List): Double { + require(values.isNotEmpty()) + val sorted = values.sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 1) sorted[mid] else (sorted[mid - 1] + sorted[mid]) / 2.0 +} + +private const val EARTH_RADIUS_KM = 6371.0 + +/** Great-circle distance in km. */ +fun haversineKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val a = sin(dLat / 2) * sin(dLat / 2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2) * sin(dLon / 2) + return 2 * EARTH_RADIUS_KM * asin(sqrt(a.coerceIn(0.0, 1.0))) +} + +/** Initial bearing bucketed into 45° octants, 0=N..7=NW. */ +internal fun azimuthOctant(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Int { + val phi1 = Math.toRadians(lat1) + val phi2 = Math.toRadians(lat2) + val dLon = Math.toRadians(lon2 - lon1) + val y = sin(dLon) * cos(phi2) + val x = cos(phi1) * sin(phi2) - sin(phi1) * cos(phi2) * cos(dLon) + var bearing = Math.toDegrees(atan2(y, x)) + if (bearing < 0) bearing += 360.0 + // Same point → bearing is 0/undefined; bucket 0 deterministically. + if (abs(lat1 - lat2) < 1e-9 && abs(lon1 - lon2) < 1e-9) return 0 + return ((bearing + 22.5) / 45.0).toInt() % 8 +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStore.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStore.kt new file mode 100644 index 000000000..5bcd2901d --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStore.kt @@ -0,0 +1,65 @@ +package radio.ks3ckc.ft8af.bandadvisor.personal + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * Local rolling history of per-band personal performance, used only to build + * the operator's OWN baseline ("above your normal 20m range"). Bounded to + * [MAX_SESSIONS] entries per band; cleared entirely by the privacy control in + * Band Advisor settings. Nothing here ever leaves the device. + */ +object PersonalAnalyticsStore { + private const val PREFS_NAME = "ft8af_personal_psk" + private const val KEY_HISTORY = "band_history" + internal const val MAX_SESSIONS = 10 + + private fun prefs(context: Context): SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + /** Historical max-distance samples (km) for [band], oldest first. */ + fun historyFor(context: Context, band: String): List = + parseHistory(prefs(context).getString(KEY_HISTORY, null))[band] ?: emptyList() + + /** + * Record a session sample for [band]. Only sessions with a meaningful + * number of reports should be recorded (callers gate on sample size) so + * a single stray spot doesn't drag the baseline. + */ + fun record(context: Context, band: String, maxDistanceKm: Int) { + val all = parseHistory(prefs(context).getString(KEY_HISTORY, null)).toMutableMap() + val list = (all[band] ?: emptyList()) + maxDistanceKm + all[band] = list.takeLast(MAX_SESSIONS) + prefs(context).edit().putString(KEY_HISTORY, encodeHistory(all)).apply() + } + + /** Wipe all locally cached personal analytics. */ + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } + + internal fun parseHistory(json: String?): Map> { + json ?: return emptyMap() + return try { + val root = JSONObject(json) + buildMap { + for (band in root.keys()) { + val arr = root.optJSONArray(band) ?: continue + put(band, (0 until arr.length()).map { arr.optInt(it) }) + } + } + } catch (_: Exception) { + emptyMap() + } + } + + internal fun encodeHistory(history: Map>): String { + val root = JSONObject() + for ((band, values) in history) { + root.put(band, JSONArray(values)) + } + return root.toString() + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorer.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorer.kt new file mode 100644 index 000000000..f1c6c0cc7 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorer.kt @@ -0,0 +1,193 @@ +package radio.ks3ckc.ft8af.bandadvisor.scoring + +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.bandadvisor.model.ScoreComponent + +/** + * Deterministic, explainable band scoring — the client-side reference + * implementation of the model the band-advisor service runs. Production + * recommendations are scored server-side; this Kotlin copy drives the fixture + * repository (offline UI development) and pins the model's behavior with unit + * tests. Any change here must be mirrored in server/band-advisor/app/scoring.py. + * + * There is NO machine learning or generative model anywhere in this path: + * a recommendation is a weighted sum of named 0..1 inputs, and the weights + * and per-component contributions are returned so the UI can show exactly + * why a band won. + */ + +/** + * Normalized (0..1) condition inputs for one band. Null = that signal is + * unavailable and its weight is redistributed across the rest. + * + * Conventions: [normalizedLiveActivity] is activity relative to that band's + * typical level for this UTC hour (0.5 = typical) — never raw spot volume, + * so a perpetually busy band can't win on volume alone. [activityTrend] and + * [personalPerformance] are centered at 0.5 (= flat / at own baseline). + */ +data class BandConditions( + val band: String, + val frequencyHz: Long, + val voacapReliability: Double? = null, + val nearbyObservedSuccess: Double? = null, + val normalizedLiveActivity: Double? = null, + val distancePotential: Double? = null, + val activityTrend: Double? = null, + val personalPerformance: Double? = null, + val directionalDiversity: Double? = null, + val targetReliability: Double? = null, + val targetObservedPaths: Double? = null, + val observationCount: Int = 0, + val destinations: List = emptyList(), +) + +/** A band with its final score and the components that produced it. */ +data class ScoredBand( + val conditions: BandConditions, + val score: Double, + val components: List, +) + +/** Wire names shared with the server contract's scoreComponents entries. */ +internal object ComponentNames { + const val VOACAP_RELIABILITY = "voacapReliability" + const val NEARBY_OBSERVED_SUCCESS = "nearbyObservedSuccess" + const val NORMALIZED_LIVE_ACTIVITY = "normalizedLiveActivity" + const val DISTANCE_POTENTIAL = "distancePotential" + const val ACTIVITY_TREND = "activityTrend" + const val PERSONAL_PERFORMANCE = "personalStationPerformance" + const val DIRECTIONAL_DIVERSITY = "directionalDiversity" + const val TARGET_RELIABILITY = "targetReliability" + const val TARGET_OBSERVED_PATHS = "targetObservedPaths" +} + +/** Base weights per goal. Each map sums to 1.0 when every input is present. */ +internal fun weightsFor(goal: OperatingGoal): Map = when (goal) { + OperatingGoal.MAKE_CONTACT -> mapOf( + ComponentNames.VOACAP_RELIABILITY to 0.30, + ComponentNames.NEARBY_OBSERVED_SUCCESS to 0.25, + ComponentNames.NORMALIZED_LIVE_ACTIVITY to 0.15, + ComponentNames.DISTANCE_POTENTIAL to 0.10, + ComponentNames.ACTIVITY_TREND to 0.10, + ComponentNames.PERSONAL_PERFORMANCE to 0.10, + ) + OperatingGoal.DX -> mapOf( + ComponentNames.DISTANCE_POTENTIAL to 0.25, + ComponentNames.VOACAP_RELIABILITY to 0.25, + ComponentNames.DIRECTIONAL_DIVERSITY to 0.15, + ComponentNames.NEARBY_OBSERVED_SUCCESS to 0.15, + ComponentNames.NORMALIZED_LIVE_ACTIVITY to 0.10, + ComponentNames.PERSONAL_PERFORMANCE to 0.10, + ) + OperatingGoal.TARGET -> mapOf( + ComponentNames.TARGET_RELIABILITY to 0.40, + ComponentNames.TARGET_OBSERVED_PATHS to 0.30, + ComponentNames.NORMALIZED_LIVE_ACTIVITY to 0.10, + ComponentNames.VOACAP_RELIABILITY to 0.10, + ComponentNames.PERSONAL_PERFORMANCE to 0.10, + ) + OperatingGoal.POTA -> mapOf( + ComponentNames.VOACAP_RELIABILITY to 0.35, + ComponentNames.NEARBY_OBSERVED_SUCCESS to 0.25, + ComponentNames.NORMALIZED_LIVE_ACTIVITY to 0.20, + ComponentNames.ACTIVITY_TREND to 0.10, + ComponentNames.PERSONAL_PERFORMANCE to 0.10, + ) +} + +private fun valueFor(conditions: BandConditions, componentName: String): Double? = + when (componentName) { + ComponentNames.VOACAP_RELIABILITY -> conditions.voacapReliability + ComponentNames.NEARBY_OBSERVED_SUCCESS -> conditions.nearbyObservedSuccess + ComponentNames.NORMALIZED_LIVE_ACTIVITY -> conditions.normalizedLiveActivity + ComponentNames.DISTANCE_POTENTIAL -> conditions.distancePotential + ComponentNames.ACTIVITY_TREND -> conditions.activityTrend + ComponentNames.PERSONAL_PERFORMANCE -> conditions.personalPerformance + ComponentNames.DIRECTIONAL_DIVERSITY -> conditions.directionalDiversity + ComponentNames.TARGET_RELIABILITY -> conditions.targetReliability + ComponentNames.TARGET_OBSERVED_PATHS -> conditions.targetObservedPaths + else -> null + } + +/** + * Score one band for a goal. Missing (null) inputs are dropped and the + * remaining weights renormalized to sum 1, so a band with only VOACAP data + * competes on the same 0..1 scale as a fully-observed one — confidence, not + * score, is where thin data shows up. Returns score 0 with no components when + * every input is missing. + */ +fun scoreBand(conditions: BandConditions, goal: OperatingGoal): ScoredBand { + val weights = weightsFor(goal) + val present = weights.mapNotNull { (name, weight) -> + valueFor(conditions, name)?.let { value -> Triple(name, weight, value.coerceIn(0.0, 1.0)) } + } + val totalWeight = present.sumOf { it.second } + if (totalWeight <= 0.0) { + return ScoredBand(conditions, 0.0, emptyList()) + } + val components = present.map { (name, weight, value) -> + val normalizedWeight = weight / totalWeight + ScoreComponent( + name = name, + weight = normalizedWeight, + value = value, + contribution = normalizedWeight * value, + ) + } + return ScoredBand(conditions, components.sumOf { it.contribution }, components) +} + +/** Score all bands and rank best-first (deterministic tie-break by frequency). */ +fun rankBands(bands: List, goal: OperatingGoal): List = + bands.map { scoreBand(it, goal) } + .sortedWith(compareByDescending { it.score }.thenBy { it.conditions.frequencyHz }) + +// --------------------------------------------------------------------------- +// Confidence — deliberately separate from score. Score says "how good is this +// band"; confidence says "how much should you trust that claim". +// --------------------------------------------------------------------------- + +data class ConfidenceInputs( + /** Live observations backing the winning band. */ + val observationCount: Int, + /** Age of the newest source data, ms. */ + val dataAgeMs: Long, + /** Fraction of the three sources that responded (0..1). */ + val sourceAvailability: Double, + /** Both present → 1 - |voacap - observed|; null when either is missing. */ + val modelAgreement: Double?, + /** Personal reports in the sample (0 when the feature is off). */ + val personalSampleSize: Int, + /** 0..1: has the user set power/antenna/grid precision etc. */ + val stationProfileCompleteness: Double, +) + +/** Observation counts at/above this no longer increase confidence. */ +internal const val OBSERVATIONS_SATURATION = 50 + +/** Data older than this contributes zero freshness confidence. */ +internal const val DATA_AGE_ZERO_MS = 60L * 60 * 1000 + +/** + * Deterministic confidence in 0..1. A weighted mean of saturating factors: + * observation volume (0.30), freshness (0.25), source availability (0.20), + * VOACAP-vs-observed agreement (0.15, neutral 0.5 when unmeasurable), and + * station-profile completeness (0.10). Personal sample size adds up to a + * +0.05 bonus rather than a core factor, since most users start without one. + */ +fun confidenceFor(inputs: ConfidenceInputs): Double { + val observations = + (inputs.observationCount.coerceAtLeast(0).toDouble() / OBSERVATIONS_SATURATION) + .coerceAtMost(1.0) + val freshness = + (1.0 - inputs.dataAgeMs.coerceAtLeast(0).toDouble() / DATA_AGE_ZERO_MS).coerceIn(0.0, 1.0) + val agreement = inputs.modelAgreement?.coerceIn(0.0, 1.0) ?: 0.5 + val core = 0.30 * observations + + 0.25 * freshness + + 0.20 * inputs.sourceAvailability.coerceIn(0.0, 1.0) + + 0.15 * agreement + + 0.10 * inputs.stationProfileCompleteness.coerceIn(0.0, 1.0) + val personalBonus = 0.05 * (inputs.personalSampleSize.coerceAtLeast(0).toDouble() / 20.0) + .coerceAtMost(1.0) + return (core + personalBonus).coerceIn(0.0, 1.0) +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlag.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlag.kt new file mode 100644 index 000000000..0fbe65ced --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlag.kt @@ -0,0 +1,55 @@ +package radio.ks3ckc.ft8af.flags + +/** + * Rollout flags for features under staged release (Band Advisor and its + * companions). Each flag maps to a key in the remote feature-configuration + * payload ([remoteKey]) and to a build-time `BuildConfig` default wired up in + * [radio.ks3ckc.ft8af.flags.FeatureFlags]. + * + * These flags are ROLLOUT CONTROLS, not subscription security: they gate what + * the client shows and requests, but a modified client can flip them. Any + * future paid feature must ALSO be validated server-side (the backend refuses + * to serve data the account is not entitled to) — never rely on a client flag + * alone for access control. + */ +enum class FeatureFlag(val remoteKey: String) { + /** The Band Advisor card, detail sheet, and recommendation fetching. */ + BAND_ADVISOR("bandAdvisor"), + + /** Callsign-specific PSK Reporter analytics inside Band Advisor. */ + PERSONAL_PSK_ANALYTICS("personalPskAnalytics"), + + /** Background propagation / band-opening alert checks + notifications. */ + PROPAGATION_ALERTS("propagationAlerts"), +} + +/** + * Developer override for one flag, persisted only in debug builds. Tri-state + * so "no override" is distinguishable from "forced off". + */ +enum class FlagOverride(val id: String) { + DEFAULT("default"), + FORCED_ON("on"), + FORCED_OFF("off"), + ; + + companion object { + /** Parse a persisted id; unknown/missing values mean no override. */ + fun fromId(raw: String?): FlagOverride = + entries.firstOrNull { it.id == raw } ?: DEFAULT + } +} + +/** Which layer supplied the effective value of a flag. */ +enum class FlagValueSource { + DEBUG_OVERRIDE, + REMOTE_CONFIG, + BUILD_DEFAULT, +} + +/** The effective value of a flag plus the layer that decided it. */ +data class FlagEvaluation( + val flag: FeatureFlag, + val enabled: Boolean, + val source: FlagValueSource, +) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepository.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepository.kt new file mode 100644 index 000000000..177fe23eb --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepository.kt @@ -0,0 +1,71 @@ +package radio.ks3ckc.ft8af.flags + +/** Read side of the flag system — what feature code consults. */ +interface FeatureFlagRepository { + fun isEnabled(flag: FeatureFlag): Boolean + + /** [isEnabled] plus which layer supplied the value (for the debug screen). */ + fun evaluate(flag: FeatureFlag): FlagEvaluation +} + +/** Persistence for developer overrides (only consulted in debug builds). */ +interface FlagOverrideStore { + fun overrideFor(flag: FeatureFlag): FlagOverride + fun setOverride(flag: FeatureFlag, override: FlagOverride) +} + +/** Persistence for the last valid remote configuration. */ +interface RemoteFlagConfigStore { + /** The cached config, already validated at save time, or null. */ + fun cached(): RemoteFlagConfig? + + /** Cache a payload that already passed [parseRemoteFlagConfig]. */ + fun save(rawJson: String, fetchedAtMs: Long) + + /** Epoch ms of the last fetch attempt (success or failure), 0 if never. */ + fun lastAttemptMs(): Long + fun recordAttempt(nowMs: Long) +} + +/** + * Deterministic flag resolution. Precedence, highest first: + * + * 1. Debug developer override — debug builds only ([isDebugBuild]); a release + * build never consults the override store, so a leftover forced value can't + * leak into production behavior. + * 2. Cached remote configuration — only while the payload itself is fresh + * (its `expiresAt` has not passed) and it actually contains the flag. + * 3. Build-time default from BuildConfig. + * + * All inputs are injected so tests can drive every layer with a fixed clock. + */ +class DefaultFeatureFlagRepository( + private val isDebugBuild: Boolean, + private val buildDefaults: (FeatureFlag) -> Boolean, + private val overrideStore: FlagOverrideStore, + private val remoteConfigStore: RemoteFlagConfigStore, + private val clock: () -> Long = { System.currentTimeMillis() }, +) : FeatureFlagRepository { + + override fun isEnabled(flag: FeatureFlag): Boolean = evaluate(flag).enabled + + override fun evaluate(flag: FeatureFlag): FlagEvaluation { + if (isDebugBuild) { + when (overrideStore.overrideFor(flag)) { + FlagOverride.FORCED_ON -> + return FlagEvaluation(flag, true, FlagValueSource.DEBUG_OVERRIDE) + FlagOverride.FORCED_OFF -> + return FlagEvaluation(flag, false, FlagValueSource.DEBUG_OVERRIDE) + FlagOverride.DEFAULT -> Unit + } + } + val remote = remoteConfigStore.cached() + if (remote != null && remote.isFresh(clock())) { + val value = remote.valueFor(flag) + if (value != null) { + return FlagEvaluation(flag, value, FlagValueSource.REMOTE_CONFIG) + } + } + return FlagEvaluation(flag, buildDefaults(flag), FlagValueSource.BUILD_DEFAULT) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlags.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlags.kt new file mode 100644 index 000000000..392626fe6 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlags.kt @@ -0,0 +1,83 @@ +package radio.ks3ckc.ft8af.flags + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.k1af.ft8af.BuildConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Process-wide access point for feature flags, wired from BuildConfig + + * SharedPreferences in [init] (called from FT8AFApplication.onCreate, cheap + * and synchronous) and refreshed from the backend opportunistically off the + * main thread. Matches the app's manual-singleton pattern (no DI container). + * + * Before [init] runs (or in isolated unit tests) every flag resolves to its + * build-time default via a null-safe fallback, so nothing can crash from + * ordering. + */ +object FeatureFlags : FeatureFlagRepository { + @Volatile + private var delegate: FeatureFlagRepository? = null + + @Volatile + var overrideStore: FlagOverrideStore? = null + private set + + private var fetcher: RemoteFlagConfigFetcher? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** Build-time defaults: debug ships enabled, release ships disabled. */ + fun buildDefault(flag: FeatureFlag): Boolean = when (flag) { + FeatureFlag.BAND_ADVISOR -> BuildConfig.FEATURE_BAND_ADVISOR_DEFAULT + FeatureFlag.PERSONAL_PSK_ANALYTICS -> BuildConfig.FEATURE_PERSONAL_PSK_ANALYTICS_DEFAULT + FeatureFlag.PROPAGATION_ALERTS -> BuildConfig.FEATURE_PROPAGATION_ALERTS_DEFAULT + } + + fun init(context: Context) { + val appContext = context.applicationContext + val overrides = SharedPrefsFlagOverrideStore(appContext) + val remoteStore = SharedPrefsRemoteFlagConfigStore(appContext) + overrideStore = overrides + delegate = DefaultFeatureFlagRepository( + isDebugBuild = BuildConfig.DEBUG, + buildDefaults = ::buildDefault, + overrideStore = overrides, + remoteConfigStore = remoteStore, + ) + fetcher = RemoteFlagConfigFetcher(remoteStore) + // The remote refresh is NOT kicked here: Application.onCreate also runs + // under Robolectric for every unit test, and startup must never depend + // on (or even attempt) network. ComposeMainActivity triggers + // [refreshRemoteConfigAsync] when the real UI comes up. + } + + /** + * Kick a background refresh of the remote configuration if one is due. + * Never blocks, never throws into the caller. + */ + fun refreshRemoteConfigAsync() { + val f = fetcher ?: return + scope.launch { + try { + f.refreshIfDue() + } catch (_: Exception) { + // Remote config must never interfere with normal operation. + } + } + } + + override fun isEnabled(flag: FeatureFlag): Boolean = + delegate?.isEnabled(flag) ?: buildDefault(flag) + + override fun evaluate(flag: FeatureFlag): FlagEvaluation = + delegate?.evaluate(flag) + ?: FlagEvaluation(flag, buildDefault(flag), FlagValueSource.BUILD_DEFAULT) + + @VisibleForTesting + internal fun setDelegateForTests(repository: FeatureFlagRepository?) { + delegate = repository + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfig.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfig.kt new file mode 100644 index 000000000..43274f18b --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfig.kt @@ -0,0 +1,88 @@ +package radio.ks3ckc.ft8af.flags + +import org.json.JSONObject +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * A validated remote feature-configuration payload: + * + * ```json + * { + * "version": 1, + * "generatedAt": "2026-09-27T12:00:00Z", + * "expiresAt": "2026-09-27T18:00:00Z", + * "flags": { "bandAdvisor": true, "personalPskAnalytics": false } + * } + * ``` + * + * Only entries whose value is a strict boolean are kept; unknown flag names are + * carried but simply never looked up, and flags missing from [flags] fall back + * to their build-time default in the repository. A payload that fails + * validation is rejected as a whole (never partially applied or cached). + */ +data class RemoteFlagConfig( + val version: Int, + val generatedAtMs: Long, + val expiresAtMs: Long, + val flags: Map, +) { + /** True while the payload's own expiry has not passed. */ + fun isFresh(nowMs: Long): Boolean = nowMs < expiresAtMs + + /** The remote value for [flag], or null when the payload omits it. */ + fun valueFor(flag: FeatureFlag): Boolean? = flags[flag.remoteKey] +} + +/** The schema version this client understands. Higher versions are rejected. */ +const val REMOTE_FLAG_CONFIG_VERSION = 1 + +/** + * Parse + validate a remote feature-configuration JSON document. Returns null + * for anything malformed: bad JSON, wrong/missing version, unparseable + * timestamps, expiry not after generation, or a missing `flags` object. + * Non-boolean flag values are dropped (not coerced); unknown names are ignored + * by lookup. Never throws. + */ +fun parseRemoteFlagConfig(json: String): RemoteFlagConfig? { + return try { + val root = JSONObject(json) + val version = root.optInt("version", -1) + if (version < 1 || version > REMOTE_FLAG_CONFIG_VERSION) return null + val generatedAtMs = parseIso8601UtcMs(root.optString("generatedAt")) ?: return null + val expiresAtMs = parseIso8601UtcMs(root.optString("expiresAt")) ?: return null + if (expiresAtMs <= generatedAtMs) return null + val flagsObj = root.optJSONObject("flags") ?: return null + val flags = mutableMapOf() + for (key in flagsObj.keys()) { + val value = flagsObj.opt(key) + if (value is Boolean) flags[key] = value + } + RemoteFlagConfig(version, generatedAtMs, expiresAtMs, flags) + } catch (_: Exception) { + null + } +} + +/** + * Parse an ISO-8601 UTC timestamp ("2026-09-27T12:00:00Z", optionally with + * fractional seconds) to epoch millis, or null if it doesn't match. Uses + * SimpleDateFormat rather than java.time because minSdk is 23 (java.time needs + * API 26 and this project does not enable core-library desugaring). + */ +internal fun parseIso8601UtcMs(raw: String?): Long? { + if (raw.isNullOrBlank()) return null + // Strip fractional seconds ("...:00.123Z" -> "...:00Z") so one pattern covers both. + val normalized = raw.replace(Regex("""\.\d+Z$"""), "Z") + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + val position = ParsePosition(0) + val date = format.parse(normalized, position) ?: return null + // Reject trailing garbage ("2026-09-27T12:00:00Zjunk"). + if (position.index != normalized.length) return null + return date.time +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcher.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcher.kt new file mode 100644 index 000000000..5f87907fc --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcher.kt @@ -0,0 +1,88 @@ +package radio.ks3ckc.ft8af.flags + +import android.util.Log +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets + +/** Re-attempt spacing while a fresh cache exists (or after a failed attempt). */ +internal const val REMOTE_FLAG_MIN_ATTEMPT_INTERVAL_MS = 6L * 60 * 60 * 1000 + +/** + * Whether a fetch attempt is worthwhile now. Pure so tests can drive it: + * fetch when there is no valid cache or the cache has expired — but never + * more often than [REMOTE_FLAG_MIN_ATTEMPT_INTERVAL_MS] between attempts, so + * an unreachable/broken endpoint is not hammered on every app start. + */ +internal fun shouldFetchRemoteFlagConfig( + cached: RemoteFlagConfig?, + lastAttemptMs: Long, + nowMs: Long, +): Boolean { + if (lastAttemptMs != 0L && nowMs - lastAttemptMs < REMOTE_FLAG_MIN_ATTEMPT_INTERVAL_MS) { + return false + } + return cached == null || !cached.isFresh(nowMs) +} + +/** + * Fetches the versioned feature-configuration document from the ft8af.app + * backend and caches it when valid. Fire-and-forget: every failure mode + * (offline, HTTP error, invalid payload) leaves the existing cache untouched + * and simply records the attempt. Nothing here may ever block startup or + * surface an error into FT8 operation — callers launch it on a background + * coroutine and ignore the result. + */ +class RemoteFlagConfigFetcher( + private val store: RemoteFlagConfigStore, + private val clock: () -> Long = { System.currentTimeMillis() }, + private val baseUrl: String = DEFAULT_URL, +) { + suspend fun refreshIfDue() = withContext(Dispatchers.IO) { + val now = clock() + if (!shouldFetchRemoteFlagConfig(store.cached(), store.lastAttemptMs(), now)) return@withContext + store.recordAttempt(now) + val body = fetch() ?: return@withContext + if (parseRemoteFlagConfig(body) == null) { + Log.w(TAG, "rejected invalid feature-config payload") + return@withContext + } + store.save(body, now) + Log.d(TAG, "cached remote feature config") + } + + private fun fetch(): String? { + var conn: HttpURLConnection? = null + return try { + conn = (URL(baseUrl).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = IO_TIMEOUT_MS + readTimeout = IO_TIMEOUT_MS + setRequestProperty("Accept", "application/json") + } + if (conn.responseCode != HttpURLConnection.HTTP_OK) { + Log.d(TAG, "feature-config http ${conn.responseCode}") + null + } else { + conn.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + } + } catch (e: IOException) { + Log.d(TAG, "feature-config fetch failed: ${e.javaClass.simpleName}") + null + } finally { + conn?.disconnect() + } + } + + companion object { + private const val TAG = "RemoteFlagConfig" + private const val IO_TIMEOUT_MS = 8000 + + @VisibleForTesting + internal const val DEFAULT_URL = "https://ft8af.app/api/feature-config" + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStores.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStores.kt new file mode 100644 index 000000000..6a66018d3 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStores.kt @@ -0,0 +1,63 @@ +package radio.ks3ckc.ft8af.flags + +import android.content.Context +import android.content.SharedPreferences + +// SharedPreferences (not the SQLite config table) so flags resolve +// synchronously at startup, before DatabaseOpr's async hydration — the same +// reason ThemePreference lives in prefs. See theme/ThemePreference.kt. +private const val PREFS_NAME = "ft8af_feature_flags" +private const val KEY_OVERRIDE_PREFIX = "override_" +private const val KEY_REMOTE_JSON = "remote_config_json" +private const val KEY_REMOTE_FETCHED_AT = "remote_config_fetched_at" +private const val KEY_REMOTE_LAST_ATTEMPT = "remote_config_last_attempt" + +private fun prefs(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + +/** Developer tri-state overrides, persisted per flag. Debug builds only. */ +class SharedPrefsFlagOverrideStore(context: Context) : FlagOverrideStore { + private val prefs = prefs(context.applicationContext) + + override fun overrideFor(flag: FeatureFlag): FlagOverride = + FlagOverride.fromId(prefs.getString(KEY_OVERRIDE_PREFIX + flag.name, null)) + + override fun setOverride(flag: FeatureFlag, override: FlagOverride) { + prefs.edit().apply { + if (override == FlagOverride.DEFAULT) { + remove(KEY_OVERRIDE_PREFIX + flag.name) + } else { + putString(KEY_OVERRIDE_PREFIX + flag.name, override.id) + } + }.apply() + } +} + +/** + * Caches the raw JSON of the last payload that passed validation, re-parsing + * on read (cheap, and keeps a single source of truth for the schema). + */ +class SharedPrefsRemoteFlagConfigStore(context: Context) : RemoteFlagConfigStore { + private val prefs = prefs(context.applicationContext) + + override fun cached(): RemoteFlagConfig? { + val json = prefs.getString(KEY_REMOTE_JSON, null) ?: return null + return parseRemoteFlagConfig(json) + } + + override fun save(rawJson: String, fetchedAtMs: Long) { + // Never cache an invalid payload — a corrupt save would otherwise shadow + // a previously good one until the next successful fetch. + if (parseRemoteFlagConfig(rawJson) == null) return + prefs.edit() + .putString(KEY_REMOTE_JSON, rawJson) + .putLong(KEY_REMOTE_FETCHED_AT, fetchedAtMs) + .apply() + } + + override fun lastAttemptMs(): Long = prefs.getLong(KEY_REMOTE_LAST_ATTEMPT, 0L) + + override fun recordAttempt(nowMs: Long) { + prefs.edit().putLong(KEY_REMOTE_LAST_ATTEMPT, nowMs).apply() + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorCard.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorCard.kt new file mode 100644 index 000000000..d430d3587 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorCard.kt @@ -0,0 +1,188 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.theme.Accent +import radio.ks3ckc.ft8af.theme.BgSurface3 +import radio.ks3ckc.ft8af.theme.Border +import radio.ks3ckc.ft8af.theme.GeistMonoFamily +import radio.ks3ckc.ft8af.theme.InterFamily +import radio.ks3ckc.ft8af.theme.StatusWarn +import radio.ks3ckc.ft8af.theme.TextMuted +import radio.ks3ckc.ft8af.theme.TextPrimary +import radio.ks3ckc.ft8af.ui.components.formatMhz + +/** + * The compact Band Advisor card shown at the top of the Band & Mode sheet. + * + * Collapsed contract (see docs/band-advisor.md): + * Best band now: 20m + * Excellent • 87% confidence + * Europe and South America are open + * [Switch to 20m] (or "Set radio to 14.074 MHz" when CAT is unavailable) + * + * Tapping the card body opens the detail sheet; only the explicit button + * tunes. Renders nothing in the Hidden state (flag off) — callers may also + * skip it entirely. + */ +@Composable +fun BandAdvisorCard( + state: BandAdvisorUiState, + catAvailable: Boolean, + onOpenDetails: () -> Unit, + onTune: (frequencyHz: Long) -> Unit, + modifier: Modifier = Modifier, +) { + if (state is BandAdvisorUiState.Hidden) return + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .clickable { onOpenDetails() } + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.band_advisor_card_title), + color = TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.sp, + ) + if (state is BandAdvisorUiState.Ready && state.freshness != Freshness.FRESH) { + Text( + text = stringResource( + if (state.freshness == Freshness.STALE) R.string.band_advisor_stale + else R.string.band_advisor_expired, + ), + color = StatusWarn, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + } + } + + when (state) { + is BandAdvisorUiState.Ready -> { + val rec = state.recommendation + Text( + text = stringResource(R.string.band_advisor_best_band, rec.recommendedBand), + color = TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + fontFamily = InterFamily, + ) + Text( + text = stringResource( + R.string.band_advisor_rating_confidence, + stringResource(ratingLabelRes(rec.score)), + (rec.confidence * 100).toInt(), + ), + color = Accent, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + if (rec.destinations.isNotEmpty()) { + Text( + text = stringResource( + R.string.band_advisor_destinations_open, + rec.destinations.joinToString(", "), + ), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + TuneButton( + band = rec.recommendedBand, + frequencyHz = rec.recommendedFrequencyHz, + catAvailable = catAvailable, + onTune = { onTune(rec.recommendedFrequencyHz) }, + ) + } + is BandAdvisorUiState.Loading -> CardBodyLine(stringResource(R.string.band_advisor_loading)) + is BandAdvisorUiState.NoGrid -> CardBodyLine(stringResource(R.string.band_advisor_no_grid)) + is BandAdvisorUiState.Unavailable -> + CardBodyLine(stringResource(R.string.band_advisor_unavailable_short)) + is BandAdvisorUiState.Hidden -> Unit + } + } +} + +@Composable +private fun CardBodyLine(text: String) { + Text( + text = text, + color = TextMuted, + fontSize = 13.sp, + fontFamily = InterFamily, + modifier = Modifier.padding(vertical = 4.dp), + ) +} + +/** + * The tuning affordance. With CAT it is an action ("Switch to 20m"); without, + * it degrades to the dial the operator should set by hand — still tappable so + * the app's own band state follows the recommendation. + */ +@Composable +internal fun TuneButton( + band: String, + frequencyHz: Long, + catAvailable: Boolean, + onTune: () -> Unit, +) { + val label = if (catAvailable) { + stringResource(R.string.band_advisor_switch_to, band) + } else { + stringResource(R.string.band_advisor_set_radio_to, formatMhz(frequencyHz)) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Accent.copy(alpha = 0.16f)) + .clickable { onTune() } + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + text = label, + color = Accent, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorSheet.kt new file mode 100644 index 000000000..da3916d4f --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorSheet.kt @@ -0,0 +1,673 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.bandadvisor.UnavailableReason +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion +import radio.ks3ckc.ft8af.bandadvisor.model.BandRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.EvidenceType +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.bandadvisor.model.PersonalAnalyticsSummary +import radio.ks3ckc.ft8af.theme.Accent +import radio.ks3ckc.ft8af.theme.BgSurface +import radio.ks3ckc.ft8af.theme.BgSurface3 +import radio.ks3ckc.ft8af.theme.Border +import radio.ks3ckc.ft8af.theme.GeistMonoFamily +import radio.ks3ckc.ft8af.theme.InterFamily +import radio.ks3ckc.ft8af.theme.StatusWarn +import radio.ks3ckc.ft8af.theme.TextFaint +import radio.ks3ckc.ft8af.theme.TextMuted +import radio.ks3ckc.ft8af.theme.TextPrimary +import radio.ks3ckc.ft8af.ui.components.FT8AFBottomSheet +import radio.ks3ckc.ft8af.ui.components.formatMhz + +/** Goal labels for the selector chips. */ +internal fun goalLabelRes(goal: OperatingGoal): Int = when (goal) { + OperatingGoal.MAKE_CONTACT -> R.string.band_advisor_goal_contact + OperatingGoal.DX -> R.string.band_advisor_goal_dx + OperatingGoal.TARGET -> R.string.band_advisor_goal_target + OperatingGoal.POTA -> R.string.band_advisor_goal_pota +} + +internal fun targetRegionLabelRes(region: AdvisorTargetRegion): Int = + when (region) { + AdvisorTargetRegion.EUROPE -> R.string.band_advisor_region_europe + AdvisorTargetRegion.NORTH_AMERICA_EAST -> R.string.band_advisor_region_na_east + AdvisorTargetRegion.NORTH_AMERICA_WEST -> R.string.band_advisor_region_na_west + AdvisorTargetRegion.SOUTH_AMERICA -> R.string.band_advisor_region_south_america + AdvisorTargetRegion.AFRICA -> R.string.band_advisor_region_africa + AdvisorTargetRegion.ASIA -> R.string.band_advisor_region_asia + AdvisorTargetRegion.OCEANIA -> R.string.band_advisor_region_oceania + } + +/** + * Band Advisor detail sheet: recommendation + dial + confidence, goal chips, + * destinations, freshness, expandable evidence ("Why this band"), alternatives + * with their tune buttons, the personal PSK Reporter panel (flag-gated by the + * caller passing null), and explicit partial/stale/offline states. The UI + * leads with the recommendation; the propagation detail hides behind the + * expandable explanation. + */ +@Composable +fun BandAdvisorSheet( + visible: Boolean, + state: BandAdvisorUiState, + goal: OperatingGoal, + targetRegion: AdvisorTargetRegion, + onSelectTargetRegion: (AdvisorTargetRegion) -> Unit, + catAvailable: Boolean, + nowMs: Long, + /** Null hides the personal section entirely (flag off / no callsign). */ + personalPanel: PersonalPanelState?, + onDismiss: () -> Unit, + onSelectGoal: (OperatingGoal) -> Unit, + onRefresh: () -> Unit, + onTune: (frequencyHz: Long) -> Unit, +) { + FT8AFBottomSheet(visible = visible, onDismiss = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // ---- Header ---- + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.band_advisor_title), + color = TextPrimary, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + Text( + text = stringResource(R.string.band_advisor_refresh), + color = Accent, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onRefresh() } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + + // ---- Goal selector ---- + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(BgSurface) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + for (g in OperatingGoal.entries) { + GoalChip( + label = stringResource(goalLabelRes(g)), + selected = g == goal, + modifier = Modifier.weight(1f), + onClick = { onSelectGoal(g) }, + ) + } + } + + if (goal == OperatingGoal.TARGET) { + var expanded by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { expanded = true }) { + Text(stringResource(targetRegionLabelRes(targetRegion)), color = Accent) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for (region in AdvisorTargetRegion.entries) { + DropdownMenuItem( + text = { Text(stringResource(targetRegionLabelRes(region))) }, + onClick = { + expanded = false + onSelectTargetRegion(region) + }, + ) + } + } + } + } + + when (state) { + is BandAdvisorUiState.Ready -> ReadyBody( + recommendation = state.recommendation, + freshness = state.freshness, + catAvailable = catAvailable, + nowMs = nowMs, + onTune = onTune, + ) + is BandAdvisorUiState.Loading -> StateLine( + stringResource(R.string.band_advisor_loading), + ) + is BandAdvisorUiState.NoGrid -> StateLine( + stringResource(R.string.band_advisor_no_grid_detail), + ) + is BandAdvisorUiState.Unavailable -> StateLine( + when (state.reason) { + UnavailableReason.OFFLINE -> + stringResource(R.string.band_advisor_offline) + UnavailableReason.RATE_LIMITED -> + stringResource(R.string.band_advisor_rate_limited) + else -> stringResource(R.string.band_advisor_unavailable) + }, + ) + is BandAdvisorUiState.Hidden -> Unit + } + + // ---- Personal PSK Reporter analytics ---- + if (personalPanel != null) { + PersonalAnalyticsPanel(panel = personalPanel, nowMs = nowMs) + } + } + } +} + +@Composable +private fun ReadyBody( + recommendation: BandRecommendation, + freshness: Freshness, + catAvailable: Boolean, + nowMs: Long, + onTune: (Long) -> Unit, +) { + val rec = recommendation + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // ---- Recommendation ---- + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Accent.copy(alpha = 0.08f)) + .border(1.dp, Accent.copy(alpha = 0.25f), RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = rec.recommendedBand, + color = Accent, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + ) + Text( + text = "${formatMhz(rec.recommendedFrequencyHz)} MHz", + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + } + Text( + text = stringResource( + R.string.band_advisor_rating_confidence, + stringResource(ratingLabelRes(rec.score)), + (rec.confidence * 100).toInt(), + ), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + if (rec.summary.isNotBlank()) { + Text( + text = rec.summary, + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + } + if (rec.destinations.isNotEmpty()) { + Text( + text = stringResource( + R.string.band_advisor_destinations_open, + rec.destinations.joinToString(", "), + ), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + TuneButton( + band = rec.recommendedBand, + frequencyHz = rec.recommendedFrequencyHz, + catAvailable = catAvailable, + onTune = { onTune(rec.recommendedFrequencyHz) }, + ) + } + + // ---- Freshness / partial-data status ---- + val minutes = ageMinutes(rec.generatedAtMs, nowMs) + val freshnessText = when (freshness) { + Freshness.FRESH -> stringResource(R.string.band_advisor_updated_min, minutes) + Freshness.STALE -> stringResource(R.string.band_advisor_stale_min, minutes) + Freshness.EXPIRED -> stringResource(R.string.band_advisor_expired_detail) + } + Text( + text = freshnessText, + color = if (freshness == Freshness.FRESH) TextFaint else StatusWarn, + fontSize = 11.sp, + fontFamily = InterFamily, + ) + if (rec.sources.isPartial) { + val missing = buildList { + if (!rec.sources.voacapAvailable) add(stringResource(R.string.band_advisor_src_voacap)) + if (!rec.sources.regionalPskReporterAvailable) { + add(stringResource(R.string.band_advisor_src_regional)) + } + } + if (missing.isNotEmpty()) { + Text( + text = stringResource( + R.string.band_advisor_partial_sources, + missing.joinToString(", "), + ), + color = StatusWarn, + fontSize = 11.sp, + fontFamily = InterFamily, + ) + } + } + + // ---- Why this band (expandable evidence + score components) ---- + var showWhy by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { showWhy = !showWhy } + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.band_advisor_why), + color = TextMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (showWhy) "△" else "▽", + color = TextFaint, + fontSize = 11.sp, + ) + } + if (showWhy) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (evidence in rec.evidence) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = when (evidence.type) { + EvidenceType.VOACAP -> + stringResource(R.string.band_advisor_ev_voacap) + EvidenceType.REGIONAL_PSK_REPORTER -> + stringResource(R.string.band_advisor_ev_regional) + EvidenceType.PERSONAL_PSK_REPORTER -> + stringResource(R.string.band_advisor_ev_personal) + EvidenceType.OTHER -> "" + }, + color = Accent, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + modifier = Modifier.width(76.dp), + ) + Text( + text = evidence.message, + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + modifier = Modifier.weight(1f), + ) + } + } + if (rec.scoreComponents.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + for (component in rec.scoreComponents) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = component.name, + color = TextFaint, + fontSize = 10.sp, + fontFamily = GeistMonoFamily, + ) + Text( + text = String.format( + java.util.Locale.US, + "%.2f × %.2f = %.2f", + component.value, component.weight, component.contribution, + ), + color = TextFaint, + fontSize = 10.sp, + fontFamily = GeistMonoFamily, + ) + } + } + } + } + } + + // ---- Alternatives ---- + if (rec.alternatives.isNotEmpty()) { + Text( + text = stringResource(R.string.band_advisor_alternatives), + color = TextMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + for (alt in rec.alternatives) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .clickable { onTune(alt.frequencyHz) } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = alt.band, + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = GeistMonoFamily, + modifier = Modifier.width(42.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${formatMhz(alt.frequencyHz)} MHz", + color = TextMuted, + fontSize = 12.sp, + fontFamily = GeistMonoFamily, + ) + if (alt.reason.isNotBlank()) { + Text( + text = alt.reason, + color = TextFaint, + fontSize = 11.sp, + fontFamily = InterFamily, + ) + } + } + Text( + text = "${(alt.score * 100).toInt()}", + color = TextFaint, + fontSize = 12.sp, + fontFamily = GeistMonoFamily, + ) + } + } + } + } +} + +@Composable +private fun StateLine(text: String) { + Text( + text = text, + color = TextMuted, + fontSize = 13.sp, + fontFamily = InterFamily, + modifier = Modifier.padding(vertical = 12.dp), + ) +} + +@Composable +private fun GoalChip( + label: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Box( + modifier = modifier + .height(34.dp) + .clip(RoundedCornerShape(7.dp)) + .background(if (selected) Accent.copy(alpha = 0.18f) else androidx.compose.ui.graphics.Color.Transparent) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (selected) Accent else TextMuted, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + fontFamily = InterFamily, + ) + } +} + +// --------------------------------------------------------------------------- +// Personal PSK Reporter panel +// --------------------------------------------------------------------------- + +/** State for the personal analytics panel. */ +sealed interface PersonalPanelState { + object Loading : PersonalPanelState + + /** No recent reports — the honest empty state. */ + object Empty : PersonalPanelState + + object Error : PersonalPanelState + + data class Ready( + val reports: Int, + val uniqueReceivers: Int, + val uniqueGridFields: Int, + val countriesReached: Int, + val maxDistanceKm: Int, + val medianDistanceKm: Int, + val bestSnrDb: Int?, + val medianSnrDb: Int?, + val directionOctants: Int, + val mostRecentReportEpochSec: Long?, + /** Null = suppressed (small sample) — never shown as zero. */ + val vsBaseline: Double?, + ) : PersonalPanelState +} + +/** Build panel state from a server-provided summary (detail payload). Pure. */ +internal fun personalPanelFromSummary(summary: PersonalAnalyticsSummary?): PersonalPanelState? { + summary ?: return null + if (!summary.enabled) return null + if (summary.reportsReceived == 0) return PersonalPanelState.Empty + return PersonalPanelState.Ready( + reports = summary.reportsReceived, + uniqueReceivers = summary.uniqueReceivers, + uniqueGridFields = 0, + countriesReached = summary.countriesReached, + maxDistanceKm = summary.maximumDistanceKm, + medianDistanceKm = 0, + bestSnrDb = summary.bestSnrDb, + medianSnrDb = summary.medianSnrDb, + directionOctants = 0, + mostRecentReportEpochSec = null, + vsBaseline = summary.performanceComparedToBaseline, + ) +} + +/** + * "Your signal" panel. Every line is explicit that these are stations that + * HEARD the operator (reception reports), never contacts. The baseline + * comparison renders only when the sample was big enough for the computation + * to produce one (small samples arrive as null and show nothing). + */ +@Composable +internal fun PersonalAnalyticsPanel(panel: PersonalPanelState, nowMs: Long) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(BgSurface3) + .border(1.dp, Border, RoundedCornerShape(12.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(R.string.band_advisor_personal_title), + color = TextMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + letterSpacing = 0.08.sp, + ) + when (panel) { + is PersonalPanelState.Loading -> Text( + text = stringResource(R.string.band_advisor_personal_loading), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + is PersonalPanelState.Empty -> Text( + text = stringResource(R.string.band_advisor_personal_empty), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + is PersonalPanelState.Error -> Text( + text = stringResource(R.string.band_advisor_personal_error), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + is PersonalPanelState.Ready -> { + Text( + text = stringResource( + R.string.band_advisor_personal_heard_by, + panel.uniqueReceivers, + panel.reports, + ), + color = TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + StatRow( + label = stringResource(R.string.band_advisor_personal_reach), + value = if (panel.countriesReached > 0) { + stringResource( + R.string.band_advisor_personal_reach_value, + panel.countriesReached, + panel.maxDistanceKm, + ) + } else { + stringResource( + R.string.band_advisor_personal_reach_km_only, + panel.maxDistanceKm, + ) + }, + ) + if (panel.bestSnrDb != null && panel.medianSnrDb != null) { + StatRow( + label = stringResource(R.string.band_advisor_personal_snr), + value = stringResource( + R.string.band_advisor_personal_snr_value, + panel.medianSnrDb, + panel.bestSnrDb, + ), + ) + } + if (panel.vsBaseline != null) { + val pct = (panel.vsBaseline * 100).toInt() + StatRow( + label = stringResource(R.string.band_advisor_personal_vs_baseline), + value = if (pct >= 0) "+$pct%" else "$pct%", + ) + } + if (panel.mostRecentReportEpochSec != null) { + val minutes = ageMinutes(panel.mostRecentReportEpochSec * 1000, nowMs) + Text( + text = stringResource(R.string.band_advisor_personal_last_report, minutes), + color = TextFaint, + fontSize = 11.sp, + fontFamily = InterFamily, + ) + } + Text( + text = stringResource(R.string.band_advisor_personal_disclaimer), + color = TextFaint, + fontSize = 10.sp, + fontFamily = InterFamily, + ) + } + } + } +} + +@Composable +private fun StatRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + Text( + text = value, + color = TextPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = GeistMonoFamily, + ) + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorState.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorState.kt new file mode 100644 index 000000000..b469ff1ae --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorState.kt @@ -0,0 +1,233 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.R +import com.k1af.ft8af.database.OperationBand +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import radio.ks3ckc.ft8af.bandadvisor.AdvisorRequest +import radio.ks3ckc.ft8af.bandadvisor.AdvisorResult +import radio.ks3ckc.ft8af.bandadvisor.BandAdvisor +import radio.ks3ckc.ft8af.bandadvisor.UnavailableReason +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion +import radio.ks3ckc.ft8af.bandadvisor.model.BandRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.bandadvisor.model.isPlausibleCallsign +import radio.ks3ckc.ft8af.bandadvisor.recommendationMatchesRequest +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlags + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested without Compose) +// --------------------------------------------------------------------------- + +/** UI state for the Band Advisor card + detail sheet. */ +sealed interface BandAdvisorUiState { + /** Feature flag off — render nothing, request nothing. */ + object Hidden : BandAdvisorUiState + + /** No valid grid configured; the advisor cannot ask for a recommendation. */ + object NoGrid : BandAdvisorUiState + + object Loading : BandAdvisorUiState + + data class Ready( + val recommendation: BandRecommendation, + val freshness: Freshness, + val fromCache: Boolean, + ) : BandAdvisorUiState + + data class Unavailable(val reason: UnavailableReason) : BandAdvisorUiState +} + +/** Map a repository result to UI state. Pure. */ +internal fun uiStateOf(result: AdvisorResult): BandAdvisorUiState = when (result) { + is AdvisorResult.Available -> + BandAdvisorUiState.Ready(result.recommendation, result.freshness, result.fromCache) + is AdvisorResult.Unavailable -> when (result.reason) { + UnavailableReason.NO_GRID -> BandAdvisorUiState.NoGrid + else -> BandAdvisorUiState.Unavailable(result.reason) + } +} + +/** + * Build the advisor request from operator settings. The callsign rides along + * only when the personal-analytics flag is on AND it is structurally valid — + * otherwise the request stays anonymous (regional data only). + */ +internal fun advisorRequestFrom( + grid: String?, + callsign: String?, + personalAnalyticsEnabled: Boolean, + goal: OperatingGoal, + mode: String, + targetRegion: AdvisorTargetRegion = AdvisorTargetRegion.EUROPE, +): AdvisorRequest = AdvisorRequest( + grid = grid, + callsign = callsign?.takeIf { personalAnalyticsEnabled && isPlausibleCallsign(it) }, + goal = goal, + mode = mode, + targetRegion = targetRegion.name.takeIf { goal == OperatingGoal.TARGET }, +) + +/** + * The bandList index to tune for a recommended dial, or null when that exact + * dial is not in the band plan for the active mode — the advisor NEVER invents + * dial entries; an unknown frequency downgrades to "tune manually" in the UI. + * (OperationBand.getIndexByFreq is deliberately not used: it appends unknown + * frequencies to the table as a side effect.) + */ +internal fun advisorTuneIndexFor( + bands: List, + frequencyHz: Long, + modeId: Int, +): Int? { + for (i in bands.indices) { + val b = bands[i] + if (b.band == frequencyHz && b.mode == modeId) return i + } + return null +} + +/** Rating bucket for the collapsed card ("Excellent • 87% confidence"). */ +internal fun ratingLabelRes(score: Double): Int = when { + score >= 0.75 -> R.string.band_advisor_rating_excellent + score >= 0.55 -> R.string.band_advisor_rating_good + score >= 0.35 -> R.string.band_advisor_rating_fair + else -> R.string.band_advisor_rating_poor +} + +/** Whole minutes since the recommendation was generated (>= 0). */ +internal fun ageMinutes(generatedAtMs: Long, nowMs: Long): Long = + ((nowMs - generatedAtMs).coerceAtLeast(0)) / 60_000 + +// --------------------------------------------------------------------------- +// State holder (thin; all decisions above are pure) +// --------------------------------------------------------------------------- + +/** + * Loads recommendations for the UI. One instance lives in FT8AFApp; the card + * and the detail sheet render [state]. All failures end as UI states — nothing + * from here can disturb decode/TX paths. + */ +class BandAdvisorStateHolder(private val appContext: Context) { + var state by mutableStateOf(BandAdvisorUiState.Loading) + private set + + var goal by mutableStateOf(BandAdvisor.goal(appContext)) + private set + + var targetRegion by mutableStateOf(BandAdvisor.targetRegion(appContext)) + private set + + fun setTargetRegion( + scope: CoroutineScope, + region: AdvisorTargetRegion, + ) { + if (region == targetRegion) return + targetRegion = region + BandAdvisor.setTargetRegion(appContext, region) + if (goal == OperatingGoal.TARGET) load(scope, force = true) + } + + /** Null while the personal flag is off or no callsign is configured. */ + var personalPanel by mutableStateOf(null) + private set + + private var inFlight: Job? = null + private var personalInFlight: Job? = null + + fun setGoal(scope: CoroutineScope, newGoal: OperatingGoal) { + if (newGoal == goal) return + goal = newGoal + BandAdvisor.setGoal(appContext, newGoal) + BandAdvisorTelemetry.event("goal_selected", newGoal.wireName) + load(scope, force = true) + } + + /** Fetch a recommendation. No-op while the flag is off. */ + fun load(scope: CoroutineScope, force: Boolean) { + if (!FeatureFlags.isEnabled(FeatureFlag.BAND_ADVISOR)) { + state = BandAdvisorUiState.Hidden + return + } + if (inFlight?.isActive == true && !force) return + if (force) inFlight?.cancel() + val request = advisorRequestFrom( + grid = GeneralVariables.getMyMaidenheadGrid(), + callsign = GeneralVariables.myCallsign, + personalAnalyticsEnabled = + FeatureFlags.isEnabled(FeatureFlag.PERSONAL_PSK_ANALYTICS), + goal = goal, + targetRegion = targetRegion, + mode = GeneralVariables.currentMode().displayName, + ) + // Show the cache instantly (if any) while the refresh runs. + val cached = + BandAdvisor.repository(appContext).cached()?.takeIf { + recommendationMatchesRequest(it.recommendation, request.grid?.take(4).orEmpty(), request) + } + state = if (cached != null) uiStateOf(cached) else BandAdvisorUiState.Loading + if (force) BandAdvisorTelemetry.event("manual_refresh", null) + inFlight = scope.launch { + // Repository contract says it never throws; belt-and-braces so a + // bug here can never take down the app. + val result = runCatching { + BandAdvisor.repository(appContext).recommendation(request, force) + }.getOrElse { AdvisorResult.Unavailable(UnavailableReason.OFFLINE, it.message) } + coroutineContext.ensureActive() + state = uiStateOf(result) + when (val s = state) { + is BandAdvisorUiState.Ready -> + BandAdvisorTelemetry.event( + "recommendation_loaded", + "${s.recommendation.recommendedBand} ${s.freshness}", + ) + is BandAdvisorUiState.Unavailable -> + BandAdvisorTelemetry.event("recommendation_unavailable", s.reason.name) + else -> Unit + } + } + } + + /** + * Refresh the personal PSK Reporter panel. No-op unless the personal flag + * is on AND a plausible callsign is configured (the panel disappears + * entirely otherwise, per the flag contract). PskReporterClient's own + * cooldown prevents refresh-spamming from reaching the service. + */ + fun loadPersonal(scope: CoroutineScope) { + if (!FeatureFlags.isEnabled(FeatureFlag.BAND_ADVISOR) || + !FeatureFlags.isEnabled(FeatureFlag.PERSONAL_PSK_ANALYTICS) || + !isPlausibleCallsign(GeneralVariables.myCallsign) + ) { + personalPanel = null + return + } + if (personalInFlight?.isActive == true) return + if (personalPanel == null) personalPanel = PersonalPanelState.Loading + personalInFlight = scope.launch { + val loaded = runCatching { + loadPersonalPanel( + context = appContext, + callsign = GeneralVariables.myCallsign, + grid = GeneralVariables.getMyMaidenheadGrid(), + ) + }.getOrElse { PersonalPanelState.Error } + // Null = cooldown/transport skip: keep showing the previous data + // rather than flashing an error over a perfectly good panel. + if (loaded != null) { + personalPanel = loaded + } else if (personalPanel is PersonalPanelState.Loading) { + personalPanel = PersonalPanelState.Error + } + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTelemetry.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTelemetry.kt new file mode 100644 index 000000000..2f3c523fc --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTelemetry.kt @@ -0,0 +1,34 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import android.util.Log +import com.k1af.ft8af.GeneralVariables +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Local-only usage telemetry for Band Advisor. The app has no remote + * analytics backend; events go to logcat + the on-device debug.log (the + * project's established diagnostic channel) so testers can report what they + * did. Never logs decoded message contents, GPS coordinates, or tokens — + * only coarse event names and band/goal labels. + */ +object BandAdvisorTelemetry { + private const val TAG = "BandAdvisor" + + fun event(name: String, detail: String?) { + val line = if (detail.isNullOrBlank()) name else "$name: $detail" + Log.d(TAG, line) + try { + val ctx = GeneralVariables.getMainContext() ?: return + val dir = ctx.getExternalFilesDir(null) ?: return + val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date()) + FileWriter(File(dir, "debug.log"), true).use { + it.append("$ts BandAdvisor: $line\n") + } + } catch (_: Exception) { + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTuning.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTuning.kt new file mode 100644 index 000000000..000a449fc --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorTuning.kt @@ -0,0 +1,46 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import android.content.Context +import android.widget.Toast +import com.k1af.ft8af.GeneralVariables +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import com.k1af.ft8af.database.OperationBand +import radio.ks3ckc.ft8af.ui.components.formatMhz +import radio.ks3ckc.ft8af.ui.components.selectBandIndex + +/** + * One-tap tune to a recommended dial. Reuses [selectBandIndex] — the same + * path as every manual band pick — so all its guarantees apply unchanged: + * operator-dial protection (RigDialTarget), config persistence, per-band + * output level restore, decode clearing, and CAT push only when a CAT-capable + * control mode is active. This function NEVER starts a transmission and never + * schedules further tuning: one tap, one dial change, done. + * + * Returns true when the dial was applied. False = the frequency is not in the + * band plan for the current mode; the caller shows it for manual tuning and + * the app state is left untouched (the advisor never invents dial entries). + */ +fun tuneToAdvisorFrequency( + mainViewModel: MainViewModel, + context: Context, + frequencyHz: Long, +): Boolean { + val index = advisorTuneIndexFor( + bands = OperationBand.bandList, + frequencyHz = frequencyHz, + modeId = GeneralVariables.operatingMode, + ) + if (index == null) { + BandAdvisorTelemetry.event("tune_unsupported", frequencyHz.toString()) + Toast.makeText( + context, + context.getString(R.string.band_advisor_tune_manual, formatMhz(frequencyHz)), + Toast.LENGTH_LONG, + ).show() + return false + } + BandAdvisorTelemetry.event("tune", "$frequencyHz") + selectBandIndex(mainViewModel, context, index) + return true +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoader.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoader.kt new file mode 100644 index 000000000..a4488bd83 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoader.kt @@ -0,0 +1,94 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import android.content.Context +import com.k1af.ft8af.maidenhead.MaidenheadGrid +import com.k1af.ft8af.rigs.BaseRigOperation +import radio.ks3ckc.ft8af.bandadvisor.model.isPlausibleCallsign +import radio.ks3ckc.ft8af.bandadvisor.model.normalizeAdvisorGrid +import radio.ks3ckc.ft8af.bandadvisor.personal.MIN_REPORTS_FOR_COMPARISON +import radio.ks3ckc.ft8af.bandadvisor.personal.PersonalAnalyticsReport +import radio.ks3ckc.ft8af.bandadvisor.personal.PersonalAnalyticsStore +import radio.ks3ckc.ft8af.bandadvisor.personal.baselineComparison +import radio.ks3ckc.ft8af.bandadvisor.personal.computePersonalAnalytics +import radio.ks3ckc.ft8af.pskreporter.PskReporterClient + +/** How far back the personal query looks. PSK Reporter reports arrive with a + * few minutes of delay, so a short window would miss recent transmissions. */ +internal const val PERSONAL_LOOKBACK_SECONDS = 30 * 60 + +/** Map panel state from a locally computed report + baseline history. Pure. */ +internal fun personalPanelFromReport( + report: PersonalAnalyticsReport, + historyForBand: (String) -> List, +): PersonalPanelState { + val overall = report.overall + if (overall.reports == 0) return PersonalPanelState.Empty + // Baseline compares the busiest band of this sample against its own history. + val bestBand = report.perBand.maxByOrNull { it.value.reports } + val vsBaseline = bestBand?.let { (band, stats) -> + baselineComparison(stats.reports, stats.maxDistanceKm, historyForBand(band)) + } + return PersonalPanelState.Ready( + reports = overall.reports, + uniqueReceivers = overall.uniqueReceivers, + uniqueGridFields = overall.uniqueGridFields, + countriesReached = overall.countriesReached, + maxDistanceKm = overall.maxDistanceKm, + medianDistanceKm = overall.medianDistanceKm, + bestSnrDb = overall.bestSnrDb, + medianSnrDb = overall.medianSnrDb, + directionOctants = overall.directionOctants, + mostRecentReportEpochSec = overall.mostRecentReportEpochSec, + vsBaseline = vsBaseline, + ) +} + +/** + * Fetch reports where the operator was the sender ("who heard me") and reduce + * them to the personal panel. Uses the existing [PskReporterClient] — which + * enforces PSK Reporter's 5-minute etiquette and 429 back-off — so opening + * the sheet repeatedly can never hammer the service; a cooldown-suppressed + * fetch keeps the previous panel via the null return. + * + * Returns null when nothing new could be fetched (caller keeps prior state), + * or a concrete state otherwise. + */ +internal suspend fun loadPersonalPanel( + context: Context, + callsign: String?, + grid: String?, +): PersonalPanelState? { + if (!isPlausibleCallsign(callsign)) return PersonalPanelState.Error + val myGrid = normalizeAdvisorGrid(grid) ?: return PersonalPanelState.Error + val myLocation = try { + MaidenheadGrid.gridToLatLng(myGrid) + } catch (_: Exception) { + null + } ?: return PersonalPanelState.Error + + val spots = PskReporterClient.fetchSpotsForMe( + call = callsign!!.trim().uppercase(), + secondsBack = PERSONAL_LOOKBACK_SECONDS, + byReceiver = false, // Reports where WE transmitted: who heard me. + ) ?: return null // Cooldown or transport failure — keep whatever is shown. + + val report = computePersonalAnalytics( + spots = spots, + myLat = myLocation.latitude, + myLon = myLocation.longitude, + bandOf = { freq -> BaseRigOperation.getMeterFromFreq(freq)?.takeIf { it.isNotBlank() } }, + ) + // Compare against history BEFORE recording this sample — otherwise the + // current session would be part of its own baseline. + val panel = personalPanelFromReport(report) { band -> + PersonalAnalyticsStore.historyFor(context, band) + } + // Feed the local baseline with sufficiently large samples only, so a + // single stray spot can't drag the operator's norm. + for ((band, stats) in report.perBand) { + if (stats.reports >= MIN_REPORTS_FOR_COMPARISON) { + PersonalAnalyticsStore.record(context, band, stats.maxDistanceKm) + } + } + return panel +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/BandModeSheet.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/BandModeSheet.kt index 75c340bd8..aae707cda 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/BandModeSheet.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/BandModeSheet.kt @@ -140,6 +140,13 @@ fun BandModeSheet( onSelectMode: (Int) -> Unit, onSelectBand: (Int) -> Unit, onOpenAllBands: () -> Unit, + /** + * Optional Band Advisor card slot rendered above the mode toggle. A slot + * (not a direct dependency) keeps this sheet ignorant of the advisor + * feature: when the BAND_ADVISOR flag is off the caller passes null and + * nothing about this sheet changes. + */ + advisorCard: (@Composable () -> Unit)? = null, ) { FT8AFBottomSheet(visible = visible, onDismiss = onDismiss) { Column( @@ -174,6 +181,12 @@ fun BandModeSheet( Spacer(modifier = Modifier.height(12.dp)) + // ---- Band Advisor card (feature-flagged; null slot = absent) ---- + if (advisorCard != null) { + advisorCard() + Spacer(modifier = Modifier.height(12.dp)) + } + // ---- Mode toggle (FT8 / FT4 / FT2) ---- Row( modifier = Modifier diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt index ef92f8ab0..e4f4b8225 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/AdvancedSettings.kt @@ -176,6 +176,13 @@ fun AdvancedSettings( var showLanguagePicker by remember { mutableStateOf(false) } var showThemePicker by remember { mutableStateOf(false) } + // Debug-only feature-flag screen (rendered instead of this screen while open). + var showFeatureFlags by remember { mutableStateOf(false) } + if (com.k1af.ft8af.BuildConfig.DEBUG && showFeatureFlags) { + FeatureFlagScreen(onBack = { showFeatureFlags = false }) + return + } + // FFT display developer knobs (issue #428) var fftWindow by remember { mutableIntStateOf(GeneralVariables.getFftWindowType()) } var fftAveraging by remember { mutableIntStateOf(GeneralVariables.getFftAveragingMode()) } @@ -690,6 +697,21 @@ fun AdvancedSettings( } } } + + // ===================================================================== + // DEVELOPER (debug builds only — never composed in release, and the + // flag repository additionally ignores overrides there) + // ===================================================================== + if (com.k1af.ft8af.BuildConfig.DEBUG) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + SettingsRow( + label = stringResource(R.string.settings_developer_flags), + description = stringResource(R.string.settings_developer_flags_desc), + showChevron = true, + onClick = { showFeatureFlags = true }, + ) + } + } } } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettings.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettings.kt new file mode 100644 index 000000000..099917b63 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettings.kt @@ -0,0 +1,297 @@ +package radio.ks3ckc.ft8af.ui.settings + +import android.Manifest +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.MainViewModel +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.bandadvisor.BandAdvisor +import radio.ks3ckc.ft8af.bandadvisor.alerts.AlertPreferencesStore +import radio.ks3ckc.ft8af.bandadvisor.alerts.AlertPrefs +import radio.ks3ckc.ft8af.bandadvisor.alerts.AlertType +import radio.ks3ckc.ft8af.bandadvisor.alerts.PropagationAlertScheduler +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.theme.Accent +import radio.ks3ckc.ft8af.theme.BgSurface +import radio.ks3ckc.ft8af.theme.InterFamily +import radio.ks3ckc.ft8af.theme.TextMuted +import radio.ks3ckc.ft8af.ui.bandadvisor.BandAdvisorTelemetry +import radio.ks3ckc.ft8af.ui.components.GlassCard +import radio.ks3ckc.ft8af.ui.components.SettingsRow + +/** Bands offered in the watch list (superset of the curated band sheet). */ +internal val ALERT_BAND_CHOICES = + listOf("160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m") + +/** Target regions matching the band-advisor service's region enum. */ +internal val ALERT_REGION_CHOICES = listOf( + "EUROPE", "NORTH_AMERICA_EAST", "NORTH_AMERICA_WEST", "SOUTH_AMERICA", + "AFRICA", "ASIA", "OCEANIA", +) + +internal fun alertTypeLabelRes(type: AlertType): Int = when (type) { + AlertType.BAND_OPENING -> R.string.prop_alerts_band_opening + AlertType.BAND_ACTIVE -> R.string.prop_alerts_band_active + AlertType.REGION_REACHABLE -> R.string.prop_alerts_region_reachable + AlertType.PERSONAL_IMPROVEMENT -> R.string.prop_alerts_personal + AlertType.UNUSUAL_OPENING -> R.string.prop_alerts_unusual +} + +/** Toggle one element in a set. Pure. */ +internal fun toggled(set: Set, element: T): Set = + if (element in set) set - element else set + element + +/** + * Band Advisor settings: propagation-alert opt-ins (only while the + * PROPAGATION_ALERTS flag is on), quiet hours, watched bands/regions, the + * privacy note, and the local-data clear control. Every change persists and + * re-syncs the WorkManager schedule, so switching the master toggle off + * cancels pending background work immediately. + */ +@Composable +fun BandAdvisorSettings( + mainViewModel: MainViewModel, + onBack: () -> Unit, +) { + val context = LocalContext.current + var prefs by remember { mutableStateOf(AlertPreferencesStore.load(context)) } + val alertsFlagOn = FeatureFlags.isEnabled(FeatureFlag.PROPAGATION_ALERTS) + + fun update(transform: (AlertPrefs) -> AlertPrefs) { + prefs = transform(prefs) + AlertPreferencesStore.save(context, prefs) + PropagationAlertScheduler.sync(context) + } + + // Android 13+ runtime permission for notifications, requested when the + // master toggle turns on. A denial leaves the preference on — checks run, + // but the worker skips posting until permission exists. + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (!granted) { + Toast.makeText( + context, + context.getString(R.string.prop_alerts_permission_needed), + Toast.LENGTH_LONG, + ).show() + } + } + + var showQuietStart by remember { mutableStateOf(false) } + var showQuietEnd by remember { mutableStateOf(false) } + + SettingsDetailScaffold( + title = stringResource(R.string.settings_cat_band_advisor), + onBack = onBack, + ) { + if (alertsFlagOn) { + SettingsSection(title = stringResource(R.string.prop_alerts_settings_title)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SettingsRow( + label = stringResource(R.string.prop_alerts_master_toggle), + description = stringResource(R.string.prop_alerts_master_desc), + toggle = prefs.enabled, + onToggleChange = { enabled -> + update { it.copy(enabled = enabled) } + BandAdvisorTelemetry.event( + if (enabled) "alerts_enabled" else "alerts_disabled", null, + ) + if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + }, + ) + if (prefs.enabled) { + for (type in AlertType.entries) { + SectionDivider() + SettingsRow( + label = stringResource(alertTypeLabelRes(type)), + toggle = type in prefs.enabledTypes, + onToggleChange = { + update { p -> p.copy(enabledTypes = toggled(p.enabledTypes, type)) } + }, + ) + } + } + } + } + } + + if (prefs.enabled) { + SettingsSection(title = stringResource(R.string.prop_alerts_quiet_hours)) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + SettingsRow( + label = stringResource(R.string.prop_alerts_quiet_from, prefs.quietStartHour), + description = stringResource(R.string.prop_alerts_quiet_hours_desc), + showChevron = true, + onClick = { showQuietStart = true }, + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.prop_alerts_quiet_to, prefs.quietEndHour), + showChevron = true, + onClick = { showQuietEnd = true }, + ) + } + } + } + + SettingsSection(title = stringResource(R.string.prop_alerts_bands)) { + ChipFlow( + choices = ALERT_BAND_CHOICES, + selected = prefs.watchedBands, + onToggle = { band -> + update { p -> p.copy(watchedBands = toggled(p.watchedBands, band)) } + }, + ) + } + + SettingsSection(title = stringResource(R.string.prop_alerts_regions)) { + ChipFlow( + choices = ALERT_REGION_CHOICES, + selected = prefs.watchedRegions, + onToggle = { region -> + update { p -> p.copy(watchedRegions = toggled(p.watchedRegions, region)) } + }, + ) + } + } + } + + // Privacy: what leaves the device + the local-data clear control. + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + Text( + text = stringResource(R.string.band_advisor_privacy_note), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + modifier = Modifier.padding(16.dp), + ) + SectionDivider() + SettingsRow( + label = stringResource(R.string.band_advisor_clear_local), + description = stringResource(R.string.band_advisor_clear_local_desc), + onClick = { + BandAdvisor.clearLocalData(context) + Toast.makeText( + context, + context.getString(R.string.band_advisor_cleared), + Toast.LENGTH_SHORT, + ).show() + }, + ) + } + } + } + + if (showQuietStart) { + HourPickerDialog( + title = stringResource(R.string.prop_alerts_quiet_hours), + selectedHour = prefs.quietStartHour, + onDismiss = { showQuietStart = false }, + onSelect = { hour -> + showQuietStart = false + update { it.copy(quietStartHour = hour) } + }, + ) + } + if (showQuietEnd) { + HourPickerDialog( + title = stringResource(R.string.prop_alerts_quiet_hours), + selectedHour = prefs.quietEndHour, + onDismiss = { showQuietEnd = false }, + onSelect = { hour -> + showQuietEnd = false + update { it.copy(quietEndHour = hour) } + }, + ) + } +} + +@Composable +private fun HourPickerDialog( + title: String, + selectedHour: Int, + onDismiss: () -> Unit, + onSelect: (Int) -> Unit, +) { + ListPickerDialog( + title = title, + items = (0..23).map { String.format(java.util.Locale.US, "%02d:00", it) }, + selectedIndex = selectedHour.coerceIn(0, 23), + onDismiss = onDismiss, + onSelect = onSelect, + ) +} + +/** Simple wrapping row of selectable chips (bands / regions). */ +@Composable +private fun ChipFlow( + choices: List, + selected: Set, + onToggle: (String) -> Unit, +) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (row in choices.chunked(3)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + for (choice in row) { + val isSelected = choice in selected + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) Accent.copy(alpha = 0.18f) else BgSurface) + .clickable { onToggle(choice) } + .padding(vertical = 8.dp), + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + Text( + text = choice.replace('_', ' '), + color = if (isSelected) Accent else TextMuted, + fontSize = 11.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + fontFamily = InterFamily, + ) + } + } + repeat(3 - row.size) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreen.kt new file mode 100644 index 000000000..c77e0feaf --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreen.kt @@ -0,0 +1,176 @@ +package radio.ks3ckc.ft8af.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.k1af.ft8af.R +import radio.ks3ckc.ft8af.bandadvisor.BandAdvisor +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.flags.FlagEvaluation +import radio.ks3ckc.ft8af.flags.FlagOverride +import radio.ks3ckc.ft8af.flags.FlagValueSource +import radio.ks3ckc.ft8af.theme.Accent +import radio.ks3ckc.ft8af.theme.BgSurface +import radio.ks3ckc.ft8af.theme.InterFamily +import radio.ks3ckc.ft8af.theme.TextMuted +import radio.ks3ckc.ft8af.theme.TextPrimary +import radio.ks3ckc.ft8af.ui.components.GlassCard +import radio.ks3ckc.ft8af.ui.components.SettingsRow + +// --------------------------------------------------------------------------- +// Pure helpers (unit-tested without Compose) +// --------------------------------------------------------------------------- + +/** "Effective: ON · from remote config" — the transparency line per flag. */ +internal fun effectiveValueLine(evaluation: FlagEvaluation): Pair { + val valueRes = if (evaluation.enabled) R.string.flags_value_on else R.string.flags_value_off + val sourceRes = when (evaluation.source) { + FlagValueSource.DEBUG_OVERRIDE -> R.string.flags_source_override + FlagValueSource.REMOTE_CONFIG -> R.string.flags_source_remote + FlagValueSource.BUILD_DEFAULT -> R.string.flags_source_default + } + return valueRes to sourceRes +} + +internal fun overrideOptionLabelRes(override: FlagOverride): Int = when (override) { + FlagOverride.DEFAULT -> R.string.flags_option_default + FlagOverride.FORCED_ON -> R.string.flags_option_on + FlagOverride.FORCED_OFF -> R.string.flags_option_off +} + +/** + * Developer feature-flag screen. Reached only from a BuildConfig.DEBUG-gated + * row in Advanced settings, so it does not exist in release builds (and the + * flag repository additionally ignores overrides there — defense in depth). + * + * Per flag: a tri-state selector (Default / Force on / Force off) plus the + * effective value and which layer supplied it. Overrides persist across app + * restarts. Also hosts the fixture-data toggle for offline UI development. + */ +@Composable +internal fun FeatureFlagScreen(onBack: () -> Unit) { + val context = LocalContext.current + // Bumped after every mutation so the effective-value lines recompute. + var revision by remember { mutableIntStateOf(0) } + var useFixtures by remember { mutableStateOf(BandAdvisor.useFixtures(context)) } + + SettingsDetailScaffold( + title = stringResource(R.string.flags_screen_title), + onBack = onBack, + ) { + GlassCard(modifier = Modifier.fillMaxWidth()) { + Column { + for (flag in FeatureFlag.entries) { + androidx.compose.runtime.key(flag, revision) { + FlagRow( + flag = flag, + onChanged = { revision++ }, + ) + } + if (flag != FeatureFlag.entries.last()) SectionDivider() + } + } + } + + GlassCard(modifier = Modifier.fillMaxWidth()) { + SettingsRow( + label = stringResource(R.string.flags_use_fixtures), + description = stringResource(R.string.flags_use_fixtures_desc), + toggle = useFixtures, + onToggleChange = { enabled -> + useFixtures = enabled + BandAdvisor.setUseFixtures(context, enabled) + }, + ) + } + } +} + +@Composable +private fun FlagRow(flag: FeatureFlag, onChanged: () -> Unit) { + val evaluation = FeatureFlags.evaluate(flag) + val store = FeatureFlags.overrideStore + val current = store?.overrideFor(flag) ?: FlagOverride.DEFAULT + val (valueRes, sourceRes) = effectiveValueLine(evaluation) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = flag.name, + color = TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = InterFamily, + ) + Text( + text = stringResource( + R.string.flags_row_effective, + stringResource(valueRes), + stringResource(sourceRes), + ), + color = TextMuted, + fontSize = 12.sp, + fontFamily = InterFamily, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(9.dp)) + .background(BgSurface) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (option in FlagOverride.entries) { + val selected = option == current + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .clip(RoundedCornerShape(7.dp)) + .background(if (selected) Accent.copy(alpha = 0.18f) else Color.Transparent) + .clickable { + store?.setOverride(flag, option) + onChanged() + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(overrideOptionLabelRes(option)), + color = if (selected) Accent else TextMuted, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + fontFamily = InterFamily, + ) + } + } + } + } +} diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt index c2b3414aa..5d8463f8a 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/SettingsScreen.kt @@ -70,6 +70,7 @@ private enum class SettingsCategory { DECODE_FILTERS, VOICE, LOGGING, + BAND_ADVISOR, ADVANCED, USB_DIAGNOSTICS, ABOUT, @@ -145,6 +146,8 @@ fun SettingsScreen( VoiceSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.LOGGING -> LoggingSettings(mainViewModel, onBack = { currentCategory = null }) + SettingsCategory.BAND_ADVISOR -> + BandAdvisorSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.ADVANCED -> AdvancedSettings(mainViewModel, onBack = { currentCategory = null }) SettingsCategory.USB_DIAGNOSTICS -> @@ -332,6 +335,20 @@ private fun SettingsLanding( showChevron = true, onClick = { onOpenCategory(SettingsCategory.LOGGING) }, ) + // Band Advisor settings exist only while its rollout flag is + // on — no entry point, no alert settings otherwise. + if (radio.ks3ckc.ft8af.flags.FeatureFlags.isEnabled( + radio.ks3ckc.ft8af.flags.FeatureFlag.BAND_ADVISOR, + ) + ) { + SectionDivider() + SettingsRow( + label = stringResource(R.string.settings_cat_band_advisor), + description = stringResource(R.string.settings_cat_band_advisor_desc), + showChevron = true, + onClick = { onOpenCategory(SettingsCategory.BAND_ADVISOR) }, + ) + } SectionDivider() SettingsRow( label = stringResource(R.string.settings_cat_advanced), diff --git a/ft8af/app/src/main/res/values/strings.xml b/ft8af/app/src/main/res/values/strings.xml index fa674991e..61a0c1f6d 100644 --- a/ft8af/app/src/main/res/values/strings.xml +++ b/ft8af/app/src/main/res/values/strings.xml @@ -583,4 +583,106 @@ Share activation From a park to the world. Powered by ft8af. %1$s Could not share this activation. Please try again. + + + BAND ADVISOR + Band Advisor + Best band now: %1$s + %1$s • %2$d%% confidence + Excellent + Good + Fair + Poor + Open: %1$s + Switch to %1$s + Set radio to %1$s MHz + CAT is not connected — tune your radio to %1$s MHz manually + Checking conditions… + Set your grid square in Settings to get band recommendations + Band Advisor needs your Maidenhead grid square. Set it in Settings (or enable GPS auto-grid) and try again. + Recommendation unavailable — tap for details + The recommendation service is unavailable right now. Normal FT8 operation is unaffected. + You appear to be offline. Band Advisor will refresh when a connection is available. + Please wait a moment before refreshing again. + Refresh + Stale + Expired + Updated %1$d min ago + Stale — last updated %1$d min ago + This recommendation has expired. Refresh for current conditions. + Partial data — missing: %1$s + VOACAP predictions + regional activity + Why this band + VOACAP + NEARBY + YOU + Alternatives + Contact + DX + Target + POTA + YOUR SIGNAL · PSK REPORTER + Checking who heard you… + No recent reports yet. Call CQ and check again in a few minutes. + Couldn\'t load your reception reports right now. + Heard by %1$d stations (%2$d reports) + Reach + %1$d countries · max %2$d km + max %1$d km + SNR + median %1$d dB · best %2$d dB + vs your usual range + Last report %1$d min ago + Reception reports show who heard your signal — they are not contacts. + + + Feature Flags + Effective: %1$s · from %2$s + ON + OFF + debug override + remote config + build default + Default + Force on + Force off + Use fixture data + Serve deterministic sample recommendations instead of calling the Band Advisor API + Developer: Feature flags + Band Advisor rollout controls (debug builds only) + + + Propagation alerts + Band openings and propagation changes near your grid + Propagation alerts + Band-opening alerts + Notify when a band opens near your grid. Checks run about every 30 minutes. + General band openings + Selected band becomes active + Selected region becomes reachable + Your signal is outperforming its baseline + Unusual openings (observed ≫ predicted) + Quiet hours + No alert notifications between these hours + From %1$02d:00 + Until %1$02d:00 + Bands to watch + Target regions + %1$s is opening near you + Stations around %1$s are reaching %2$s on %3$s. + Allow notifications to receive propagation alerts + Band Advisor + Propagation alerts and recommendation preferences + Clear cached analytics + Delete locally stored recommendations and personal signal history + Cached Band Advisor data cleared + Band Advisor sends only your 4-character grid square (about 100×200 km) and, if personal analytics are on, your callsign. GPS coordinates never leave the device. + Europe + North America East + North America West + South America + Africa + Asia + Oceania diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepositoryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepositoryTest.kt new file mode 100644 index 000000000..738de0158 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/ApiBandAdvisorRepositoryTest.kt @@ -0,0 +1,278 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +@RunWith(RobolectricTestRunner::class) +class ApiBandAdvisorRepositoryTest { + private lateinit var server: MockWebServer + private lateinit var cache: InMemoryRecommendationCacheStore + private var now = 0L + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + cache = InMemoryRecommendationCacheStore() + now = iso("2026-09-27T12:01:00Z") + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun iso(s: String): Long { + val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + f.timeZone = TimeZone.getTimeZone("UTC") + return f.parse(s)!!.time + } + + private fun isoOf(ms: Long): String { + val f = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + f.timeZone = TimeZone.getTimeZone("UTC") + return f.format(Date(ms)) + } + + private fun body( + generatedAt: String = "2026-09-27T12:00:00Z", + validUntil: String = "2026-09-27T12:15:00Z", + goal: String = "MAKE_CONTACT", + callsign: String? = null, + ): String { + val callsignLine = callsign?.let { "\"callsign\": \"$it\"," } ?: "" + return """ + { + "generatedAt": "$generatedAt", "validUntil": "$validUntil", + "grid": "EM28", $callsignLine "mode": "FT8", "goal": "$goal", + "recommendedBand": "20m", "recommendedFrequencyHz": 14074000, + "score": 0.9, "confidence": 0.8, "summary": "s", + "destinations": [], "evidence": [], "scoreComponents": [], + "alternatives": [], + "sources": {"voacapAvailable": true, "regionalPskReporterAvailable": true, + "personalPskReporterAvailable": false} + } + """.trimIndent() + } + + private fun repo() = ApiBandAdvisorRepository( + cacheStore = cache, + clock = { now }, + baseUrl = server.url("/api/band-advisor").toString().removeSuffix("/"), + ) + + private val request = AdvisorRequest(grid = "EM28ax", callsign = null) + + @Test + fun `fetches parses and caches a recommendation`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val result = repo().recommendation(request) as AdvisorResult.Available + assertThat(result.recommendation.recommendedBand).isEqualTo("20m") + assertThat(result.freshness).isEqualTo(Freshness.FRESH) + assertThat(result.fromCache).isFalse() + assertThat(cache.load()).isNotNull() + // Grid was normalized to 4 chars in the query. + val path = server.takeRequest().path!! + assertThat(path).contains("grid=EM28") + assertThat(path).doesNotContain("EM28ax") + assertThat(path).contains("goal=MAKE_CONTACT") + assertThat(path).doesNotContain("callsign") + } + + @Test + fun `fresh cache for same request is served without network`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) + now += 60_000 // Still inside validUntil. + val second = r.recommendation(request) as AdvisorResult.Available + assertThat(second.fromCache).isTrue() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `goal change bypasses the fresh cache`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + server.enqueue(MockResponse().setBody(body(goal = "DX"))) + val r = repo() + r.recommendation(request) + now += 61_000 // Past the min fetch interval. + val dx = r.recommendation(request.copy(goal = OperatingGoal.DX)) as AdvisorResult.Available + assertThat(dx.fromCache).isFalse() + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `offline falls back to stale cache`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) + // Jump past validity, kill the server. + now = iso("2026-09-27T12:20:00Z") + server.shutdown() + val result = r.recommendation(request, force = true) as AdvisorResult.Available + assertThat(result.fromCache).isTrue() + assertThat(result.freshness).isEqualTo(Freshness.STALE) + } + + @Test + fun `offline with no cache reports OFFLINE`() = runBlocking { + server.shutdown() + val result = repo().recommendation(request) + assertThat((result as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.OFFLINE) + } + + @Test + fun `invalid payload never replaces the cache`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) + now = iso("2026-09-27T12:20:00Z") + server.enqueue(MockResponse().setBody("{\"broken\": true}")) + val result = r.recommendation(request, force = true) as AdvisorResult.Available + assertThat(result.fromCache).isTrue() + assertThat(r.cached()!!.recommendation.recommendedBand).isEqualTo("20m") + } + + @Test + fun `http 500 with no cache reports HTTP_ERROR`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val result = repo().recommendation(request) + assertThat((result as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.HTTP_ERROR) + assertThat(result.detail).contains("500") + } + + @Test + fun `429 imposes a back-off that even force honors`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(429)) + val r = repo() + val first = r.recommendation(request) + assertThat((first as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.HTTP_ERROR) + now += 60_000 + val second = r.recommendation(request, force = true) + assertThat((second as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.RATE_LIMITED) + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `cooldown suppresses repeat requests but force overrides`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) // 404, no cache. + now += 10_000 // Inside the 60s cooldown. + val cooled = r.recommendation(request) + assertThat((cooled as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.RATE_LIMITED) + assertThat(server.requestCount).isEqualTo(1) + val forced = r.recommendation(request, force = true) + assertThat(forced).isInstanceOf(AdvisorResult.Available::class.java) + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `missing grid reports NO_GRID without any request`() = runBlocking { + val result = repo().recommendation(AdvisorRequest(grid = null, callsign = null)) + assertThat((result as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.NO_GRID) + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `valid callsign is sent uppercase, invalid omitted`() = runBlocking { + server.enqueue(MockResponse().setBody(body(callsign = "K1AF"))) + val r = repo() + r.recommendation(request.copy(callsign = "k1af")) + assertThat(server.takeRequest().path).contains("callsign=K1AF") + + now += 61_000 + server.enqueue(MockResponse().setBody(body())) + r.recommendation(request.copy(callsign = "NOCALL", goal = OperatingGoal.POTA)) + assertThat(server.takeRequest().path).doesNotContain("callsign") + } + + @Test + fun `clearCache drops the stored recommendation`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) + assertThat(r.cached()).isNotNull() + r.clearCache() + assertThat(r.cached()).isNull() + } + + @Test + fun `expired cache is reported as EXPIRED`() = runBlocking { + server.enqueue(MockResponse().setBody(body())) + val r = repo() + r.recommendation(request) + now = iso("2026-09-27T14:00:00Z") // Far past validity + grace. + assertThat(r.cached()!!.freshness).isEqualTo(Freshness.EXPIRED) + } + + @Test + fun `changing target region refetches instead of reusing another target`() = + runBlocking { + val r = repo() + val targetRequest = request.copy(goal = OperatingGoal.TARGET, targetRegion = "EUROPE") + server.enqueue( + MockResponse().setBody( + body(goal = "TARGET").replace( + "\"goal\": \"TARGET\"", + "\"goal\": \"TARGET\", \"targetRegion\": \"EUROPE\"", + ), + ), + ) + r.recommendation(targetRequest) + assertThat(server.takeRequest().path).contains("targetRegion=EUROPE") + now += 61_000 + server.enqueue( + MockResponse().setBody( + body(goal = "TARGET").replace( + "\"goal\": \"TARGET\"", + "\"goal\": \"TARGET\", \"targetRegion\": \"ASIA\"", + ), + ), + ) + val changed = r.recommendation(targetRequest.copy(targetRegion = "ASIA")) as AdvisorResult.Available + assertThat(server.takeRequest().path).contains("targetRegion=ASIA") + assertThat(changed.recommendation.targetRegion).isEqualTo("ASIA") + assertThat(changed.fromCache).isFalse() + assertThat(r.recommendation(targetRequest.copy(targetRegion = "ASIA"))) + .isEqualTo(changed.copy(fromCache = true)) + assertThat(server.requestCount).isEqualTo(2) + } + + @Test + fun `target cooldown does not display a cached answer for another region`() = + runBlocking { + cache.save( + body(goal = "TARGET").replace( + "\"goal\": \"TARGET\"", + "\"goal\": \"TARGET\", \"targetRegion\": \"EUROPE\"", + ), + ) + server.enqueue(MockResponse().setResponseCode(503)) + val r = repo() + val asia = request.copy(goal = OperatingGoal.TARGET, targetRegion = "ASIA") + assertThat(r.recommendation(asia)).isInstanceOf(AdvisorResult.Unavailable::class.java) + assertThat(r.recommendation(asia, force = true)).isInstanceOf(AdvisorResult.Unavailable::class.java) + assertThat(server.requestCount).isEqualTo(1) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepositoryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepositoryTest.kt new file mode 100644 index 000000000..8aee963ea --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/FixtureBandAdvisorRepositoryTest.kt @@ -0,0 +1,95 @@ +package radio.ks3ckc.ft8af.bandadvisor + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Test +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal + +class FixtureBandAdvisorRepositoryTest { + // 2026-09-27T18:00:00Z — "daytime" branch of the fixture. + private val dayMs = 1_790_532_000_000L + + // 2026-09-27T04:00:00Z — "night" branch. + private val nightMs = 1_790_481_600_000L + + private val request = AdvisorRequest(grid = "EM28", callsign = "K1AF") + + @Test + fun `same inputs produce identical output`() = runBlocking { + val repo = FixtureBandAdvisorRepository(clock = { dayMs }) + val a = repo.recommendation(request) as AdvisorResult.Available + val b = repo.recommendation(request) as AdvisorResult.Available + assertThat(a).isEqualTo(b) + } + + @Test + fun `recommendation is fresh, complete and passes through the real scorer`() = runBlocking { + val result = FixtureBandAdvisorRepository(clock = { dayMs }) + .recommendation(request) as AdvisorResult.Available + val rec = result.recommendation + assertThat(result.freshness).isEqualTo(Freshness.FRESH) + assertThat(rec.recommendedBand).isEqualTo("20m") // Daytime winner. + assertThat(rec.recommendedFrequencyHz).isEqualTo(14_074_000L) + assertThat(rec.alternatives).hasSize(2) + assertThat(rec.alternatives.map { it.reason }).doesNotContain("") + assertThat(rec.scoreComponents).isNotEmpty() + assertThat(rec.scoreComponents.sumOf { it.contribution }).isWithin(1e-9).of(rec.score) + assertThat(rec.confidence).isIn(com.google.common.collect.Range.closed(0.0, 1.0)) + assertThat(rec.personalAnalytics!!.enabled).isTrue() + assertThat(rec.evidence.map { it.type.name }).contains("PERSONAL_PSK_REPORTER") + } + + @Test + fun `night hours recommend a low band`() = runBlocking { + val rec = ( + FixtureBandAdvisorRepository(clock = { nightMs }) + .recommendation(request) as AdvisorResult.Available + ).recommendation + assertThat(rec.recommendedBand).isEqualTo("40m") + } + + @Test + fun `no callsign disables the personal layer`() = runBlocking { + val rec = ( + FixtureBandAdvisorRepository(clock = { dayMs }) + .recommendation(request.copy(callsign = null)) as AdvisorResult.Available + ).recommendation + assertThat(rec.personalAnalytics).isNull() + assertThat(rec.callsign).isNull() + assertThat(rec.sources.personalPskReporterAvailable).isFalse() + assertThat(rec.sources.isPartial).isTrue() + assertThat(rec.evidence.map { it.type.name }).doesNotContain("PERSONAL_PSK_REPORTER") + // Personal component is dropped and remaining weights renormalized. + assertThat(rec.scoreComponents.map { it.name }).doesNotContain("personalStationPerformance") + assertThat(rec.scoreComponents.sumOf { it.weight }).isWithin(1e-9).of(1.0) + } + + @Test + fun `goal changes the ranking`() = runBlocking { + val repo = FixtureBandAdvisorRepository(clock = { dayMs }) + val contact = (repo.recommendation(request) as AdvisorResult.Available).recommendation + val dx = ( + repo.recommendation(request.copy(goal = OperatingGoal.DX)) as AdvisorResult.Available + ).recommendation + assertThat(contact.goal).isEqualTo(OperatingGoal.MAKE_CONTACT) + assertThat(dx.goal).isEqualTo(OperatingGoal.DX) + // DX weighting must raise the score of the distance-heavy 15m band + // relative to the contact weighting (visible via alternatives order/score). + val contact15 = contact.alternatives.firstOrNull { it.band == "15m" }?.score + ?: if (contact.recommendedBand == "15m") contact.score else null + val dx15 = dx.alternatives.firstOrNull { it.band == "15m" }?.score + ?: if (dx.recommendedBand == "15m") dx.score else null + assertThat(dx15).isNotNull() + assertThat(contact15).isNotNull() + assertThat(dx15!!).isGreaterThan(contact15!!) + } + + @Test + fun `invalid grid is rejected`() = runBlocking { + val result = FixtureBandAdvisorRepository(clock = { dayMs }) + .recommendation(request.copy(grid = "bogus")) + assertThat((result as AdvisorResult.Unavailable).reason) + .isEqualTo(UnavailableReason.NO_GRID) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertGridTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertGridTest.kt new file mode 100644 index 000000000..aef5d6a93 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertGridTest.kt @@ -0,0 +1,48 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.GeneralVariables +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.bandadvisor.BandAdvisor +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion + +@RunWith(RobolectricTestRunner::class) +class AlertGridTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `cold process reads persisted grid without globals`() { + GeneralVariables.setMyMaidenheadGrid("") + context.openOrCreateDatabase("data.db", Context.MODE_PRIVATE, null).use { db -> + db.execSQL("CREATE TABLE config (KeyName TEXT, Value TEXT)") + db.execSQL("INSERT INTO config VALUES ('GRID', 'EM28ab')") + } + assertThat(loadAlertGrid(context)).isEqualTo("EM28") + assertThat(GeneralVariables.getMyMaidenheadGrid()).isEmpty() + } + + @Test + fun `missing or invalid persisted grid skips checks`() { + assertThat(loadAlertGrid(context)).isNull() + context.openOrCreateDatabase("data.db", Context.MODE_PRIVATE, null).use { db -> + db.execSQL("CREATE TABLE config (KeyName TEXT, Value TEXT)") + } + assertThat(loadAlertGrid(context)).isNull() + context.openOrCreateDatabase("data.db", Context.MODE_PRIVATE, null).use { db -> + db.execSQL("INSERT INTO config VALUES ('grid', 'INVALID')") + } + assertThat(loadAlertGrid(context)).isNull() + } + + @Test + fun `target region defaults and persists`() { + assertThat(BandAdvisor.targetRegion(context)).isEqualTo(AdvisorTargetRegion.EUROPE) + BandAdvisor.setTargetRegion(context, AdvisorTargetRegion.ASIA) + assertThat(BandAdvisor.targetRegion(context)).isEqualTo(AdvisorTargetRegion.ASIA) + assertThat(AdvisorTargetRegion.fromWireName("UNKNOWN")).isEqualTo(AdvisorTargetRegion.EUROPE) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertStoresTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertStoresTest.kt new file mode 100644 index 000000000..e00e7ac09 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/AlertStoresTest.kt @@ -0,0 +1,64 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AlertStoresTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `prefs round-trip`() { + val prefs = AlertPrefs( + enabled = true, + enabledTypes = setOf(AlertType.BAND_OPENING, AlertType.REGION_REACHABLE), + quietStartHour = 23, + quietEndHour = 6, + watchedBands = setOf("10m", "15m"), + watchedRegions = setOf("EUROPE"), + ) + AlertPreferencesStore.save(context, prefs) + assertThat(AlertPreferencesStore.load(context)).isEqualTo(prefs) + } + + @Test + fun `defaults are everything-off`() { + val fresh = AlertPreferencesStore.load(context) + assertThat(fresh.enabled).isFalse() + assertThat(fresh.enabledTypes).isEmpty() + } + + @Test + fun `unknown persisted alert-type ids are dropped`() { + context.getSharedPreferences("ft8af_prop_alerts", Context.MODE_PRIVATE) + .edit() + .putStringSet("types", setOf("band_opening", "future_alert_type")) + .commit() + assertThat(AlertPreferencesStore.load(context).enabledTypes) + .containsExactly(AlertType.BAND_OPENING) + } + + @Test + fun `history records, prunes, and clears`() { + val now = 1_790_532_000_000L + AlertHistoryStore.recordDelivery(context, "a|10m|-|1", now - DEDUP_RETENTION_MS - 1000) + AlertHistoryStore.recordDelivery(context, "b|15m|-|2", now) + // Recording "b" prunes the expired "a". + assertThat(AlertHistoryStore.deliveredAt(context).keys).containsExactly("b|15m|-|2") + + AlertHistoryStore.clear(context) + assertThat(AlertHistoryStore.deliveredAt(context)).isEmpty() + } + + @Test + fun `corrupt history json degrades to empty`() { + assertThat(AlertHistoryStore.parse("not json")).isEmpty() + assertThat(AlertHistoryStore.parse(null)).isEmpty() + val round = AlertHistoryStore.encode(mapOf("k" to 42L)) + assertThat(AlertHistoryStore.parse(round)).containsExactly("k", 42L) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClientTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClientTest.kt new file mode 100644 index 000000000..0058b6cb8 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/ConditionsClientTest.kt @@ -0,0 +1,60 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConditionsClientTest { + private val now = 1_790_532_000_000L // 2026-09-27T18:00:00Z + + private val valid = """ + { + "generatedAt": "2026-09-27T17:55:00Z", + "bands": [ + {"band": "10m", "score": 0.8, "confidence": 0.7, "observationCount": 30, + "activityRatio": 2.1, "activeRegions": ["SOUTH_AMERICA"], + "voacapSupported": true, "unusualOpening": false}, + {"band": "20m", "score": 0.6, "confidence": 0.9, "observationCount": 55, + "activityRatio": 1.1, "activeRegions": [], + "voacapSupported": true, "unusualOpening": false, + "personalImprovement": true} + ] + } + """.trimIndent() + + @Test + fun `parses bands with age from generatedAt`() { + val conditions = ConditionsClient.parseConditions(valid, now)!! + assertThat(conditions).hasSize(2) + val ten = conditions[0] + assertThat(ten.band).isEqualTo("10m") + assertThat(ten.activeRegions).containsExactly("SOUTH_AMERICA") + assertThat(ten.dataAgeMs).isEqualTo(5 * 60_000) + assertThat(ten.personalImprovement).isNull() + assertThat(conditions[1].personalImprovement).isTrue() + } + + @Test + fun `malformed entries are dropped, empty document is a failure`() { + val oneBad = valid.replace("\"band\": \"20m\"", "\"band\": \"\"") + assertThat(ConditionsClient.parseConditions(oneBad, now)!!).hasSize(1) + + assertThat(ConditionsClient.parseConditions("not json", now)).isNull() + assertThat(ConditionsClient.parseConditions("{}", now)).isNull() + assertThat( + ConditionsClient.parseConditions("""{"generatedAt":"2026-09-27T17:55:00Z","bands":[]}""", now), + ).isNull() + } + + @Test + fun `out-of-range values are clamped`() { + val weird = valid + .replace("\"score\": 0.8", "\"score\": 7.0") + .replace("\"observationCount\": 30", "\"observationCount\": -3") + val conditions = ConditionsClient.parseConditions(weird, now)!! + assertThat(conditions[0].score).isEqualTo(1.0) + assertThat(conditions[0].observationCount).isEqualTo(0) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicyTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicyTest.kt new file mode 100644 index 000000000..658087f9d --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertPolicyTest.kt @@ -0,0 +1,201 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class PropagationAlertPolicyTest { + private val now = 1_790_532_000_000L + private val grid = "EM28" + + private fun snapshot( + band: String = "10m", + score: Double = 0.8, + confidence: Double = 0.8, + observations: Int = 25, + activityRatio: Double = 2.0, + regions: List = listOf("SOUTH_AMERICA"), + voacap: Boolean = true, + unusual: Boolean = false, + personal: Boolean? = null, + ageMs: Long = 5 * 60_000, + ) = BandConditionSnapshot( + band, score, confidence, observations, activityRatio, regions, + voacap, unusual, personal, ageMs, + ) + + private fun prefs( + enabled: Boolean = true, + types: Set = setOf(AlertType.BAND_OPENING), + quietStart: Int = 0, + quietEnd: Int = 0, + bands: Set = emptySet(), + regions: Set = emptySet(), + ) = AlertPrefs(enabled, types, quietStart, quietEnd, bands, regions) + + private fun evaluate( + conditions: List, + prefs: AlertPrefs = prefs(), + delivered: Map = emptyMap(), + localHour: Int = 12, + ) = evaluateAlerts(grid, conditions, prefs, delivered, now, localHour) + + // ---------------- thresholds ---------------- + + @Test + fun `a well-evidenced opening alerts`() { + val decisions = evaluate(listOf(snapshot())) + assertThat(decisions).hasSize(1) + assertThat(decisions[0].type).isEqualTo(AlertType.BAND_OPENING) + assertThat(decisions[0].band).isEqualTo("10m") + assertThat(decisions[0].region).isEqualTo("SOUTH_AMERICA") + assertThat(decisions[0].conditionsAtMs).isEqualTo(now - 5 * 60_000) + } + + @Test + fun `insufficient evidence is silent`() { + // Each mutation individually kills the alert. + assertThat(evaluate(listOf(snapshot(observations = MIN_OBSERVATIONS - 1)))).isEmpty() + assertThat(evaluate(listOf(snapshot(confidence = MIN_CONFIDENCE - 0.01)))).isEmpty() + assertThat(evaluate(listOf(snapshot(activityRatio = MIN_ACTIVITY_RATIO - 0.1)))).isEmpty() + assertThat(evaluate(listOf(snapshot(ageMs = MAX_DATA_AGE_MS + 1)))).isEmpty() + // No VOACAP support AND not flagged unusual -> silent. + assertThat(evaluate(listOf(snapshot(voacap = false, unusual = false)))).isEmpty() + // Unusual observed opening without VOACAP support still qualifies. + assertThat(evaluate(listOf(snapshot(voacap = false, unusual = true)))).hasSize(1) + } + + @Test + fun `disabled master or empty types is silent`() { + assertThat(evaluate(listOf(snapshot()), prefs(enabled = false))).isEmpty() + assertThat(evaluate(listOf(snapshot()), prefs(types = emptySet()))).isEmpty() + } + + // ---------------- quiet hours ---------------- + + @Test + fun `quiet hours silence alerts including midnight wrap`() { + val night = prefs(quietStart = 22, quietEnd = 7) + assertThat(evaluate(listOf(snapshot()), night, localHour = 23)).isEmpty() + assertThat(evaluate(listOf(snapshot()), night, localHour = 3)).isEmpty() + assertThat(evaluate(listOf(snapshot()), night, localHour = 12)).hasSize(1) + } + + @Test + fun `isInQuietHours edge cases`() { + assertThat(isInQuietHours(22, 7, 22)).isTrue() + assertThat(isInQuietHours(22, 7, 7)).isFalse() // end exclusive + assertThat(isInQuietHours(9, 17, 9)).isTrue() + assertThat(isInQuietHours(9, 17, 17)).isFalse() + assertThat(isInQuietHours(5, 5, 5)).isFalse() // equal = disabled + } + + // ---------------- dedup + cooldowns ---------------- + + @Test + fun `same opening never alerts twice`() { + val first = evaluate(listOf(snapshot())).single() + val delivered = mapOf(first.identity to now - GLOBAL_COOLDOWN_MS - 1) + // Past global cooldown but the identity matches -> per-band cooldown or + // dedup keeps it silent. Push the delivery past the band cooldown too: + val oldDelivery = mapOf(first.identity to now - PER_BAND_COOLDOWN_MS - 1) + // Identity buckets are 90 min; a delivery 3h+ ago has a different bucket, + // so build one with the CURRENT bucket to test pure dedup: + val currentIdentity = alertIdentity(grid, "10m", "SOUTH_AMERICA", now) + assertThat( + evaluate( + listOf(snapshot()), + delivered = mapOf(currentIdentity to now - PER_BAND_COOLDOWN_MS - 1), + ), + ).isEmpty() + // Sanity: with unrelated history it still fires. + assertThat( + evaluate( + listOf(snapshot()), + delivered = mapOf("other|20m|-|1" to now - PER_BAND_COOLDOWN_MS - 1), + ), + ).hasSize(1) + // Silence-check for the delivered map built above (kept for clarity). + assertThat(delivered).isNotEmpty() + assertThat(oldDelivery).isNotEmpty() + } + + @Test + fun `global cooldown suppresses everything`() { + val delivered = mapOf("x|20m|-|1" to now - GLOBAL_COOLDOWN_MS + 1) + assertThat(evaluate(listOf(snapshot()), delivered = delivered)).isEmpty() + } + + @Test + fun `per-band cooldown suppresses that band only`() { + val delivered = mapOf("$grid|10m|EUROPE|123" to now - PER_BAND_COOLDOWN_MS + 1) + assertThat(evaluate(listOf(snapshot(band = "10m")), delivered = delivered)).isEmpty() + assertThat(evaluate(listOf(snapshot(band = "15m")), delivered = delivered)).hasSize(1) + } + + @Test + fun `at most one alert per check - strongest band wins`() { + val decisions = evaluate( + listOf(snapshot(band = "10m", score = 0.7), snapshot(band = "15m", score = 0.9)), + ) + assertThat(decisions).hasSize(1) + assertThat(decisions[0].band).isEqualTo("15m") + } + + // ---------------- type routing ---------------- + + @Test + fun `watched region beats other types`() { + val p = prefs( + types = setOf(AlertType.BAND_OPENING, AlertType.REGION_REACHABLE), + regions = setOf("SOUTH_AMERICA"), + ) + assertThat(evaluate(listOf(snapshot()), p).single().type) + .isEqualTo(AlertType.REGION_REACHABLE) + } + + @Test + fun `watched band type requires the band to be watched`() { + val p = prefs(types = setOf(AlertType.BAND_ACTIVE), bands = setOf("15m")) + assertThat(evaluate(listOf(snapshot(band = "10m")), p)).isEmpty() + assertThat(evaluate(listOf(snapshot(band = "15m")), p).single().type) + .isEqualTo(AlertType.BAND_ACTIVE) + } + + @Test + fun `personal improvement type requires the signal flag`() { + val p = prefs(types = setOf(AlertType.PERSONAL_IMPROVEMENT)) + assertThat(evaluate(listOf(snapshot(personal = null)), p)).isEmpty() + assertThat(evaluate(listOf(snapshot(personal = false)), p)).isEmpty() + assertThat(evaluate(listOf(snapshot(personal = true)), p).single().type) + .isEqualTo(AlertType.PERSONAL_IMPROVEMENT) + } + + @Test + fun `unusual opening type requires the unusual flag`() { + val p = prefs(types = setOf(AlertType.UNUSUAL_OPENING)) + assertThat(evaluate(listOf(snapshot(unusual = false)), p)).isEmpty() + assertThat(evaluate(listOf(snapshot(unusual = true)), p).single().type) + .isEqualTo(AlertType.UNUSUAL_OPENING) + } + + // ---------------- identity + retention ---------------- + + @Test + fun `identity buckets openings into 90-minute windows`() { + val a = alertIdentity("EM28", "10m", "EUROPE", now) + val sameBucket = alertIdentity("EM28", "10m", "EUROPE", now + OPENING_BUCKET_MS - 1 - now % OPENING_BUCKET_MS) + val nextBucket = alertIdentity("EM28", "10m", "EUROPE", now + OPENING_BUCKET_MS) + assertThat(a).isEqualTo(sameBucket) + assertThat(a).isNotEqualTo(nextBucket) + assertThat(alertIdentity("EM28", "10m", null, now)).contains("|-|") + } + + @Test + fun `history pruning drops entries past retention`() { + val history = mapOf( + "fresh" to now - 1000, + "old" to now - DEDUP_RETENTION_MS - 1, + ) + assertThat(pruneHistory(history, now).keys).containsExactly("fresh") + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertSchedulerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertSchedulerTest.kt new file mode 100644 index 000000000..8d800784a --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/alerts/PropagationAlertSchedulerTest.kt @@ -0,0 +1,91 @@ +package radio.ks3ckc.ft8af.bandadvisor.alerts + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlagRepository +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.flags.FlagEvaluation +import radio.ks3ckc.ft8af.flags.FlagValueSource + +@RunWith(RobolectricTestRunner::class) +class PropagationAlertSchedulerTest { + private val context = ApplicationProvider.getApplicationContext() + + private class FixedFlags(private val enabled: Boolean) : FeatureFlagRepository { + override fun isEnabled(flag: FeatureFlag) = enabled + override fun evaluate(flag: FeatureFlag) = + FlagEvaluation(flag, enabled, FlagValueSource.BUILD_DEFAULT) + } + + @Before + fun setUp() { + WorkManagerTestInitHelper.initializeTestWorkManager( + context, + Configuration.Builder().setExecutor(SynchronousExecutor()).build(), + ) + } + + private fun workState(): List = + WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(PropagationAlertScheduler.WORK_NAME) + .get() + + private val onPrefs = AlertPrefs(enabled = true, enabledTypes = setOf(AlertType.BAND_OPENING)) + + @Test + fun `shouldSchedule requires flag AND master pref AND at least one type`() { + assertThat(PropagationAlertScheduler.shouldSchedule(true, onPrefs)).isTrue() + assertThat(PropagationAlertScheduler.shouldSchedule(false, onPrefs)).isFalse() + assertThat( + PropagationAlertScheduler.shouldSchedule(true, onPrefs.copy(enabled = false)), + ).isFalse() + assertThat( + PropagationAlertScheduler.shouldSchedule(true, onPrefs.copy(enabledTypes = emptySet())), + ).isFalse() + } + + @Test + fun `sync schedules when enabled and cancels when disabled`() { + FeatureFlags.setDelegateForTests(FixedFlags(true)) + try { + AlertPreferencesStore.save(context, onPrefs) + PropagationAlertScheduler.sync(context) + assertThat(workState().single().state).isEqualTo(WorkInfo.State.ENQUEUED) + + // User turns the master preference off: pending work is cancelled. + AlertPreferencesStore.save(context, onPrefs.copy(enabled = false)) + PropagationAlertScheduler.sync(context) + assertThat(workState().single().state).isEqualTo(WorkInfo.State.CANCELLED) + } finally { + FeatureFlags.setDelegateForTests(null) + } + } + + @Test + fun `sync cancels when the feature flag is off even with the pref on`() { + FeatureFlags.setDelegateForTests(FixedFlags(true)) + try { + AlertPreferencesStore.save(context, onPrefs) + PropagationAlertScheduler.sync(context) + assertThat(workState().single().state).isEqualTo(WorkInfo.State.ENQUEUED) + + // Remote-config rollback: the flag flips off, the next sync cancels. + FeatureFlags.setDelegateForTests(FixedFlags(false)) + PropagationAlertScheduler.sync(context) + assertThat(workState().single().state).isEqualTo(WorkInfo.State.CANCELLED) + } finally { + FeatureFlags.setDelegateForTests(null) + } + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParserTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParserTest.kt new file mode 100644 index 000000000..5373d4629 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/RecommendationParserTest.kt @@ -0,0 +1,151 @@ +package radio.ks3ckc.ft8af.bandadvisor.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Robolectric for org.json. Fixture mirrors the server contract exactly. */ +@RunWith(RobolectricTestRunner::class) +class RecommendationParserTest { + + private val contract = """ + { + "generatedAt": "2026-09-27T12:00:00Z", + "validUntil": "2026-09-27T12:15:00Z", + "grid": "EM28", + "callsign": "K1AF", + "mode": "FT8", + "goal": "DX", + "recommendedBand": "20m", + "recommendedFrequencyHz": 14074000, + "score": 0.91, + "confidence": 0.87, + "summary": "Best combination of live activity and long-distance reliability.", + "destinations": ["Europe", "South America"], + "evidence": [ + {"type": "VOACAP", "message": "Reliable paths into Europe are predicted for the current hour."}, + {"type": "REGIONAL_PSK_REPORTER", "message": "Nearby stations have reached 14 European countries in the last 15 minutes."}, + {"type": "PERSONAL_PSK_REPORTER", "message": "Your station has recently performed above its normal 20-meter range."} + ], + "scoreComponents": [ + {"name": "voacapReliability", "weight": 0.3, "value": 0.8, "contribution": 0.24} + ], + "personalAnalytics": { + "enabled": true, + "reportsReceived": 23, + "uniqueReceivers": 18, + "countriesReached": 7, + "maximumDistanceKm": 8420, + "medianSnrDb": -11, + "bestSnrDb": -3, + "performanceComparedToBaseline": 0.18 + }, + "alternatives": [ + {"band": "15m", "frequencyHz": 21074000, "score": 0.82, "reason": "Greater maximum distance, but fewer observed paths."}, + {"band": "40m", "frequencyHz": 7074000, "score": 0.74, "reason": "Better for dependable regional contacts."} + ], + "sources": { + "voacapAvailable": true, + "regionalPskReporterAvailable": true, + "personalPskReporterAvailable": true + } + } + """.trimIndent() + + @Test + fun `parses the full contract example`() { + val rec = parseBandRecommendation(contract)!! + assertThat(rec.grid).isEqualTo("EM28") + assertThat(rec.callsign).isEqualTo("K1AF") + assertThat(rec.goal).isEqualTo(OperatingGoal.DX) + assertThat(rec.recommendedBand).isEqualTo("20m") + assertThat(rec.recommendedFrequencyHz).isEqualTo(14_074_000L) + assertThat(rec.score).isWithin(1e-9).of(0.91) + assertThat(rec.confidence).isWithin(1e-9).of(0.87) + assertThat(rec.destinations).containsExactly("Europe", "South America").inOrder() + assertThat(rec.evidence).hasSize(3) + assertThat(rec.evidence[0].type).isEqualTo(EvidenceType.VOACAP) + assertThat(rec.evidence[1].type).isEqualTo(EvidenceType.REGIONAL_PSK_REPORTER) + assertThat(rec.scoreComponents).hasSize(1) + assertThat(rec.scoreComponents[0].contribution).isWithin(1e-9).of(0.24) + assertThat(rec.personalAnalytics!!.reportsReceived).isEqualTo(23) + assertThat(rec.personalAnalytics!!.performanceComparedToBaseline).isWithin(1e-9).of(0.18) + assertThat(rec.alternatives).hasSize(2) + assertThat(rec.alternatives[0].frequencyHz).isEqualTo(21_074_000L) + assertThat(rec.sources.voacapAvailable).isTrue() + assertThat(rec.sources.isPartial).isFalse() + assertThat(rec.validUntilMs - rec.generatedAtMs).isEqualTo(15L * 60 * 1000) + } + + @Test + fun `personal analytics and callsign are optional`() { + val anonymous = contract + .replace("\"callsign\": \"K1AF\",", "") + .replace(Regex("\"personalAnalytics\":\\s*\\{[^}]*\\},"), "") + val rec = parseBandRecommendation(anonymous)!! + assertThat(rec.callsign).isNull() + assertThat(rec.personalAnalytics).isNull() + } + + @Test + fun `unknown goal falls back to MAKE_CONTACT and unknown evidence type to OTHER`() { + val mutated = contract + .replace("\"goal\": \"DX\"", "\"goal\": \"SOMETHING_NEW\"") + .replace("\"type\": \"VOACAP\"", "\"type\": \"FUTURE_SOURCE\"") + val rec = parseBandRecommendation(mutated)!! + assertThat(rec.goal).isEqualTo(OperatingGoal.MAKE_CONTACT) + assertThat(rec.evidence[0].type).isEqualTo(EvidenceType.OTHER) + } + + @Test + fun `rejects invalid documents`() { + assertThat(parseBandRecommendation("not json")).isNull() + assertThat(parseBandRecommendation("{}")).isNull() + // Score out of range. + assertThat(parseBandRecommendation(contract.replace("\"score\": 0.91", "\"score\": 1.5"))).isNull() + // Bogus frequency must never reach the tuning path. + assertThat( + parseBandRecommendation( + contract.replace("\"recommendedFrequencyHz\": 14074000", "\"recommendedFrequencyHz\": 12"), + ), + ).isNull() + // validUntil before generatedAt. + assertThat( + parseBandRecommendation( + contract.replace("2026-09-27T12:15:00Z", "2026-09-27T11:00:00Z"), + ), + ).isNull() + // Invalid grid. + assertThat(parseBandRecommendation(contract.replace("\"EM28\"", "\"XYZ\""))).isNull() + } + + @Test + fun `partial sources are surfaced`() { + val partial = contract.replace("\"voacapAvailable\": true", "\"voacapAvailable\": false") + val rec = parseBandRecommendation(partial)!! + assertThat(rec.sources.voacapAvailable).isFalse() + assertThat(rec.sources.isPartial).isTrue() + assertThat(rec.sources.anyAvailable).isTrue() + } + + @Test + fun `malformed list entries are dropped not fatal`() { + val mutated = contract.replace( + "{\"band\": \"15m\", \"frequencyHz\": 21074000, \"score\": 0.82, \"reason\": \"Greater maximum distance, but fewer observed paths.\"}", + "{\"band\": \"\", \"frequencyHz\": -1, \"score\": 0.82, \"reason\": \"bad\"}", + ) + val rec = parseBandRecommendation(mutated)!! + assertThat(rec.alternatives).hasSize(1) + assertThat(rec.alternatives[0].band).isEqualTo("40m") + } + + @Test + fun `freshness transitions FRESH STALE EXPIRED`() { + val rec = parseBandRecommendation(contract)!! + assertThat(freshnessOf(rec, rec.validUntilMs - 1)).isEqualTo(Freshness.FRESH) + assertThat(freshnessOf(rec, rec.validUntilMs)).isEqualTo(Freshness.STALE) + assertThat(freshnessOf(rec, rec.validUntilMs + STALE_GRACE_MS - 1)).isEqualTo(Freshness.STALE) + assertThat(freshnessOf(rec, rec.validUntilMs + STALE_GRACE_MS)).isEqualTo(Freshness.EXPIRED) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/ValidationTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/ValidationTest.kt new file mode 100644 index 000000000..769b06fca --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/model/ValidationTest.kt @@ -0,0 +1,40 @@ +package radio.ks3ckc.ft8af.bandadvisor.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ValidationTest { + + @Test + fun `normalizeAdvisorGrid reduces to 4-char square`() { + assertThat(normalizeAdvisorGrid("EM28")).isEqualTo("EM28") + assertThat(normalizeAdvisorGrid("em28ax")).isEqualTo("EM28") + assertThat(normalizeAdvisorGrid(" FN42 ")).isEqualTo("FN42") + assertThat(normalizeAdvisorGrid("jo01ab")).isEqualTo("JO01") + } + + @Test + fun `normalizeAdvisorGrid rejects invalid grids`() { + assertThat(normalizeAdvisorGrid(null)).isNull() + assertThat(normalizeAdvisorGrid("")).isNull() + assertThat(normalizeAdvisorGrid("EM")).isNull() + assertThat(normalizeAdvisorGrid("E128")).isNull() // digit in field letters + assertThat(normalizeAdvisorGrid("EMXX")).isNull() // letters in square digits + assertThat(normalizeAdvisorGrid("ZZ99")).isNull() // S > R field letter + assertThat(normalizeAdvisorGrid("RR73")).isEqualTo("RR73") // valid square; caller context decides + } + + @Test + fun `plausible callsigns accepted`() { + for (call in listOf("K1AF", "W1AW", "VE3ABC", "9A1AA", "DL1ABC", "G4XYZ/P", "VP2E/K1AF", "kd9xyz")) { + assertThat(isPlausibleCallsign(call)).isTrue() + } + } + + @Test + fun `garbage callsigns rejected`() { + for (call in listOf(null, "", " ", "AB", "NOCALL", "EM28", "1234567", "CQ", "THIRTEENCHARS")) { + assertThat(isPlausibleCallsign(call)).isFalse() + } + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStoreTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStoreTest.kt new file mode 100644 index 000000000..dcf8870db --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsStoreTest.kt @@ -0,0 +1,50 @@ +package radio.ks3ckc.ft8af.bandadvisor.personal + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PersonalAnalyticsStoreTest { + private val context = ApplicationProvider.getApplicationContext() + + @Test + fun `history round-trips per band`() { + PersonalAnalyticsStore.record(context, "20m", 5000) + PersonalAnalyticsStore.record(context, "20m", 6000) + PersonalAnalyticsStore.record(context, "40m", 1500) + assertThat(PersonalAnalyticsStore.historyFor(context, "20m")) + .containsExactly(5000, 6000).inOrder() + assertThat(PersonalAnalyticsStore.historyFor(context, "40m")).containsExactly(1500) + assertThat(PersonalAnalyticsStore.historyFor(context, "10m")).isEmpty() + } + + @Test + fun `history is bounded to the newest sessions`() { + repeat(PersonalAnalyticsStore.MAX_SESSIONS + 5) { i -> + PersonalAnalyticsStore.record(context, "20m", i) + } + val history = PersonalAnalyticsStore.historyFor(context, "20m") + assertThat(history).hasSize(PersonalAnalyticsStore.MAX_SESSIONS) + assertThat(history.last()).isEqualTo(PersonalAnalyticsStore.MAX_SESSIONS + 4) + assertThat(history.first()).isEqualTo(5) + } + + @Test + fun `clear wipes everything`() { + PersonalAnalyticsStore.record(context, "20m", 5000) + PersonalAnalyticsStore.clear(context) + assertThat(PersonalAnalyticsStore.historyFor(context, "20m")).isEmpty() + } + + @Test + fun `corrupt persisted json degrades to empty`() { + assertThat(PersonalAnalyticsStore.parseHistory("not json")).isEmpty() + assertThat(PersonalAnalyticsStore.parseHistory(null)).isEmpty() + val roundTrip = PersonalAnalyticsStore.encodeHistory(mapOf("20m" to listOf(1, 2))) + assertThat(PersonalAnalyticsStore.parseHistory(roundTrip)).containsExactly("20m", listOf(1, 2)) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsTest.kt new file mode 100644 index 000000000..afc5d8b50 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/personal/PersonalAnalyticsTest.kt @@ -0,0 +1,135 @@ +package radio.ks3ckc.ft8af.bandadvisor.personal + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8af.pskreporter.PskReporterSpot + +class PersonalAnalyticsTest { + // Observer in EM28 (Kansas-ish). + private val myLat = 38.5 + private val myLon = -95.0 + + private fun spot( + callsign: String, + grid: String, + lat: Double, + lon: Double, + freqHz: Long = 14_074_000, + snr: Int = -10, + flow: Long = 1_000, + ) = PskReporterSpot(callsign, grid, lat, lon, freqHz, snr, "FT8", flow) + + private val bandOf: (Long) -> String? = { freq -> + when (freq) { + in 14_000_000L..14_350_000L -> "20m" + in 7_000_000L..7_300_000L -> "40m" + else -> null + } + } + + @Test + fun `computes per-band and overall stats`() { + val spots = listOf( + spot("DL1ABC", "JO62", 52.5, 13.4, snr = -3, flow = 3_000), // Berlin ~7900 km + spot("G4XYZ", "IO91", 51.5, -0.1, snr = -11, flow = 2_000), // London + spot("W1AW", "FN31", 41.7, -72.7, snr = -20, flow = 1_000), // Connecticut + spot("W9ABC", "EN52", 42.0, -88.0, freqHz = 7_074_000, snr = -5, flow = 4_000), + ) + val report = computePersonalAnalytics(spots, myLat, myLon, bandOf) { call -> + when { + call.startsWith("DL") -> "Germany" + call.startsWith("G") -> "England" + else -> "United States" + } + } + + assertThat(report.perBand.keys).containsExactly("20m", "40m") + val band20 = report.perBand["20m"]!! + assertThat(band20.reports).isEqualTo(3) + assertThat(band20.uniqueReceivers).isEqualTo(3) + assertThat(band20.countriesReached).isEqualTo(3) + assertThat(band20.bestSnrDb).isEqualTo(-3) + assertThat(band20.medianSnrDb).isEqualTo(-11) + // Berlin is the farthest 20m receiver: ~7900 km from EM28. + assertThat(band20.maxDistanceKm).isAtLeast(7_000) + assertThat(band20.maxDistanceKm).isAtMost(8_500) + assertThat(band20.mostRecentReportEpochSec).isEqualTo(3_000) + // Europe (NE octant) + domestic east: at least 2 direction octants. + assertThat(band20.directionOctants).isAtLeast(2) + + assertThat(report.overall.reports).isEqualTo(4) + assertThat(report.overall.uniqueGridFields).isEqualTo(4) + } + + @Test + fun `spots on unmapped frequencies are dropped`() { + val report = computePersonalAnalytics( + listOf(spot("K0AAA", "EM48", 38.6, -90.2, freqHz = 999_000)), + myLat, myLon, bandOf, + ) + assertThat(report.overall.reports).isEqualTo(0) + assertThat(report.perBand).isEmpty() + } + + @Test + fun `empty input produces an explicit empty state`() { + val report = computePersonalAnalytics(emptyList(), myLat, myLon, bandOf) + assertThat(report.overall.reports).isEqualTo(0) + assertThat(report.overall.bestSnrDb).isNull() + assertThat(report.overall.mostRecentReportEpochSec).isNull() + } + + @Test + fun `duplicate receivers count once`() { + val spots = listOf( + spot("W1AW", "FN31", 41.7, -72.7, flow = 1), + spot("w1aw", "FN31", 41.7, -72.7, flow = 2), + ) + val report = computePersonalAnalytics(spots, myLat, myLon, bandOf) + assertThat(report.overall.reports).isEqualTo(2) + assertThat(report.overall.uniqueReceivers).isEqualTo(1) + } + + @Test + fun `baseline comparison suppressed for small current samples`() { + assertThat(baselineComparison(MIN_REPORTS_FOR_COMPARISON - 1, 9000, listOf(5000, 6000, 5500))) + .isNull() + } + + @Test + fun `baseline comparison suppressed without enough history`() { + assertThat(baselineComparison(20, 9000, listOf(5000, 6000))).isNull() + assertThat(baselineComparison(20, 9000, emptyList())).isNull() + } + + @Test + fun `baseline comparison is a signed fraction of the historical median`() { + // Median of history = 6000; 7200 is +20%. + val up = baselineComparison(20, 7200, listOf(5000, 6000, 7000))!! + assertThat(up).isWithin(1e-9).of(0.2) + val down = baselineComparison(20, 3000, listOf(5000, 6000, 7000))!! + assertThat(down).isWithin(1e-9).of(-0.5) + } + + @Test + fun `median handles odd and even sizes`() { + assertThat(medianOf(listOf(3.0, 1.0, 2.0))).isEqualTo(2.0) + assertThat(medianOf(listOf(4.0, 1.0, 2.0, 3.0))).isEqualTo(2.5) + } + + @Test + fun `haversine sanity`() { + // EM28 to Berlin ~ 7,900 km; to itself 0. + assertThat(haversineKm(myLat, myLon, 52.5, 13.4)).isWithin(300.0).of(7_900.0) + assertThat(haversineKm(myLat, myLon, myLat, myLon)).isWithin(1e-9).of(0.0) + } + + @Test + fun `azimuth octants bucket correctly`() { + assertThat(azimuthOctant(0.0, 0.0, 10.0, 0.0)).isEqualTo(0) // due north + assertThat(azimuthOctant(0.0, 0.0, 0.0, 10.0)).isEqualTo(2) // due east + assertThat(azimuthOctant(0.0, 0.0, -10.0, 0.0)).isEqualTo(4) // due south + assertThat(azimuthOctant(0.0, 0.0, 0.0, -10.0)).isEqualTo(6) // due west + assertThat(azimuthOctant(1.0, 1.0, 1.0, 1.0)).isEqualTo(0) // degenerate + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorerTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorerTest.kt new file mode 100644 index 000000000..5f2e16fcd --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/bandadvisor/scoring/BandScorerTest.kt @@ -0,0 +1,168 @@ +package radio.ks3ckc.ft8af.bandadvisor.scoring + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal + +class BandScorerTest { + + private fun fullConditions( + band: String = "20m", + freq: Long = 14_074_000, + rel: Double = 0.8, + nearby: Double = 0.7, + activity: Double = 0.6, + distance: Double = 0.5, + trend: Double = 0.5, + personal: Double? = 0.6, + diversity: Double = 0.5, + targetRel: Double = 0.7, + targetPaths: Double = 0.6, + ) = BandConditions( + band = band, + frequencyHz = freq, + voacapReliability = rel, + nearbyObservedSuccess = nearby, + normalizedLiveActivity = activity, + distancePotential = distance, + activityTrend = trend, + personalPerformance = personal, + directionalDiversity = diversity, + targetReliability = targetRel, + targetObservedPaths = targetPaths, + ) + + @Test + fun `base weights sum to one for every goal`() { + for (goal in OperatingGoal.entries) { + assertThat(weightsFor(goal).values.sum()).isWithin(1e-9).of(1.0) + } + } + + @Test + fun `full-input score matches the documented weighted sum`() { + val scored = scoreBand(fullConditions(), OperatingGoal.MAKE_CONTACT) + val expected = 0.30 * 0.8 + 0.25 * 0.7 + 0.15 * 0.6 + 0.10 * 0.5 + 0.10 * 0.5 + 0.10 * 0.6 + assertThat(scored.score).isWithin(1e-9).of(expected) + assertThat(scored.components).hasSize(6) + // Components explain the score exactly. + assertThat(scored.components.sumOf { it.contribution }).isWithin(1e-9).of(scored.score) + assertThat(scored.components.sumOf { it.weight }).isWithin(1e-9).of(1.0) + } + + @Test + fun `missing personal component renormalizes remaining weights`() { + val without = scoreBand( + fullConditions(personal = null), + OperatingGoal.MAKE_CONTACT, + ) + // Weights renormalize to sum 1 across the five remaining components. + assertThat(without.components.sumOf { it.weight }).isWithin(1e-9).of(1.0) + assertThat(without.components.map { it.name }) + .doesNotContain(ComponentNames.PERSONAL_PERFORMANCE) + val expected = (0.30 * 0.8 + 0.25 * 0.7 + 0.15 * 0.6 + 0.10 * 0.5 + 0.10 * 0.5) / 0.90 + assertThat(without.score).isWithin(1e-9).of(expected) + } + + @Test + fun `all inputs missing scores zero with no components`() { + val scored = scoreBand( + BandConditions(band = "20m", frequencyHz = 14_074_000), + OperatingGoal.DX, + ) + assertThat(scored.score).isEqualTo(0.0) + assertThat(scored.components).isEmpty() + } + + @Test + fun `goal weighting changes the winner`() { + // Reliable-but-short paths vs long-but-thin paths. + val regional = fullConditions( + band = "40m", freq = 7_074_000, + rel = 0.9, nearby = 0.9, activity = 0.8, distance = 0.2, diversity = 0.3, + ) + val dxBand = fullConditions( + band = "10m", freq = 28_074_000, + rel = 0.6, nearby = 0.4, activity = 0.4, distance = 0.95, diversity = 0.9, + ) + val contactRanking = rankBands(listOf(regional, dxBand), OperatingGoal.MAKE_CONTACT) + val dxRanking = rankBands(listOf(regional, dxBand), OperatingGoal.DX) + assertThat(contactRanking.first().conditions.band).isEqualTo("40m") + assertThat(dxRanking.first().conditions.band).isEqualTo("10m") + } + + @Test + fun `values are clamped to the unit interval`() { + val scored = scoreBand( + fullConditions(rel = 1.5, nearby = -0.3), + OperatingGoal.MAKE_CONTACT, + ) + val relComponent = scored.components.first { it.name == ComponentNames.VOACAP_RELIABILITY } + val nearbyComponent = scored.components.first { it.name == ComponentNames.NEARBY_OBSERVED_SUCCESS } + assertThat(relComponent.value).isEqualTo(1.0) + assertThat(nearbyComponent.value).isEqualTo(0.0) + } + + @Test + fun `ranking is deterministic with frequency tie-break`() { + val a = fullConditions(band = "20m", freq = 14_074_000) + val b = fullConditions(band = "17m", freq = 18_100_000) + val ranked = rankBands(listOf(b, a), OperatingGoal.MAKE_CONTACT) + assertThat(ranked.map { it.conditions.band }).containsExactly("20m", "17m").inOrder() + // Same result regardless of input order. + assertThat(rankBands(listOf(a, b), OperatingGoal.MAKE_CONTACT).map { it.conditions.band }) + .containsExactly("20m", "17m").inOrder() + } + + // ---------------- confidence ---------------- + + @Test + fun `confidence grows with observations and saturates`() { + fun conf(obs: Int) = confidenceFor( + ConfidenceInputs( + observationCount = obs, + dataAgeMs = 0, + sourceAvailability = 1.0, + modelAgreement = 1.0, + personalSampleSize = 0, + stationProfileCompleteness = 1.0, + ), + ) + assertThat(conf(0)).isLessThan(conf(25)) + assertThat(conf(25)).isLessThan(conf(50)) + assertThat(conf(50)).isEqualTo(conf(500)) // saturation + } + + @Test + fun `confidence decays with data age`() { + fun conf(ageMs: Long) = confidenceFor( + ConfidenceInputs(50, ageMs, 1.0, 1.0, 0, 1.0), + ) + assertThat(conf(0)).isGreaterThan(conf(DATA_AGE_ZERO_MS / 2)) + assertThat(conf(DATA_AGE_ZERO_MS / 2)).isGreaterThan(conf(DATA_AGE_ZERO_MS)) + assertThat(conf(DATA_AGE_ZERO_MS)).isEqualTo(conf(DATA_AGE_ZERO_MS * 10)) + } + + @Test + fun `disagreement between model and observations lowers confidence`() { + fun conf(agreement: Double?) = confidenceFor( + ConfidenceInputs(50, 0, 1.0, agreement, 0, 1.0), + ) + assertThat(conf(1.0)).isGreaterThan(conf(0.0)) + // Unmeasurable agreement is neutral, between the extremes. + assertThat(conf(null)).isGreaterThan(conf(0.0)) + assertThat(conf(null)).isLessThan(conf(1.0)) + } + + @Test + fun `fewer sources means less confidence and result stays in unit range`() { + fun conf(sources: Double) = confidenceFor( + ConfidenceInputs(50, 0, sources, 1.0, 20, 1.0), + ) + assertThat(conf(1.0)).isGreaterThan(conf(1.0 / 3.0)) + assertThat(conf(1.0)).isAtMost(1.0) + assertThat( + confidenceFor(ConfidenceInputs(0, Long.MAX_VALUE / 2, 0.0, 0.0, 0, 0.0)), + ).isAtLeast(0.0) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepositoryTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepositoryTest.kt new file mode 100644 index 000000000..55735c9e2 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/FeatureFlagRepositoryTest.kt @@ -0,0 +1,141 @@ +package radio.ks3ckc.ft8af.flags + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** In-memory stores so precedence tests drive every layer deterministically. */ +internal class FakeOverrideStore : FlagOverrideStore { + private val map = mutableMapOf() + override fun overrideFor(flag: FeatureFlag): FlagOverride = + map[flag] ?: FlagOverride.DEFAULT + override fun setOverride(flag: FeatureFlag, override: FlagOverride) { + map[flag] = override + } +} + +internal class FakeRemoteStore(var config: RemoteFlagConfig? = null) : RemoteFlagConfigStore { + var lastAttempt = 0L + var savedJson: String? = null + override fun cached(): RemoteFlagConfig? = config + override fun save(rawJson: String, fetchedAtMs: Long) { + savedJson = rawJson + config = parseRemoteFlagConfig(rawJson) + } + override fun lastAttemptMs(): Long = lastAttempt + override fun recordAttempt(nowMs: Long) { + lastAttempt = nowMs + } +} + +class FeatureFlagRepositoryTest { + private val now = 1_000_000_000_000L + private val overrides = FakeOverrideStore() + private val remote = FakeRemoteStore() + + private fun repo( + isDebug: Boolean = true, + default: Boolean = false, + ) = DefaultFeatureFlagRepository( + isDebugBuild = isDebug, + buildDefaults = { default }, + overrideStore = overrides, + remoteConfigStore = remote, + clock = { now }, + ) + + private fun freshRemote(vararg flags: Pair) = RemoteFlagConfig( + version = 1, + generatedAtMs = now - 60_000, + expiresAtMs = now + 60_000, + flags = flags.toMap(), + ) + + @Test + fun `build default used when nothing else present`() { + assertThat(repo(default = false).evaluate(FeatureFlag.BAND_ADVISOR)) + .isEqualTo(FlagEvaluation(FeatureFlag.BAND_ADVISOR, false, FlagValueSource.BUILD_DEFAULT)) + assertThat(repo(default = true).isEnabled(FeatureFlag.BAND_ADVISOR)).isTrue() + } + + @Test + fun `fresh remote config beats build default`() { + remote.config = freshRemote("bandAdvisor" to true) + val eval = repo(default = false).evaluate(FeatureFlag.BAND_ADVISOR) + assertThat(eval.enabled).isTrue() + assertThat(eval.source).isEqualTo(FlagValueSource.REMOTE_CONFIG) + } + + @Test + fun `expired remote config falls back to build default`() { + remote.config = RemoteFlagConfig( + version = 1, + generatedAtMs = now - 120_000, + expiresAtMs = now - 60_000, + flags = mapOf("bandAdvisor" to true), + ) + val eval = repo(default = false).evaluate(FeatureFlag.BAND_ADVISOR) + assertThat(eval.enabled).isFalse() + assertThat(eval.source).isEqualTo(FlagValueSource.BUILD_DEFAULT) + } + + @Test + fun `remote config missing a flag falls back to build default for that flag only`() { + remote.config = freshRemote("bandAdvisor" to true) + val repo = repo(default = false) + assertThat(repo.evaluate(FeatureFlag.BAND_ADVISOR).source) + .isEqualTo(FlagValueSource.REMOTE_CONFIG) + val alerts = repo.evaluate(FeatureFlag.PROPAGATION_ALERTS) + assertThat(alerts.enabled).isFalse() + assertThat(alerts.source).isEqualTo(FlagValueSource.BUILD_DEFAULT) + } + + @Test + fun `debug override beats fresh remote config`() { + remote.config = freshRemote("bandAdvisor" to true) + overrides.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.FORCED_OFF) + val eval = repo(default = true).evaluate(FeatureFlag.BAND_ADVISOR) + assertThat(eval.enabled).isFalse() + assertThat(eval.source).isEqualTo(FlagValueSource.DEBUG_OVERRIDE) + } + + @Test + fun `forced on override enables a default-off flag`() { + overrides.setOverride(FeatureFlag.PROPAGATION_ALERTS, FlagOverride.FORCED_ON) + val eval = repo(default = false).evaluate(FeatureFlag.PROPAGATION_ALERTS) + assertThat(eval.enabled).isTrue() + assertThat(eval.source).isEqualTo(FlagValueSource.DEBUG_OVERRIDE) + } + + @Test + fun `override cleared back to DEFAULT falls through to next layer`() { + remote.config = freshRemote("bandAdvisor" to true) + overrides.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.FORCED_OFF) + overrides.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.DEFAULT) + assertThat(repo(default = false).evaluate(FeatureFlag.BAND_ADVISOR).source) + .isEqualTo(FlagValueSource.REMOTE_CONFIG) + } + + @Test + fun `release build ignores developer overrides entirely`() { + overrides.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.FORCED_ON) + val eval = repo(isDebug = false, default = false).evaluate(FeatureFlag.BAND_ADVISOR) + assertThat(eval.enabled).isFalse() + assertThat(eval.source).isEqualTo(FlagValueSource.BUILD_DEFAULT) + } + + @Test + fun `release build still honors remote config`() { + remote.config = freshRemote("personalPskAnalytics" to true) + val eval = repo(isDebug = false, default = false) + .evaluate(FeatureFlag.PERSONAL_PSK_ANALYTICS) + assertThat(eval.enabled).isTrue() + assertThat(eval.source).isEqualTo(FlagValueSource.REMOTE_CONFIG) + } + + @Test + fun `unknown remote flag names are ignored`() { + remote.config = freshRemote("someFutureFlag" to true) + val eval = repo(default = false).evaluate(FeatureFlag.BAND_ADVISOR) + assertThat(eval.source).isEqualTo(FlagValueSource.BUILD_DEFAULT) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcherTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcherTest.kt new file mode 100644 index 000000000..bc1a7c4f4 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigFetcherTest.kt @@ -0,0 +1,111 @@ +package radio.ks3ckc.ft8af.flags + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Robolectric for android.util.Log + org.json inside the fetcher. */ +@RunWith(RobolectricTestRunner::class) +class RemoteFlagConfigFetcherTest { + private lateinit var server: MockWebServer + private lateinit var store: FakeRemoteStore + private var now = 1_700_000_000_000L + + private val validBody = """ + {"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z", + "flags":{"bandAdvisor":true}} + """.trimIndent() + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + store = FakeRemoteStore() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun fetcher() = RemoteFlagConfigFetcher( + store = store, + clock = { now }, + baseUrl = server.url("/api/feature-config").toString(), + ) + + @Test + fun `caches a valid payload`() = runBlocking { + server.enqueue(MockResponse().setBody(validBody)) + fetcher().refreshIfDue() + assertThat(store.savedJson).isEqualTo(validBody) + assertThat(store.config!!.valueFor(FeatureFlag.BAND_ADVISOR)).isTrue() + assertThat(store.lastAttempt).isEqualTo(now) + } + + @Test + fun `invalid payload is rejected and cache untouched`() = runBlocking { + server.enqueue(MockResponse().setBody("""{"version": 99}""")) + fetcher().refreshIfDue() + assertThat(store.savedJson).isNull() + assertThat(store.config).isNull() + // The attempt is still recorded so a broken endpoint isn't hammered. + assertThat(store.lastAttempt).isEqualTo(now) + } + + @Test + fun `http error leaves cache untouched but records the attempt`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + fetcher().refreshIfDue() + assertThat(store.savedJson).isNull() + assertThat(store.lastAttempt).isEqualTo(now) + } + + @Test + fun `no fetch while cache is fresh`() = runBlocking { + store.config = RemoteFlagConfig(1, now - 1000, now + 60_000, emptyMap()) + fetcher().refreshIfDue() + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `expired cache triggers a refetch`() = runBlocking { + store.config = RemoteFlagConfig(1, now - 120_000, now - 60_000, emptyMap()) + server.enqueue(MockResponse().setBody(validBody)) + fetcher().refreshIfDue() + assertThat(server.requestCount).isEqualTo(1) + assertThat(store.savedJson).isEqualTo(validBody) + } + + @Test + fun `attempts are rate limited even when cache is missing`() = runBlocking { + store.lastAttempt = now - (REMOTE_FLAG_MIN_ATTEMPT_INTERVAL_MS - 1) + fetcher().refreshIfDue() + assertThat(server.requestCount).isEqualTo(0) + + store.lastAttempt = now - REMOTE_FLAG_MIN_ATTEMPT_INTERVAL_MS + server.enqueue(MockResponse().setBody(validBody)) + fetcher().refreshIfDue() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `shouldFetch decision table`() { + val fresh = RemoteFlagConfig(1, 0, now + 1, emptyMap()) + val expired = RemoteFlagConfig(1, 0, now - 1, emptyMap()) + // No cache, never attempted -> fetch. + assertThat(shouldFetchRemoteFlagConfig(null, 0L, now)).isTrue() + // Fresh cache -> no fetch. + assertThat(shouldFetchRemoteFlagConfig(fresh, 0L, now)).isFalse() + // Expired cache -> fetch (attempt interval permitting). + assertThat(shouldFetchRemoteFlagConfig(expired, 0L, now)).isTrue() + assertThat( + shouldFetchRemoteFlagConfig(expired, now - 1000, now), + ).isFalse() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigTest.kt new file mode 100644 index 000000000..241da4757 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/RemoteFlagConfigTest.kt @@ -0,0 +1,104 @@ +package radio.ks3ckc.ft8af.flags + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** Robolectric for org.json (a stub in plain JVM unit tests). */ +@RunWith(RobolectricTestRunner::class) +class RemoteFlagConfigTest { + + private val valid = """ + { + "version": 1, + "generatedAt": "2026-09-27T12:00:00Z", + "expiresAt": "2026-09-27T18:00:00Z", + "flags": { + "bandAdvisor": true, + "personalPskAnalytics": false, + "propagationAlerts": true + } + } + """.trimIndent() + + @Test + fun `parses a valid payload`() { + val config = parseRemoteFlagConfig(valid)!! + assertThat(config.version).isEqualTo(1) + assertThat(config.valueFor(FeatureFlag.BAND_ADVISOR)).isTrue() + assertThat(config.valueFor(FeatureFlag.PERSONAL_PSK_ANALYTICS)).isFalse() + assertThat(config.expiresAtMs - config.generatedAtMs).isEqualTo(6L * 3600 * 1000) + } + + @Test + fun `freshness follows expiresAt`() { + val config = parseRemoteFlagConfig(valid)!! + assertThat(config.isFresh(config.expiresAtMs - 1)).isTrue() + assertThat(config.isFresh(config.expiresAtMs)).isFalse() + } + + @Test + fun `rejects malformed json`() { + assertThat(parseRemoteFlagConfig("not json")).isNull() + assertThat(parseRemoteFlagConfig("")).isNull() + assertThat(parseRemoteFlagConfig("[]")).isNull() + } + + @Test + fun `rejects missing or unsupported version`() { + assertThat(parseRemoteFlagConfig(valid.replace("\"version\": 1", "\"version\": 99"))).isNull() + assertThat(parseRemoteFlagConfig(valid.replace("\"version\": 1,", ""))).isNull() + assertThat(parseRemoteFlagConfig(valid.replace("\"version\": 1", "\"version\": 0"))).isNull() + } + + @Test + fun `rejects bad timestamps`() { + assertThat( + parseRemoteFlagConfig(valid.replace("2026-09-27T12:00:00Z", "yesterday")), + ).isNull() + // Expiry must be after generation. + assertThat( + parseRemoteFlagConfig(valid.replace("2026-09-27T18:00:00Z", "2026-09-27T11:00:00Z")), + ).isNull() + } + + @Test + fun `rejects missing flags object`() { + val noFlags = """{"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z"}""" + assertThat(parseRemoteFlagConfig(noFlags)).isNull() + } + + @Test + fun `drops non-boolean flag values instead of coercing`() { + val mixed = """ + {"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z", + "flags":{"bandAdvisor":"yes","propagationAlerts":true}} + """.trimIndent() + val config = parseRemoteFlagConfig(mixed)!! + assertThat(config.valueFor(FeatureFlag.BAND_ADVISOR)).isNull() + assertThat(config.valueFor(FeatureFlag.PROPAGATION_ALERTS)).isTrue() + } + + @Test + fun `unknown flag names survive parsing but are simply never looked up`() { + val future = """ + {"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z", + "flags":{"someFutureFeature":true}} + """.trimIndent() + val config = parseRemoteFlagConfig(future)!! + for (flag in FeatureFlag.entries) { + assertThat(config.valueFor(flag)).isNull() + } + } + + @Test + fun `iso parser handles fractional seconds and rejects garbage`() { + assertThat(parseIso8601UtcMs("1970-01-01T00:00:00Z")).isEqualTo(0L) + assertThat(parseIso8601UtcMs("1970-01-01T00:00:01.500Z")).isEqualTo(1000L) + assertThat(parseIso8601UtcMs("2026-09-27T12:00:00Zjunk")).isNull() + assertThat(parseIso8601UtcMs(null)).isNull() + assertThat(parseIso8601UtcMs("")).isNull() + assertThat(parseIso8601UtcMs("2026-13-45T99:00:00Z")).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStoresTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStoresTest.kt new file mode 100644 index 000000000..b558b98e0 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/flags/SharedPrefsFlagStoresTest.kt @@ -0,0 +1,61 @@ +package radio.ks3ckc.ft8af.flags + +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SharedPrefsFlagStoresTest { + private val context = ApplicationProvider.getApplicationContext() + + private val validBody = """ + {"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z", + "flags":{"bandAdvisor":true}} + """.trimIndent() + + @Test + fun `override round-trips and clears`() { + val store = SharedPrefsFlagOverrideStore(context) + assertThat(store.overrideFor(FeatureFlag.BAND_ADVISOR)).isEqualTo(FlagOverride.DEFAULT) + + store.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.FORCED_ON) + assertThat(store.overrideFor(FeatureFlag.BAND_ADVISOR)).isEqualTo(FlagOverride.FORCED_ON) + // A second store instance sees the persisted value. + assertThat(SharedPrefsFlagOverrideStore(context).overrideFor(FeatureFlag.BAND_ADVISOR)) + .isEqualTo(FlagOverride.FORCED_ON) + // Other flags are unaffected. + assertThat(store.overrideFor(FeatureFlag.PROPAGATION_ALERTS)).isEqualTo(FlagOverride.DEFAULT) + + store.setOverride(FeatureFlag.BAND_ADVISOR, FlagOverride.DEFAULT) + assertThat(store.overrideFor(FeatureFlag.BAND_ADVISOR)).isEqualTo(FlagOverride.DEFAULT) + } + + @Test + fun `remote config round-trips`() { + val store = SharedPrefsRemoteFlagConfigStore(context) + assertThat(store.cached()).isNull() + + store.save(validBody, 123L) + val cached = store.cached()!! + assertThat(cached.valueFor(FeatureFlag.BAND_ADVISOR)).isTrue() + assertThat(SharedPrefsRemoteFlagConfigStore(context).cached()).isEqualTo(cached) + } + + @Test + fun `invalid payload is never cached over a valid one`() { + val store = SharedPrefsRemoteFlagConfigStore(context) + store.save(validBody, 123L) + store.save("garbage", 456L) + assertThat(store.cached()!!.valueFor(FeatureFlag.BAND_ADVISOR)).isTrue() + } + + @Test + fun `attempt timestamps round-trip`() { + val store = SharedPrefsRemoteFlagConfigStore(context) + assertThat(store.lastAttemptMs()).isEqualTo(0L) + store.recordAttempt(789L) + assertThat(store.lastAttemptMs()).isEqualTo(789L) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateHolderTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateHolderTest.kt new file mode 100644 index 000000000..3dfff8246 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateHolderTest.kt @@ -0,0 +1,60 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.GeneralVariables +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import radio.ks3ckc.ft8af.bandadvisor.BandAdvisor +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FeatureFlagRepository +import radio.ks3ckc.ft8af.flags.FeatureFlags +import radio.ks3ckc.ft8af.flags.FlagEvaluation +import radio.ks3ckc.ft8af.flags.FlagValueSource + +@RunWith(RobolectricTestRunner::class) +class BandAdvisorStateHolderTest { + private val context = ApplicationProvider.getApplicationContext() + + @After + fun tearDown() { + FeatureFlags.setDelegateForTests(null) + BandAdvisor.setUseFixtures(context, false) + GeneralVariables.setMyMaidenheadGrid("") + } + + @Test + fun `goal and target changes replace pending request and persist selection`() = + runBlocking { + FeatureFlags.setDelegateForTests( + object : FeatureFlagRepository { + override fun isEnabled(flag: FeatureFlag) = true + + override fun evaluate(flag: FeatureFlag) = FlagEvaluation(flag, true, FlagValueSource.BUILD_DEFAULT) + }, + ) + BandAdvisor.setUseFixtures(context, true) + GeneralVariables.setMyMaidenheadGrid("EM28") + val holder = BandAdvisorStateHolder(context) + holder.load(this, force = false) + holder.setGoal(this, OperatingGoal.TARGET) + coroutineContext[Job]!!.children.toList().forEach { it.join() } + assertThat((holder.state as BandAdvisorUiState.Ready).recommendation.targetRegion).isEqualTo("EUROPE") + holder.setTargetRegion(this, AdvisorTargetRegion.ASIA) + coroutineContext[Job]!!.children.toList().forEach { it.join() } + assertThat((holder.state as BandAdvisorUiState.Ready).recommendation.targetRegion).isEqualTo("ASIA") + val restored = BandAdvisorStateHolder(context) + assertThat(restored.goal).isEqualTo(OperatingGoal.TARGET) + assertThat(restored.targetRegion).isEqualTo(AdvisorTargetRegion.ASIA) + holder.setTargetRegion(this, AdvisorTargetRegion.ASIA) + holder.setGoal(this, OperatingGoal.TARGET) + assertThat(coroutineContext[Job]!!.children.toList()).isEmpty() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateTest.kt new file mode 100644 index 000000000..6bb6ead68 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/BandAdvisorStateTest.kt @@ -0,0 +1,150 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.FT8Common +import com.k1af.ft8af.R +import com.k1af.ft8af.database.OperationBand +import org.junit.Test +import radio.ks3ckc.ft8af.bandadvisor.AdvisorRequest +import radio.ks3ckc.ft8af.bandadvisor.AdvisorResult +import radio.ks3ckc.ft8af.bandadvisor.UnavailableReason +import radio.ks3ckc.ft8af.bandadvisor.buildFixtureRecommendation +import radio.ks3ckc.ft8af.bandadvisor.model.AdvisorTargetRegion +import radio.ks3ckc.ft8af.bandadvisor.model.Freshness +import radio.ks3ckc.ft8af.bandadvisor.model.OperatingGoal +import radio.ks3ckc.ft8af.bandadvisor.model.PersonalAnalyticsSummary + +class BandAdvisorStateTest { + + // ---------------- uiStateOf ---------------- + + @Test + fun `available result maps to Ready`() { + val rec = buildFixtureRecommendation( + "EM28", + AdvisorRequest(grid = "EM28", callsign = "K1AF"), + nowMs = 1_790_532_000_000L, + ) + val state = uiStateOf(AdvisorResult.Available(rec, Freshness.STALE, fromCache = true)) + assertThat(state).isInstanceOf(BandAdvisorUiState.Ready::class.java) + val ready = state as BandAdvisorUiState.Ready + assertThat(ready.freshness).isEqualTo(Freshness.STALE) + assertThat(ready.fromCache).isTrue() + } + + @Test + fun `NO_GRID maps to its own state, others to Unavailable`() { + assertThat(uiStateOf(AdvisorResult.Unavailable(UnavailableReason.NO_GRID))) + .isEqualTo(BandAdvisorUiState.NoGrid) + val offline = uiStateOf(AdvisorResult.Unavailable(UnavailableReason.OFFLINE)) + assertThat((offline as BandAdvisorUiState.Unavailable).reason) + .isEqualTo(UnavailableReason.OFFLINE) + } + + // ---------------- advisorRequestFrom ---------------- + + @Test + fun `callsign rides along only when flag on and plausible`() { + val withPersonal = advisorRequestFrom("EM28", "k1af", true, OperatingGoal.DX, "FT8") + assertThat(withPersonal.callsign).isEqualTo("k1af") + + val flagOff = advisorRequestFrom("EM28", "k1af", false, OperatingGoal.DX, "FT8") + assertThat(flagOff.callsign).isNull() + + val garbage = advisorRequestFrom("EM28", "NOCALL", true, OperatingGoal.DX, "FT8") + assertThat(garbage.callsign).isNull() + } + + // ---------------- advisorTuneIndexFor ---------------- + + private fun band(freq: Long, wave: String, mode: Int = FT8Common.FT8_MODE) = + OperationBand.Band(freq, wave).also { it.mode = mode } + + @Test + fun `finds the exact dial for the active mode`() { + val bands = listOf( + band(7_074_000, "40m"), + band(14_074_000, "20m"), + band(14_080_000, "20m", mode = 1), // FT4 dial on the same wavelength + ) + assertThat(advisorTuneIndexFor(bands, 14_074_000, FT8Common.FT8_MODE)).isEqualTo(1) + assertThat(advisorTuneIndexFor(bands, 14_080_000, 1)).isEqualTo(2) + } + + @Test + fun `unknown frequency or wrong mode returns null - never invents dials`() { + val bands = listOf(band(14_074_000, "20m")) + assertThat(advisorTuneIndexFor(bands, 14_075_000, FT8Common.FT8_MODE)).isNull() + assertThat(advisorTuneIndexFor(bands, 14_074_000, 1)).isNull() + assertThat(advisorTuneIndexFor(emptyList(), 14_074_000, FT8Common.FT8_MODE)).isNull() + } + + // ---------------- labels / formatting ---------------- + + @Test + fun `rating buckets`() { + assertThat(ratingLabelRes(0.9)).isEqualTo(R.string.band_advisor_rating_excellent) + assertThat(ratingLabelRes(0.75)).isEqualTo(R.string.band_advisor_rating_excellent) + assertThat(ratingLabelRes(0.6)).isEqualTo(R.string.band_advisor_rating_good) + assertThat(ratingLabelRes(0.4)).isEqualTo(R.string.band_advisor_rating_fair) + assertThat(ratingLabelRes(0.1)).isEqualTo(R.string.band_advisor_rating_poor) + } + + @Test + fun `age minutes clamps negative clock skew`() { + assertThat(ageMinutes(1_000_000, 1_000_000 + 3 * 60_000)).isEqualTo(3) + assertThat(ageMinutes(1_000_000, 1_000_000)).isEqualTo(0) + assertThat(ageMinutes(2_000_000, 1_000_000)).isEqualTo(0) + } + + @Test + fun `goal labels cover every goal`() { + val labels = OperatingGoal.entries.map { goalLabelRes(it) } + assertThat(labels.toSet()).hasSize(OperatingGoal.entries.size) + } + + // ---------------- personalPanelFromSummary ---------------- + + @Test + fun `server personal summary maps to panel`() { + val panel = personalPanelFromSummary( + PersonalAnalyticsSummary( + enabled = true, + reportsReceived = 23, + uniqueReceivers = 18, + countriesReached = 7, + maximumDistanceKm = 8420, + medianSnrDb = -11, + bestSnrDb = -3, + performanceComparedToBaseline = 0.18, + ), + ) + val ready = panel as PersonalPanelState.Ready + assertThat(ready.reports).isEqualTo(23) + assertThat(ready.vsBaseline).isWithin(1e-9).of(0.18) + } + + @Test + fun `disabled or absent summary hides the panel, zero reports is Empty`() { + assertThat(personalPanelFromSummary(null)).isNull() + assertThat(personalPanelFromSummary(PersonalAnalyticsSummary(enabled = false))) + .isNull() + assertThat( + personalPanelFromSummary(PersonalAnalyticsSummary(enabled = true, reportsReceived = 0)), + ).isEqualTo(PersonalPanelState.Empty) + } + + @Test + fun `target requests include selected region and other goals omit it`() { + for (region in AdvisorTargetRegion.entries) { + val request = advisorRequestFrom("EM28", null, false, OperatingGoal.TARGET, "FT8", region) + assertThat(request.targetRegion).isEqualTo(region.name) + } + assertThat(advisorRequestFrom("EM28", null, false, OperatingGoal.TARGET, "FT8").targetRegion) + .isEqualTo("EUROPE") + assertThat(advisorRequestFrom("EM28", null, false, OperatingGoal.DX, "FT8").targetRegion) + .isNull() + assertThat(AdvisorTargetRegion.entries.map { targetRegionLabelRes(it) }.toSet()) + .hasSize(AdvisorTargetRegion.entries.size) + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoaderTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoaderTest.kt new file mode 100644 index 000000000..3ef1a7161 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/bandadvisor/PersonalPanelLoaderTest.kt @@ -0,0 +1,77 @@ +package radio.ks3ckc.ft8af.ui.bandadvisor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8af.bandadvisor.personal.MIN_REPORTS_FOR_COMPARISON +import radio.ks3ckc.ft8af.bandadvisor.personal.PersonalAnalyticsReport +import radio.ks3ckc.ft8af.bandadvisor.personal.PersonalBandStats + +class PersonalPanelLoaderTest { + + private fun stats( + band: String?, + reports: Int, + maxKm: Int = 5000, + ) = PersonalBandStats( + band = band, + reports = reports, + uniqueReceivers = reports, + uniqueGridFields = reports, + countriesReached = 2, + maxDistanceKm = maxKm, + medianDistanceKm = maxKm / 2, + bestSnrDb = -5, + medianSnrDb = -12, + directionOctants = 3, + mostRecentReportEpochSec = 1_000L, + ) + + @Test + fun `empty report maps to Empty`() { + val report = PersonalAnalyticsReport(stats(null, 0), emptyMap()) + assertThat(personalPanelFromReport(report) { emptyList() }) + .isEqualTo(PersonalPanelState.Empty) + } + + @Test + fun `busiest band drives the baseline comparison`() { + val report = PersonalAnalyticsReport( + overall = stats(null, 25), + perBand = mapOf( + "40m" to stats("40m", 5, maxKm = 2_000), + "20m" to stats("20m", 20, maxKm = 6_000), + ), + ) + var asked: String? = null + val panel = personalPanelFromReport(report) { band -> + asked = band + listOf(4_000, 5_000, 6_000) // median 5000 -> +20% + } as PersonalPanelState.Ready + assertThat(asked).isEqualTo("20m") + assertThat(panel.vsBaseline!!).isWithin(1e-9).of(0.2) + assertThat(panel.reports).isEqualTo(25) + } + + @Test + fun `small sample suppresses the comparison but keeps the stats`() { + val report = PersonalAnalyticsReport( + overall = stats(null, MIN_REPORTS_FOR_COMPARISON - 1), + perBand = mapOf("20m" to stats("20m", MIN_REPORTS_FOR_COMPARISON - 1)), + ) + val panel = personalPanelFromReport(report) { + listOf(4_000, 5_000, 6_000) + } as PersonalPanelState.Ready + assertThat(panel.vsBaseline).isNull() + assertThat(panel.reports).isEqualTo(MIN_REPORTS_FOR_COMPARISON - 1) + } + + @Test + fun `insufficient history suppresses the comparison`() { + val report = PersonalAnalyticsReport( + overall = stats(null, 25), + perBand = mapOf("20m" to stats("20m", 25)), + ) + val panel = personalPanelFromReport(report) { listOf(5_000) } as PersonalPanelState.Ready + assertThat(panel.vsBaseline).isNull() + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettingsTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettingsTest.kt new file mode 100644 index 000000000..86966b5e8 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/BandAdvisorSettingsTest.kt @@ -0,0 +1,29 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import radio.ks3ckc.ft8af.bandadvisor.alerts.AlertType + +class BandAdvisorSettingsTest { + + @Test + fun `toggled adds and removes`() { + assertThat(toggled(setOf("10m"), "15m")).containsExactly("10m", "15m") + assertThat(toggled(setOf("10m", "15m"), "15m")).containsExactly("10m") + assertThat(toggled(emptySet(), "20m")).containsExactly("20m") + } + + @Test + fun `every alert type has a distinct label`() { + val labels = AlertType.entries.map { alertTypeLabelRes(it) } + assertThat(labels.toSet()).hasSize(AlertType.entries.size) + } + + @Test + fun `region choices match the server region enum names`() { + for (region in ALERT_REGION_CHOICES) { + assertThat(region).matches("[A-Z_]+") + } + assertThat(ALERT_BAND_CHOICES).contains("20m") + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreenTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreenTest.kt new file mode 100644 index 000000000..24d70aae3 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FeatureFlagScreenTest.kt @@ -0,0 +1,38 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import com.k1af.ft8af.R +import org.junit.Test +import radio.ks3ckc.ft8af.flags.FeatureFlag +import radio.ks3ckc.ft8af.flags.FlagEvaluation +import radio.ks3ckc.ft8af.flags.FlagOverride +import radio.ks3ckc.ft8af.flags.FlagValueSource + +class FeatureFlagScreenTest { + + @Test + fun `effective line reflects value and source`() { + val onFromRemote = effectiveValueLine( + FlagEvaluation(FeatureFlag.BAND_ADVISOR, true, FlagValueSource.REMOTE_CONFIG), + ) + assertThat(onFromRemote.first).isEqualTo(R.string.flags_value_on) + assertThat(onFromRemote.second).isEqualTo(R.string.flags_source_remote) + + val offFromOverride = effectiveValueLine( + FlagEvaluation(FeatureFlag.BAND_ADVISOR, false, FlagValueSource.DEBUG_OVERRIDE), + ) + assertThat(offFromOverride.first).isEqualTo(R.string.flags_value_off) + assertThat(offFromOverride.second).isEqualTo(R.string.flags_source_override) + + val fromDefault = effectiveValueLine( + FlagEvaluation(FeatureFlag.PROPAGATION_ALERTS, false, FlagValueSource.BUILD_DEFAULT), + ) + assertThat(fromDefault.second).isEqualTo(R.string.flags_source_default) + } + + @Test + fun `every tri-state option has a distinct label`() { + val labels = FlagOverride.entries.map { overrideOptionLabelRes(it) } + assertThat(labels.toSet()).hasSize(FlagOverride.entries.size) + } +} diff --git a/server/band-advisor/.gitignore b/server/band-advisor/.gitignore new file mode 100644 index 000000000..e1736e939 --- /dev/null +++ b/server/band-advisor/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ diff --git a/server/band-advisor/Dockerfile b/server/band-advisor/Dockerfile new file mode 100644 index 000000000..b91b98c04 --- /dev/null +++ b/server/band-advisor/Dockerfile @@ -0,0 +1,65 @@ +# ft8af-band-advisor +# +# Stage 1 builds voacapl (James Watson's GPL Linux port of NTIA/ITS VOACAP, +# https://github.com/jawatson/voacapl) from source. It is invoked by the app +# as an EXTERNAL binary via subprocess — never linked or vendored. +# +# NOTE(scaffold): the voacapl build follows the project's autotools + gfortran +# conventions (./configure && make && make install, then `make makeitshfbc` +# to install the itshfbc data tree). Verify against the repo README when +# first building this image; pin VOACAPL_REF to a release tag for +# reproducibility. + +# ----------------------------------------------------------------- stage 1 +FROM debian:bookworm-slim AS voacapl-build + +ARG VOACAPL_REF=master + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates build-essential gfortran automake autoconf make \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +RUN git clone --depth 1 --branch "${VOACAPL_REF}" \ + https://github.com/jawatson/voacapl.git + +WORKDIR /build/voacapl +# voacapl ships an autotools build; makeitshfbc installs the itshfbc data +# directory (ionospheric coefficients, antenna files) into $HOME/itshfbc. +RUN ./configure && make && make install \ + && HOME=/build make makeitshfbc + +# ----------------------------------------------------------------- stage 2 +FROM python:3.12-slim AS runtime + +# gfortran runtime library for the voacapl binary +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgfortran5 curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --create-home --uid 10001 advisor + +COPY --from=voacapl-build /usr/local/bin/voacapl /usr/local/bin/voacapl +COPY --from=voacapl-build /build/itshfbc /opt/itshfbc + +WORKDIR /srv/band-advisor +COPY pyproject.toml README.md ./ +COPY app ./app +RUN pip install --no-cache-dir . + +# voacapl writes run decks under /run — that dir must be writable. +RUN mkdir -p /opt/itshfbc/run /var/lib/band-advisor /etc/band-advisor \ + && chown -R advisor:advisor /opt/itshfbc/run /var/lib/band-advisor + +ENV VOACAPL_PATH=/usr/local/bin/voacapl \ + ITSHFBC_PATH=/opt/itshfbc \ + FEATURE_CONFIG_PATH=/etc/band-advisor/feature-config.json \ + BASELINE_PATH=/var/lib/band-advisor/baseline.json + +USER advisor +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/server/band-advisor/README.md b/server/band-advisor/README.md new file mode 100644 index 000000000..22d413087 --- /dev/null +++ b/server/band-advisor/README.md @@ -0,0 +1,226 @@ +# ft8af-band-advisor + +Server-side scaffold for the FT8AF **Band Advisor** feature: a Python 3.11+ +FastAPI service that fuses **VOACAP propagation predictions** with +**centralized PSK Reporter observations** into a deterministic per-band +recommendation for the mobile apps. Runs in a Linux container behind the +ft8af.app API. + +**License note:** predictions come from `voacapl` +(, James Watson's GPL Linux port of +NTIA/ITS VOACAP), invoked strictly as an **external tool** via subprocess. +No VOACAP/voacapl code is copied, vendored, or linked into this service; the +Dockerfile builds it from source in a separate stage. + +## Architecture + +``` +app/ + api.py FastAPI endpoints, validation, error contract, JSON logs + recommendation.py orchestration + the client-facing response contract + scoring.py pure weighted scoring + separate confidence function + config.py env-var configuration + geo.py Maidenhead grids, haversine, azimuths (pure) + bands.py the 10 FT8 dial frequencies + band mapping + ttl_cache.py small in-memory TTL cache (optional JSON persistence) + voacap/ + input_builder.py voacapx.dat card decks (power/antenna/noise classes) + ft8_profile.py FT8 REQ.SNR profile — the dB vs dB-Hz conversion + runner.py subprocess wrapper (short Fortran-safe run names) + parser.py Method-30 output parser (defensive) + cache.py TTL cache keyed by the full request tuple + regions.py target region centroids + REGIONAL / LONG_DX + pskreporter/ + client.py rate-limited HTTP client (gzip, defusedxml, backoff) + aggregator.py per-band stats (distance, octants, nearby-TX, quality) + baseline.py EWMA "typical activity" per (band, hour) + trend + cache.py field-granularity 5-min cache, 10-min personal cache + service.py facade used by the recommendation layer +``` + +Both upstream integrations degrade independently: if VOACAP or PSK Reporter +is down, the API still answers 200 with `sources.*Available: false`, the +missing score components are dropped (weights renormalized), and confidence +drops. An upstream outage is never a 500. + +## Endpoints + +### `GET /healthz` + +``` +curl http://localhost:8000/healthz +{"status":"ok","service":"ft8af-band-advisor","version":"0.1.0", + "voacaplPresent":true,"voacaplPath":"/usr/local/bin/voacapl"} +``` + +### `GET /v1/feature-config` + +Serves the mounted feature-flag document (`FEATURE_CONFIG_PATH`), stamped +with `generatedAt` and `expiresAt = now + FEATURE_CONFIG_TTL_S` (default 6 h): + +``` +curl http://localhost:8000/v1/feature-config +{"version":1,"generatedAt":"2026-09-27T12:00:00Z","expiresAt":"2026-09-27T18:00:00Z", + "flags":{"bandAdvisor":true,"personalPskAnalytics":true,"propagationAlerts":false}} +``` + +### `GET /v1/recommendation` + +``` +curl "http://localhost:8000/v1/recommendation?grid=EM28&goal=MAKE_CONTACT&mode=FT8&power=STANDARD&antenna=WIRE&noise=RESIDENTIAL&callsign=K1AF" +``` + +Parameters: + +| param | values | default | +|---|---|---| +| `grid` | Maidenhead; truncated to 4 chars, must match `[A-R]{2}[0-9]{2}` | required | +| `goal` | `MAKE_CONTACT`, `DX`, `TARGET`, `POTA` | `MAKE_CONTACT` | +| `mode` | `FT8` | `FT8` | +| `power` | `QRP` (5 W), `LOW` (25 W), `STANDARD` (100 W), `HIGH` (1000 W) | `STANDARD` | +| `antenna` | `ISOTROPE`, `WIRE`, `VERTICAL`, `BEAM` | `WIRE` | +| `noise` | `QUIET` (-164), `RURAL` (-154), `RESIDENTIAL` (-145), `URBAN` (-136 dBW/Hz) | `RESIDENTIAL` | +| `callsign` | valid amateur callsign; enables personal analytics | omitted | +| `targetRegion` | `EUROPE`, `NORTH_AMERICA_EAST`, `NORTH_AMERICA_WEST`, `SOUTH_AMERICA`, `AFRICA`, `ASIA`, `OCEANIA`, `REGIONAL`, `LONG_DX`; required when `goal=TARGET` | goal-dependent | + +Response: the exact contract the clients parse (see +`app/recommendation.py`; example in the repo brief). Notes: + +* `callsign` and `personalAnalytics` are **omitted** when no callsign param + was sent; `personalAnalytics` is `null` when requested but unavailable. +* `scoreComponents[].weight` are the renormalized weights (sum to 1). +* Validation failures return HTTP 400 with + `{"error":{"code":"INVALID_GRID","message":"..."}}` — deterministic codes: + `MISSING_PARAMETER`, `INVALID_GRID`, `INVALID_PARAMETER`, + `INVALID_CALLSIGN`. + +### `GET /v1/conditions?grid=EM28` + +Cheap pre-aggregated per-band summary for the mobile alert checker: +`bands[] = {band, score, confidence, activeRegions, openingEvidence}` sorted +best-first, plus `sources`. + +## The FT8 SNR conversion (why REQ.SNR = 16, not -21) + +VOACAP's REQ.SNR is in **dB-Hz** (1 Hz reference bandwidth). The famous FT8 +threshold of **-21 dB** is referenced to a **2500 Hz** SSB bandwidth +(WSJT-X reporting convention). Conversion: + +``` +-21 dB + 10*log10(2500) = -21 + 34.0 ≈ 13 dB-Hz +``` + +Defaults (configurable in `app/voacap/ft8_profile.py`): +`required_snr_dbhz = 13`, plus `snr_margin_db = 3` of real-world margin +(fading between 15 s slots, multi-signal QRM, imperfect audio chains) → +**REQ.SNR card = 16 dB-Hz**. The -21 dB figure is the *a-priori +single-decode* threshold; deep/AP decodes go a few dB lower but are not a +sound planning baseline. + +## PSK Reporter etiquette (implemented) + +* Phones never poll `retrieve.pskreporter.info` — this service is the single + **centralized** aggregation point. +* `Accept-Encoding: gzip` on every request; responses decompressed by httpx. +* `appcontact=` on every request. +* Per-parameter-set minimum interval of **5 minutes**, enforced twice: + by the cache TTL and again inside the client. +* **15-minute backoff** after HTTP 429/503. +* XML parsed with `defusedxml`; malformed XML is rejected, reports with bad + grids / absurd frequencies are dropped individually. + +## Cache keying + +| cache | key | TTL | +|---|---|---| +| VOACAP | `(tx_grid_4char, month, utc_hour, power_class, antenna_class, noise_class, target_region)` | 6 h (memory bound — predictions are stable) | +| PSK regional | `(observer_2char_FIELD, band\|ALL, mode, window)` — nearby users share entries | 5 min | +| PSK personal | `(callsign, mode, window)` | 10 min | + +One voacapl run predicts all 24 hours, so a single engine invocation +populates 24 cache entries. + +## Data retention + +Only **aggregates** are stored (per-band counts, medians, octants). The +per-callsign cache lives at most its 10-minute TTL (≤ 1 h by policy) and is +memory-only. No long-term per-user storage; logs carry at most a 4-char +grid square, never GPS coordinates, callsigns-with-locations, or tokens. + +## Configuration (environment variables) + +| var | default | meaning | +|---|---|---| +| `VOACAPL_PATH` | `/usr/local/bin/voacapl` | engine binary | +| `ITSHFBC_PATH` | `/opt/itshfbc` | VOACAP data tree (runs live in `/run/`) | +| `VOACAP_TIMEOUT_S` | `20` | subprocess timeout | +| `VOACAP_CACHE_TTL_S` | `21600` | prediction cache TTL | +| `VOACAP_CACHE_DISK_PATH` | unset | optional JSON persistence for the prediction cache | +| `SUNSPOT_NUMBER` | `68` | SSN for the SUNSPOT card (TODO: live feed) | +| `PSK_CONTACT_EMAIL` | `ops@ft8af.app` | `appcontact` parameter — set a real address | +| `PSK_TIMEOUT_S` / `PSK_WINDOW_S` | `20` / `900` | HTTP timeout / `flowStartSeconds` window | +| `PSK_CACHE_TTL_S` / `PSK_MIN_INTERVAL_S` / `PSK_BACKOFF_S` | `300` / `300` / `900` | etiquette knobs | +| `PERSONAL_CACHE_TTL_S` | `600` | per-callsign cache | +| `BASELINE_PATH` | `/var/lib/band-advisor/baseline.json` | EWMA activity baseline | +| `FEATURE_CONFIG_PATH` | `/etc/band-advisor/feature-config.json` | mounted flag document | +| `FEATURE_CONFIG_TTL_S` | `21600` | `expiresAt` horizon | +| `RECOMMENDATION_VALID_S` | `900` | `validUntil` horizon | + +## Development + +``` +cd server/band-advisor +python3 -m venv .venv && source .venv/bin/activate +pip install -e '.[dev]' +pytest +``` + +No test touches the network or a real voacapl binary — HTTP is served by +`httpx.MockTransport`, the engine by fake runners, and the parser by the +checked-in fixture `tests/fixtures/voacapx.out`. + +Run locally (upstream calls will be live — mind the PSK contact email): + +``` +uvicorn app.main:app --reload +``` + +## Deployment + +``` +docker build -t ft8af-band-advisor . +docker run -p 8000:8000 \ + -e PSK_CONTACT_EMAIL=you@example.com \ + -v $PWD/feature-config.example.json:/etc/band-advisor/feature-config.json:ro \ + ft8af-band-advisor +``` + +or `docker compose up --build`. The image is multi-stage: stage 1 builds +voacapl + the itshfbc data tree from source with gfortran/autotools; stage 2 +is `python:3.12-slim` with a non-root user and a `/healthz` HEALTHCHECK. + +**Fortran gotcha (do not "fix"):** voacapl inherits VOACAP's fixed-length +filename buffers. Run decks must live in `/run/`, be passed as +short *relative* names on the command line, and stay under ~30 characters — +hence the `ba<6 hex>.dat` run names in `app/voacap/runner.py`. + +## Not yet implemented / next steps (honest list) + +* **voacapl deck/CLI verification** — the card column layout and the + `voacapl ` invocation follow published samples and + pythonprop conventions but have not yet been validated against a real + voacapl build (tests use fixtures). Do one real run in the container and + lock a real output file in as a fixture. +* **Antenna patterns** — power classes map to generic built-in antenna cards + (isotrope/dipole/whip); `BEAM` is modelled as a dipole. Real per-band gain + files are a follow-up. +* **Live sunspot number** — `SUNSPOT_NUMBER` is a static env var; wire a + SIDC/NOAA feed. +* **DXCC lookup** — `countriesReached` is approximated by distinct receiver + Maidenhead fields; replace with a real prefix→DXCC table. +* **`frange` server-side filtering** — the PSK query currently fetches all + FT8 and filters by band locally; per-band `frange` could shrink payloads. +* **Multi-process cache coherence** — caches are per-process; run one worker + or add redis before scaling out. +* **AuthN/rate limiting for the public API**, metrics/tracing, and FT4 + support are all out of scope for this scaffold. diff --git a/server/band-advisor/app/__init__.py b/server/band-advisor/app/__init__.py new file mode 100644 index 000000000..ab7277261 --- /dev/null +++ b/server/band-advisor/app/__init__.py @@ -0,0 +1,12 @@ +"""ft8af-band-advisor: server-side Band Advisor scaffold for FT8AF. + +License note +------------ +Propagation predictions are produced by invoking ``voacapl`` (James Watson's +GPL-licensed Linux port of NTIA/ITS VOACAP, https://github.com/jawatson/voacapl) +as an EXTERNAL binary via subprocess. No VOACAP/voacapl source code is copied, +vendored, or linked into this service; the binary is built and installed +separately (see Dockerfile). +""" + +__version__ = "0.1.0" diff --git a/server/band-advisor/app/api.py b/server/band-advisor/app/api.py new file mode 100644 index 000000000..f1a004c8e --- /dev/null +++ b/server/band-advisor/app/api.py @@ -0,0 +1,262 @@ +"""FastAPI application: versioned, deterministic JSON endpoints. + +Error contract (every non-2xx body has this exact shape):: + + {"error": {"code": "", "message": ""}} + +Degradation contract: `/v1/recommendation` and `/v1/conditions` return 200 +with ``sources.*Available: false`` when an upstream is down — an upstream +outage is NEVER a 500. +""" + +from __future__ import annotations + +import json +import logging +import time +from datetime import timedelta + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from . import __version__ +from .clock import Clock, SystemClock, isoformat_z +from .config import Settings +from .geo import GridError, is_valid_callsign, normalize_grid4 +from .logging_config import configure_logging +from .pskreporter.service import PskService +from .recommendation import RecommendationRequest, RecommendationService +from .scoring import Goal +from .voacap.input_builder import AntennaClass, NoiseClass, PowerClass +from .voacap.regions import TARGET_REGION_NAMES +from .voacap.service import VoacapService + +log = logging.getLogger(__name__) + +SUPPORTED_MODES = ("FT8",) + + +class ApiError(Exception): + """Client error carrying the deterministic error body.""" + + def __init__(self, status_code: int, code: str, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.code = code + self.message = message + + +def _error_response(status_code: int, code: str, message: str) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"error": {"code": code, "message": message}}, + ) + + +def _parse_enum(kind, raw: str | None, default, param: str): + if raw is None: + return default, False + try: + return kind(raw.upper()), True + except ValueError: + valid = ", ".join(e.value for e in kind) + raise ApiError(400, "INVALID_PARAMETER", f"{param} must be one of: {valid}") + + +def create_app( + settings: Settings | None = None, + clock: Clock | None = None, + voacap_service: VoacapService | None = None, + psk_service: PskService | None = None, + recommendation_service: RecommendationService | None = None, +) -> FastAPI: + """App factory. All collaborators are injectable for tests.""" + configure_logging() + settings = settings or Settings.from_env() + clock = clock or SystemClock() + voacap = voacap_service or VoacapService(settings, clock) + psk = psk_service or PskService(settings, clock) + recommender = recommendation_service or RecommendationService( + settings, clock, voacap, psk + ) + + app = FastAPI(title="ft8af-band-advisor", version=__version__, docs_url=None) + app.state.settings = settings + app.state.clock = clock + + # ------------------------------------------------------------- middleware + + @app.middleware("http") + async def request_logging(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + grid = request.query_params.get("grid", "") + log.info( + "request", + extra={ + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "durationMs": round((time.monotonic() - start) * 1000, 1), + # privacy: only ever a 4-char square, never finer + "grid": grid.upper()[:4] if grid else None, + }, + ) + return response + + # --------------------------------------------------------------- handlers + + @app.exception_handler(ApiError) + async def handle_api_error(_req: Request, exc: ApiError): + return _error_response(exc.status_code, exc.code, exc.message) + + @app.exception_handler(RequestValidationError) + async def handle_validation(_req: Request, exc: RequestValidationError): + return _error_response(400, "INVALID_PARAMETER", "request validation failed") + + @app.exception_handler(Exception) + async def handle_unexpected(_req: Request, exc: Exception): + log.error("unhandled error", exc_info=exc) + return _error_response( + 502, "UPSTREAM_FAILURE", "an internal dependency failed" + ) + + # -------------------------------------------------------------- endpoints + + @app.get("/healthz") + async def healthz(): + return { + "status": "ok", + "service": "ft8af-band-advisor", + "version": __version__, + "voacaplPresent": voacap.binary_available(), + "voacaplPath": settings.voacapl_path, + } + + @app.get("/v1/feature-config") + async def feature_config(): + try: + document = json.loads(settings.feature_config_path.read_text()) + flags = document["flags"] + except (OSError, ValueError, KeyError) as exc: + log.error("feature config unreadable: %s", exc) + return _error_response( + 503, "FEATURE_CONFIG_UNAVAILABLE", "feature config not available" + ) + now = clock.now() + return { + "version": int(document.get("version", 1)), + "generatedAt": isoformat_z(now), + "expiresAt": isoformat_z( + now + timedelta(seconds=settings.feature_config_ttl_s) + ), + "flags": { + "bandAdvisor": bool(flags.get("bandAdvisor", False)), + "personalPskAnalytics": bool(flags.get("personalPskAnalytics", False)), + "propagationAlerts": bool(flags.get("propagationAlerts", False)), + }, + } + + @app.get("/v1/recommendation") + def recommendation( + grid: str | None = None, + goal: str | None = None, + mode: str | None = None, + power: str | None = None, + antenna: str | None = None, + noise: str | None = None, + callsign: str | None = None, + targetRegion: str | None = None, + ): + req = _build_request( + grid=grid, + goal=goal, + mode=mode, + power=power, + antenna=antenna, + noise=noise, + callsign=callsign, + target_region=targetRegion, + ) + return recommender.recommend(req) + + @app.get("/v1/conditions") + def conditions(grid: str | None = None): + if grid is None: + raise ApiError(400, "MISSING_PARAMETER", "grid is required") + try: + normalized = normalize_grid4(grid) + except GridError: + raise ApiError(400, "INVALID_GRID", "grid must match [A-R]{2}[0-9]{2}") + return recommender.conditions(normalized) + + return app + + +def _build_request( + grid: str | None, + goal: str | None, + mode: str | None, + power: str | None, + antenna: str | None, + noise: str | None, + callsign: str | None, + target_region: str | None, +) -> RecommendationRequest: + """Validate raw query params into a :class:`RecommendationRequest`.""" + if grid is None: + raise ApiError(400, "MISSING_PARAMETER", "grid is required") + try: + normalized_grid = normalize_grid4(grid) + except GridError: + raise ApiError(400, "INVALID_GRID", "grid must match [A-R]{2}[0-9]{2}") + + goal_value, _ = _parse_enum(Goal, goal, Goal.MAKE_CONTACT, "goal") + + mode_value = (mode or "FT8").upper() + if mode_value not in SUPPORTED_MODES: + raise ApiError( + 400, "INVALID_PARAMETER", f"mode must be one of: {', '.join(SUPPORTED_MODES)}" + ) + + power_value, power_given = _parse_enum(PowerClass, power, PowerClass.STANDARD, "power") + antenna_value, antenna_given = _parse_enum( + AntennaClass, antenna, AntennaClass.WIRE, "antenna" + ) + noise_value, noise_given = _parse_enum( + NoiseClass, noise, NoiseClass.RESIDENTIAL, "noise" + ) + + callsign_value: str | None = None + if callsign is not None: + callsign_value = callsign.strip().upper() + if not is_valid_callsign(callsign_value): + raise ApiError(400, "INVALID_CALLSIGN", "callsign is not a valid callsign") + + region_value: str | None = None + if target_region is not None: + region_value = target_region.strip().upper() + if region_value not in TARGET_REGION_NAMES: + raise ApiError( + 400, + "INVALID_PARAMETER", + f"targetRegion must be one of: {', '.join(TARGET_REGION_NAMES)}", + ) + if goal_value is Goal.TARGET and region_value is None: + raise ApiError(400, "MISSING_PARAMETER", "goal=TARGET requires targetRegion") + + provided = sum([power_given, antenna_given, noise_given, callsign_value is not None]) + completeness = (1 + provided) / 5.0 # grid always counts + + return RecommendationRequest( + grid=normalized_grid, + goal=goal_value, + mode=mode_value, + power=power_value, + antenna=antenna_value, + noise=noise_value, + callsign=callsign_value, + target_region=region_value, + profile_completeness=completeness, + ) diff --git a/server/band-advisor/app/bands.py b/server/band-advisor/app/bands.py new file mode 100644 index 000000000..508ae50e8 --- /dev/null +++ b/server/band-advisor/app/bands.py @@ -0,0 +1,60 @@ +"""Standard FT8 dial frequencies and band mapping. + +The 10 conventional FT8 dial frequencies (WSJT-X defaults). A reception +report's frequency maps to a band when it falls within the dial frequency +plus the ~3 kHz audio passband, widened to +-4 kHz to absorb off-dial +operation and reporting jitter. +""" + +from __future__ import annotations + +# Ordered low band -> high band. +FT8_DIAL_FREQ_HZ: dict[str, int] = { + "160m": 1_840_000, + "80m": 3_573_000, + "60m": 5_357_000, + "40m": 7_074_000, + "30m": 10_136_000, + "20m": 14_074_000, + "17m": 18_100_000, + "15m": 21_074_000, + "12m": 24_915_000, + "10m": 28_074_000, +} + +BAND_ORDER: list[str] = list(FT8_DIAL_FREQ_HZ.keys()) + +#: Frequencies for the VOACAP FREQUENCY card, in MHz. +FT8_DIAL_FREQ_MHZ: dict[str, float] = { + band: hz / 1e6 for band, hz in FT8_DIAL_FREQ_HZ.items() +} + +BAND_MATCH_TOLERANCE_HZ = 4_000 + + +def band_for_frequency_hz(freq_hz: float) -> str | None: + """Map a reported frequency to an FT8 band, or None if it is off-dial. + + Accepts dial .. dial + 4 kHz (FT8 audio sits above the dial), plus a + 4 kHz guard below the dial for below-dial reporting quirks. + """ + try: + f = float(freq_hz) + except (TypeError, ValueError): + return None + for band, dial in FT8_DIAL_FREQ_HZ.items(): + if dial - BAND_MATCH_TOLERANCE_HZ <= f <= dial + BAND_MATCH_TOLERANCE_HZ: + return band + return None + + +def band_for_frequency_mhz(freq_mhz: float, tolerance_mhz: float = 0.1) -> str | None: + """Map a VOACAP output frequency column (MHz, 1-2 decimals) to a band.""" + try: + f = float(freq_mhz) + except (TypeError, ValueError): + return None + for band, dial in FT8_DIAL_FREQ_MHZ.items(): + if abs(f - dial) <= tolerance_mhz: + return band + return None diff --git a/server/band-advisor/app/clock.py b/server/band-advisor/app/clock.py new file mode 100644 index 000000000..4fbacdef7 --- /dev/null +++ b/server/band-advisor/app/clock.py @@ -0,0 +1,62 @@ +"""Injectable clock so every time-dependent code path is testable. + +All timestamps in this service are timezone-aware UTC ``datetime`` objects. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Protocol + + +class Clock(Protocol): + """Minimal clock interface used throughout the service.""" + + def now(self) -> datetime: + """Return the current UTC time (timezone-aware).""" + ... + + def monotonic(self) -> float: + """Return a monotonically increasing number of seconds.""" + ... + + +class SystemClock: + """Real wall clock.""" + + def now(self) -> datetime: + return datetime.now(timezone.utc) + + def monotonic(self) -> float: + import time + + return time.monotonic() + + +class FixedClock: + """Deterministic clock for tests. ``advance()`` moves both time bases.""" + + def __init__(self, now: datetime) -> None: + if now.tzinfo is None: + now = now.replace(tzinfo=timezone.utc) + self._now = now + self._mono = 1000.0 + + def now(self) -> datetime: + return self._now + + def monotonic(self) -> float: + return self._mono + + def advance(self, seconds: float) -> None: + from datetime import timedelta + + self._now += timedelta(seconds=seconds) + self._mono += seconds + + +def isoformat_z(dt: datetime) -> str: + """Render a UTC datetime as ``2026-09-27T12:00:00Z`` (contract format).""" + return dt.astimezone(timezone.utc).replace(microsecond=0).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) diff --git a/server/band-advisor/app/config.py b/server/band-advisor/app/config.py new file mode 100644 index 000000000..6ec42454c --- /dev/null +++ b/server/band-advisor/app/config.py @@ -0,0 +1,96 @@ +"""Service configuration from environment variables (12-factor style). + +Plain ``os.environ`` with typed defaults rather than pydantic-settings, to +keep the dependency surface small. All knobs are documented in the README. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + + +def _env_str(name: str, default: str) -> str: + return os.environ.get(name, default) + + +def _env_float(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + raise ValueError(f"environment variable {name}={raw!r} is not a number") + + +@dataclass +class Settings: + """All service configuration. Construct with ``Settings.from_env()``.""" + + # -- VOACAP ------------------------------------------------------------- + voacapl_path: str = "/usr/local/bin/voacapl" + itshfbc_path: str = "/opt/itshfbc" + voacap_timeout_s: float = 20.0 + voacap_cache_ttl_s: float = 6 * 3600.0 + voacap_cache_disk_path: Path | None = None + #: Smoothed sunspot number used on the SUNSPOT card. + #: TODO: wire to a live SIDC/NOAA SSN feed; static default for the scaffold. + sunspot_number: float = 68.0 + + # -- PSK Reporter --------------------------------------------------------- + psk_base_url: str = "https://retrieve.pskreporter.info/query" + psk_contact_email: str = "ops@ft8af.app" + psk_timeout_s: float = 20.0 + psk_cache_ttl_s: float = 300.0 # 5 min: retrieve.pskreporter.info guidance + psk_min_interval_s: float = 300.0 + psk_backoff_s: float = 900.0 # 15 min after HTTP 429/503 + psk_window_s: int = 900 # flowStartSeconds=-900 + personal_cache_ttl_s: float = 600.0 # 10 min per-callsign cache + baseline_path: Path = Path("/var/lib/band-advisor/baseline.json") + + # -- API ------------------------------------------------------------------ + feature_config_path: Path = Path("/etc/band-advisor/feature-config.json") + feature_config_ttl_s: float = 6 * 3600.0 + recommendation_valid_s: float = 900.0 + + extra: dict[str, str] = field(default_factory=dict) + + @classmethod + def from_env(cls) -> "Settings": + disk = os.environ.get("VOACAP_CACHE_DISK_PATH") + return cls( + voacapl_path=_env_str("VOACAPL_PATH", cls.voacapl_path), + itshfbc_path=_env_str("ITSHFBC_PATH", cls.itshfbc_path), + voacap_timeout_s=_env_float("VOACAP_TIMEOUT_S", cls.voacap_timeout_s), + voacap_cache_ttl_s=_env_float( + "VOACAP_CACHE_TTL_S", cls.voacap_cache_ttl_s + ), + voacap_cache_disk_path=Path(disk) if disk else None, + sunspot_number=_env_float("SUNSPOT_NUMBER", cls.sunspot_number), + psk_base_url=_env_str("PSK_BASE_URL", cls.psk_base_url), + psk_contact_email=_env_str("PSK_CONTACT_EMAIL", cls.psk_contact_email), + psk_timeout_s=_env_float("PSK_TIMEOUT_S", cls.psk_timeout_s), + psk_cache_ttl_s=_env_float("PSK_CACHE_TTL_S", cls.psk_cache_ttl_s), + psk_min_interval_s=_env_float( + "PSK_MIN_INTERVAL_S", cls.psk_min_interval_s + ), + psk_backoff_s=_env_float("PSK_BACKOFF_S", cls.psk_backoff_s), + psk_window_s=int(_env_float("PSK_WINDOW_S", cls.psk_window_s)), + personal_cache_ttl_s=_env_float( + "PERSONAL_CACHE_TTL_S", cls.personal_cache_ttl_s + ), + baseline_path=Path( + _env_str("BASELINE_PATH", str(cls.baseline_path)) + ), + feature_config_path=Path( + _env_str("FEATURE_CONFIG_PATH", str(cls.feature_config_path)) + ), + feature_config_ttl_s=_env_float( + "FEATURE_CONFIG_TTL_S", cls.feature_config_ttl_s + ), + recommendation_valid_s=_env_float( + "RECOMMENDATION_VALID_S", cls.recommendation_valid_s + ), + ) diff --git a/server/band-advisor/app/geo.py b/server/band-advisor/app/geo.py new file mode 100644 index 000000000..6e8f53943 --- /dev/null +++ b/server/band-advisor/app/geo.py @@ -0,0 +1,127 @@ +"""Maidenhead grid + great-circle geometry helpers (pure functions). + +Supports 2-char fields, 4-char squares and 6-char subsquares. All conversions +return the *center* of the grid cell. Distance is a plain spherical haversine +(mean Earth radius 6371 km) — accurate to well under 0.5 %, which is far more +precision than band-advice needs. +""" + +from __future__ import annotations + +import math +import re + +EARTH_RADIUS_KM = 6371.0 + +GRID4_RE = re.compile(r"^[A-R]{2}[0-9]{2}$") +GRID6_RE = re.compile(r"^[A-R]{2}[0-9]{2}[A-X]{2}$") +FIELD_RE = re.compile(r"^[A-R]{2}$") + +CALLSIGN_RE = re.compile(r"^[A-Z0-9]{1,3}[0-9][A-Z0-9]{0,3}[A-Z](/[A-Z0-9]{1,4})?$") + + +class GridError(ValueError): + """Raised for malformed Maidenhead locators.""" + + +def normalize_grid4(grid: str) -> str: + """Uppercase, truncate to 4 characters and validate as a Maidenhead square. + + Raises :class:`GridError` if the result does not match ``^[A-R]{2}[0-9]{2}$``. + """ + if not isinstance(grid, str): + raise GridError("grid must be a string") + g = grid.strip().upper()[:4] + if not GRID4_RE.match(g): + raise GridError(f"invalid Maidenhead grid: {grid!r}") + return g + + +def grid_to_latlon(grid: str) -> tuple[float, float]: + """Convert a 2/4/6-char Maidenhead locator to the (lat, lon) of its center.""" + if not isinstance(grid, str): + raise GridError("grid must be a string") + g = grid.strip() + # Canonical case: fields upper, subsquares lower (accept any input case). + g = g[:2].upper() + g[2:4] + g[4:6].lower() + if len(g) == 2 and FIELD_RE.match(g): + lon = (ord(g[0]) - ord("A")) * 20.0 - 180.0 + 10.0 + lat = (ord(g[1]) - ord("A")) * 10.0 - 90.0 + 5.0 + return lat, lon + if len(g) == 4 and GRID4_RE.match(g): + lon = (ord(g[0]) - ord("A")) * 20.0 - 180.0 + int(g[2]) * 2.0 + 1.0 + lat = (ord(g[1]) - ord("A")) * 10.0 - 90.0 + int(g[3]) * 1.0 + 0.5 + return lat, lon + if len(g) == 6 and GRID6_RE.match(g.upper()): + lon = ( + (ord(g[0].upper()) - ord("A")) * 20.0 + - 180.0 + + int(g[2]) * 2.0 + + (ord(g[4]) - ord("a")) * (2.0 / 24.0) + + (1.0 / 24.0) + ) + lat = ( + (ord(g[1].upper()) - ord("A")) * 10.0 + - 90.0 + + int(g[3]) * 1.0 + + (ord(g[5]) - ord("a")) * (1.0 / 24.0) + + (0.5 / 24.0) + ) + return lat, lon + raise GridError(f"invalid Maidenhead grid: {grid!r}") + + +def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Great-circle distance in km between two lat/lon points.""" + p1, p2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlmb = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 + return 2 * EARTH_RADIUS_KM * math.asin(min(1.0, math.sqrt(a))) + + +def azimuth_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Initial great-circle bearing from point 1 to point 2, degrees 0..360.""" + p1, p2 = math.radians(lat1), math.radians(lat2) + dlmb = math.radians(lon2 - lon1) + y = math.sin(dlmb) * math.cos(p2) + x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dlmb) + return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0 + + +def destination_point( + lat: float, lon: float, bearing_deg: float, distance_km: float +) -> tuple[float, float]: + """Point reached from (lat, lon) travelling ``distance_km`` at ``bearing_deg``.""" + d = distance_km / EARTH_RADIUS_KM + b = math.radians(bearing_deg) + p1 = math.radians(lat) + l1 = math.radians(lon) + p2 = math.asin( + math.sin(p1) * math.cos(d) + math.cos(p1) * math.sin(d) * math.cos(b) + ) + l2 = l1 + math.atan2( + math.sin(b) * math.sin(d) * math.cos(p1), + math.cos(d) - math.sin(p1) * math.sin(p2), + ) + lon2 = (math.degrees(l2) + 540.0) % 360.0 - 180.0 + return math.degrees(p2), lon2 + + +def grid_distance_km(grid1: str, grid2: str) -> float: + """Great-circle distance in km between two Maidenhead grid centers.""" + lat1, lon1 = grid_to_latlon(grid1) + lat2, lon2 = grid_to_latlon(grid2) + return haversine_km(lat1, lon1, lat2, lon2) + + +def grid_azimuth_deg(grid1: str, grid2: str) -> float: + """Initial bearing from grid1 to grid2, degrees 0..360.""" + lat1, lon1 = grid_to_latlon(grid1) + lat2, lon2 = grid_to_latlon(grid2) + return azimuth_deg(lat1, lon1, lat2, lon2) + + +def is_valid_callsign(callsign: str) -> bool: + """Loose amateur callsign shape check (uppercased before matching).""" + return bool(CALLSIGN_RE.match(callsign.strip().upper())) diff --git a/server/band-advisor/app/logging_config.py b/server/band-advisor/app/logging_config.py new file mode 100644 index 000000000..1bb2cd218 --- /dev/null +++ b/server/band-advisor/app/logging_config.py @@ -0,0 +1,47 @@ +"""Structured JSON logging. + +Privacy rules enforced here and at call sites: +* location is only ever logged as a 4-char Maidenhead square (never GPS); +* no tokens, no emails, no full query strings. +""" + +from __future__ import annotations + +import json +import logging +import sys +from datetime import datetime, timezone + + +class JsonFormatter(logging.Formatter): + """One JSON object per line; extra fields via ``logging`` ``extra=``.""" + + _RESERVED = set( + logging.LogRecord( + name="", level=0, pathname="", lineno=0, msg="", args=(), exc_info=None + ).__dict__ + ) | {"message", "asctime", "taskName"} + + def format(self, record: logging.LogRecord) -> str: + payload: dict = { + "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for key, value in record.__dict__.items(): + if key not in self._RESERVED and not key.startswith("_"): + payload[key] = value + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, default=str) + + +def configure_logging(level: int = logging.INFO) -> None: + root = logging.getLogger() + if any(isinstance(h.formatter, JsonFormatter) for h in root.handlers): + return # already configured (idempotent for tests / reloads) + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter()) + root.handlers = [handler] + root.setLevel(level) diff --git a/server/band-advisor/app/main.py b/server/band-advisor/app/main.py new file mode 100644 index 000000000..c7b123e04 --- /dev/null +++ b/server/band-advisor/app/main.py @@ -0,0 +1,5 @@ +"""ASGI entry point: ``uvicorn app.main:app``.""" + +from .api import create_app + +app = create_app() diff --git a/server/band-advisor/app/pskreporter/__init__.py b/server/band-advisor/app/pskreporter/__init__.py new file mode 100644 index 000000000..cb538cbe5 --- /dev/null +++ b/server/band-advisor/app/pskreporter/__init__.py @@ -0,0 +1,7 @@ +"""Centralized PSK Reporter integration. + +Phones NEVER poll retrieve.pskreporter.info directly — this service is the +single aggregation point, so one fetch per 2-char field per 5 minutes serves +every nearby user. Etiquette implemented here: gzip, appcontact parameter, +>=5-minute per-parameter-set interval, 15-minute backoff on 429/503. +""" diff --git a/server/band-advisor/app/pskreporter/aggregator.py b/server/band-advisor/app/pskreporter/aggregator.py new file mode 100644 index 000000000..9ecd0dbe5 --- /dev/null +++ b/server/band-advisor/app/pskreporter/aggregator.py @@ -0,0 +1,163 @@ +"""Aggregate reception reports into per-band activity statistics. + +All statistics are computed relative to an *observer* 4-char grid: + +* distances/azimuths use the report's own TX->RX path; +* "directional diversity" counts distinct 45-degree azimuth octants among + paths whose transmitter is near the observer (within + ``NEARBY_REGION_KM``), i.e. "from stations like me, which directions are + working right now"; +* "nearby transmitters reaching region R" counts distinct transmitters + within ``NEARBY_TX_KM`` of the observer whose signal was heard by a + receiver in region R (region = nearest-centroid of the receiver's field). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from statistics import median + +from ..geo import GridError, grid_azimuth_deg, grid_distance_km +from ..voacap.regions import home_region_for_field +from .client import ReceptionReport + +#: Transmitters within this range of the observer count as "nearby" (stations +#: with comparable propagation to the observer's own). +NEARBY_TX_KM = 500.0 + +#: Paths originating within this range feed the directional-diversity octants. +NEARBY_REGION_KM = 1500.0 + +PATH_THRESHOLD_1_KM = 3000.0 +PATH_THRESHOLD_2_KM = 8000.0 + + +class SampleQuality(str, Enum): + """How trustworthy a band's statistics are, by observation count.""" + + NONE = "NONE" # < 5 observations + LOW = "LOW" # 5..19 + GOOD = "GOOD" # >= 20 + + +def sample_quality_for_count(count: int) -> SampleQuality: + if count < 5: + return SampleQuality.NONE + if count < 20: + return SampleQuality.LOW + return SampleQuality.GOOD + + +@dataclass +class BandActivity: + """Per-band aggregate over one observation window.""" + + band: str + observation_count: int = 0 + unique_transmitters: int = 0 + unique_receivers: int = 0 + median_snr_db: float | None = None + median_distance_km: float | None = None + max_distance_km: float | None = None + paths_over_3000km: int = 0 + paths_over_8000km: int = 0 + #: Distinct 45-degree octants (0..8) with at least one observer-area path. + directional_octants: int = 0 + #: Distinct nearby (<500 km) transmitters heard in each region. + nearby_tx_to_region: dict[str, int] = field(default_factory=dict) + #: Distinct nearby transmitters heard anywhere. + nearby_tx_total: int = 0 + sample_quality: SampleQuality = SampleQuality.NONE + + +def aggregate_band_activity( + reports: list[ReceptionReport] | tuple[ReceptionReport, ...], + observer_grid: str, +) -> dict[str, BandActivity]: + """Compute :class:`BandActivity` for every band present in ``reports``. + + Reports with off-dial frequencies or grid math failures are skipped — + the aggregator never raises on a bad report. + """ + per_band: dict[str, dict] = {} + + for report in reports: + band = report.band + if band is None: + continue + try: + path_km = grid_distance_km(report.sender_grid, report.receiver_grid) + tx_from_observer_km = grid_distance_km(observer_grid, report.sender_grid) + except GridError: + continue + + acc = per_band.setdefault( + band, + { + "snrs": [], + "distances": [], + "transmitters": set(), + "receivers": set(), + "over1": 0, + "over2": 0, + "octants": set(), + "nearby_tx_by_region": {}, + "nearby_tx_all": set(), + "count": 0, + }, + ) + acc["count"] += 1 + acc["transmitters"].add(report.sender_callsign) + acc["receivers"].add(report.receiver_callsign) + acc["distances"].append(path_km) + if report.snr_db is not None: + acc["snrs"].append(report.snr_db) + if path_km > PATH_THRESHOLD_1_KM: + acc["over1"] += 1 + if path_km > PATH_THRESHOLD_2_KM: + acc["over2"] += 1 + + if tx_from_observer_km <= NEARBY_REGION_KM and path_km > 1.0: + try: + az = grid_azimuth_deg(report.sender_grid, report.receiver_grid) + acc["octants"].add(int(az // 45.0) % 8) + except GridError: + pass + + if tx_from_observer_km <= NEARBY_TX_KM: + acc["nearby_tx_all"].add(report.sender_callsign) + try: + region = home_region_for_field(report.receiver_grid) + except GridError: + region = None + if region is not None: + acc["nearby_tx_by_region"].setdefault(region, set()).add( + report.sender_callsign + ) + + result: dict[str, BandActivity] = {} + for band, acc in per_band.items(): + result[band] = BandActivity( + band=band, + observation_count=acc["count"], + unique_transmitters=len(acc["transmitters"]), + unique_receivers=len(acc["receivers"]), + median_snr_db=median(acc["snrs"]) if acc["snrs"] else None, + median_distance_km=( + round(median(acc["distances"]), 1) if acc["distances"] else None + ), + max_distance_km=( + round(max(acc["distances"]), 1) if acc["distances"] else None + ), + paths_over_3000km=acc["over1"], + paths_over_8000km=acc["over2"], + directional_octants=len(acc["octants"]), + nearby_tx_to_region={ + region: len(calls) + for region, calls in sorted(acc["nearby_tx_by_region"].items()) + }, + nearby_tx_total=len(acc["nearby_tx_all"]), + sample_quality=sample_quality_for_count(acc["count"]), + ) + return result diff --git a/server/band-advisor/app/pskreporter/baseline.py b/server/band-advisor/app/pskreporter/baseline.py new file mode 100644 index 000000000..fd9955ee0 --- /dev/null +++ b/server/band-advisor/app/pskreporter/baseline.py @@ -0,0 +1,108 @@ +"""Typical-activity baseline per (band, UTC hour). + +Raw spot volume must NOT be the dominant recommendation signal: 20 m at +14:00 UTC is *always* busy, so "lots of spots on 20 m" carries little +information. This module maintains an exponentially-weighted mean of +observation counts per (band, utc_hour) so live activity can be expressed +as a *ratio against what is normal for that band at that hour*: + + activity_ratio = current / max(baseline, floor) + +plus a short-term ``activity_trend`` computed from the last 3 observation +windows (rising/falling live activity). + +The baseline persists to a small JSON file so it survives restarts and +slowly learns the deployment's real traffic pattern. +""" + +from __future__ import annotations + +import json +import logging +from collections import deque +from pathlib import Path + +log = logging.getLogger(__name__) + +DEFAULT_ALPHA = 0.2 # EWMA smoothing factor per window +DEFAULT_FLOOR = 5.0 # avoid divide-by-tiny for quiet band-hours +TREND_WINDOWS = 3 + + +def _key(band: str, utc_hour: int) -> str: + return f"{band}|{int(utc_hour) % 24:02d}" + + +class ActivityBaseline: + """EWMA baseline of observation counts per (band, utc_hour).""" + + def __init__( + self, + path: Path | None = None, + alpha: float = DEFAULT_ALPHA, + floor: float = DEFAULT_FLOOR, + ) -> None: + self._path = path + self._alpha = alpha + self._floor = floor + self._means: dict[str, float] = {} + # recent raw counts per band (across hours) for the trend signal + self._recent: dict[str, deque[int]] = {} + self._load() + + # -- updates ------------------------------------------------------------- + + def update(self, band: str, utc_hour: int, observation_count: int) -> None: + """Fold one observation window into the baseline and trend history.""" + k = _key(band, utc_hour) + prev = self._means.get(k) + if prev is None: + self._means[k] = float(observation_count) + else: + self._means[k] = (1 - self._alpha) * prev + self._alpha * observation_count + window = self._recent.setdefault(band, deque(maxlen=TREND_WINDOWS)) + window.append(int(observation_count)) + self._save() + + # -- queries ------------------------------------------------------------- + + def baseline(self, band: str, utc_hour: int) -> float: + return max(self._means.get(_key(band, utc_hour), 0.0), self._floor) + + def activity_ratio(self, band: str, utc_hour: int, current: int) -> float: + """current / max(baseline, floor); 1.0 means "normal for this hour".""" + return float(current) / self.baseline(band, utc_hour) + + def activity_trend(self, band: str) -> float: + """Relative change across the last 3 windows, clamped to [-1, 1]. + + 0.0 = flat or insufficient history; positive = activity rising. + """ + window = self._recent.get(band) + if not window or len(window) < 2: + return 0.0 + oldest, newest = window[0], window[-1] + trend = (newest - oldest) / max(float(oldest), self._floor) + return max(-1.0, min(1.0, trend)) + + # -- persistence ----------------------------------------------------------- + + def _save(self) -> None: + if self._path is None: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps({"means": self._means}, sort_keys=True)) + except OSError as exc: + log.warning("baseline save failed (ignored): %s", exc) + + def _load(self) -> None: + if self._path is None or not self._path.exists(): + return + try: + data = json.loads(self._path.read_text()) + means = data.get("means", {}) + self._means = {str(k): float(v) for k, v in means.items()} + except (OSError, ValueError, TypeError, AttributeError) as exc: + log.warning("baseline load failed (starting fresh): %s", exc) + self._means = {} diff --git a/server/band-advisor/app/pskreporter/cache.py b/server/band-advisor/app/pskreporter/cache.py new file mode 100644 index 000000000..160746871 --- /dev/null +++ b/server/band-advisor/app/pskreporter/cache.py @@ -0,0 +1,72 @@ +"""TTL caches for PSK Reporter data. + +Regional cache keying: the observer's 4-char grid is reduced to its 2-char +FIELD so that nearby users share cache entries (one upstream fetch serves +everyone in, say, EM** for the whole window). Key shape:: + + (observer_field_2char, band_or_ALL, mode, window_seconds) + +TTL 5 minutes — matching retrieve.pskreporter.info's minimum polling +guidance, so the cache itself enforces the etiquette. + +Personal (per-callsign) results are cached separately for 10 minutes and are +never persisted to disk (data-retention: aggregates only; per-callsign data +lives at most the cache TTL, well under 1 h). +""" + +from __future__ import annotations + +from typing import Any, Callable + +from ..geo import normalize_grid4 +from ..ttl_cache import TTLCache + + +def regional_cache_key( + observer_grid: str, band: str | None, mode: str, window_s: int +) -> tuple: + """Canonical regional key — observer grid collapses to its 2-char field.""" + field = normalize_grid4(observer_grid)[:2] + return (field, band or "ALL", mode.upper(), int(window_s)) + + +def personal_cache_key(callsign: str, mode: str, window_s: int) -> tuple: + return ("personal", callsign.strip().upper(), mode.upper(), int(window_s)) + + +class PskCache: + """Regional (5 min) + personal (10 min) TTL caches, memory only.""" + + def __init__( + self, + regional_ttl_s: float, + personal_ttl_s: float, + time_fn: Callable[[], float], + ) -> None: + self._regional = TTLCache(regional_ttl_s, time_fn, max_entries=1024) + self._personal = TTLCache(personal_ttl_s, time_fn, max_entries=1024) + + def get_regional( + self, observer_grid: str, band: str | None, mode: str, window_s: int + ) -> Any | None: + return self._regional.get(regional_cache_key(observer_grid, band, mode, window_s)) + + def set_regional( + self, + observer_grid: str, + band: str | None, + mode: str, + window_s: int, + value: Any, + ) -> None: + self._regional.set( + regional_cache_key(observer_grid, band, mode, window_s), value + ) + + def get_personal(self, callsign: str, mode: str, window_s: int) -> Any | None: + return self._personal.get(personal_cache_key(callsign, mode, window_s)) + + def set_personal( + self, callsign: str, mode: str, window_s: int, value: Any + ) -> None: + self._personal.set(personal_cache_key(callsign, mode, window_s), value) diff --git a/server/band-advisor/app/pskreporter/client.py b/server/band-advisor/app/pskreporter/client.py new file mode 100644 index 000000000..589bbc363 --- /dev/null +++ b/server/band-advisor/app/pskreporter/client.py @@ -0,0 +1,222 @@ +"""HTTP client for https://retrieve.pskreporter.info/query. + +Etiquette / safety requirements implemented (see package docstring): + +* ``Accept-Encoding: gzip`` (httpx negotiates and transparently decompresses); +* ``appcontact=`` on every request; +* per-parameter-set minimum interval of 5 minutes (their guidance) — the + cache layer is the primary guard, this client enforces it again; +* 15-minute global backoff after HTTP 429/503; +* XML parsed with ``defusedxml`` (no external entities / bombs); +* malformed XML raises, out-of-range values (bad grids, absurd + frequencies) are dropped per-report rather than failing the fetch. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime + +import httpx +from defusedxml import ElementTree as SafeET + +from ..bands import band_for_frequency_hz +from ..clock import Clock +from ..config import Settings +from ..geo import GridError, grid_to_latlon + +log = logging.getLogger(__name__) + +_MIN_FREQ_HZ = 1_000_000 # below 160 m: not an HF FT8 report we care about +_MAX_FREQ_HZ = 60_000_000 +_SNR_RANGE = (-40, 60) + + +class PskReporterError(RuntimeError): + """Fetch failed (network, HTTP error, malformed XML, or backoff active).""" + + +class PskReporterBackoff(PskReporterError): + """Raised while the 429/503 backoff window or min-interval is active.""" + + +@dataclass(frozen=True) +class ReceptionReport: + """One validated PSK Reporter reception report.""" + + sender_callsign: str + sender_grid: str + receiver_callsign: str + receiver_grid: str + frequency_hz: int + snr_db: int | None + mode: str + flow_start_seconds: int | None + + @property + def band(self) -> str | None: + return band_for_frequency_hz(self.frequency_hz) + + +@dataclass(frozen=True) +class FetchResult: + """Reports plus the wall-clock time they were fetched (for freshness).""" + + reports: tuple[ReceptionReport, ...] + fetched_at: datetime + + +def _valid_grid(locator: str) -> bool: + if not locator or len(locator) < 4: + return False + try: + grid_to_latlon(locator[:6] if len(locator) >= 6 else locator[:4]) + return True + except GridError: + return False + + +def parse_reception_reports(xml_bytes: bytes) -> list[ReceptionReport]: + """Parse a retrieve.pskreporter.info XML response into validated reports. + + Raises :class:`PskReporterError` on malformed XML. Individual reports + with bad grids, absurd frequencies, or missing fields are skipped. + """ + try: + root = SafeET.fromstring(xml_bytes) + except Exception as exc: # defusedxml raises several parse error types + raise PskReporterError(f"malformed PSK Reporter XML: {exc}") from exc + + reports: list[ReceptionReport] = [] + for elem in root.iter("receptionReport"): + sender = (elem.get("senderCallsign") or "").strip().upper() + receiver = (elem.get("receiverCallsign") or "").strip().upper() + sender_grid = (elem.get("senderLocator") or "").strip() + receiver_grid = (elem.get("receiverLocator") or "").strip() + mode = (elem.get("mode") or "").strip().upper() + try: + frequency_hz = int(float(elem.get("frequency", ""))) + except (TypeError, ValueError): + continue + snr_raw = elem.get("sNR") + snr_db: int | None + try: + snr_db = int(float(snr_raw)) if snr_raw is not None else None + except (TypeError, ValueError): + snr_db = None + if snr_db is not None and not _SNR_RANGE[0] <= snr_db <= _SNR_RANGE[1]: + snr_db = None + try: + flow_start = int(float(elem.get("flowStartSeconds", ""))) + except (TypeError, ValueError): + flow_start = None + + if not sender or not receiver: + continue + if not _MIN_FREQ_HZ <= frequency_hz <= _MAX_FREQ_HZ: + continue + if not _valid_grid(sender_grid) or not _valid_grid(receiver_grid): + continue + + reports.append( + ReceptionReport( + sender_callsign=sender, + sender_grid=sender_grid[:6], + receiver_callsign=receiver, + receiver_grid=receiver_grid[:6], + frequency_hz=frequency_hz, + snr_db=snr_db, + mode=mode, + flow_start_seconds=flow_start, + ) + ) + return reports + + +class PskReporterClient: + """Rate-limited fetcher. ``http_client`` is injectable for tests.""" + + def __init__( + self, + settings: Settings, + clock: Clock, + http_client: httpx.Client | None = None, + ) -> None: + self._settings = settings + self._clock = clock + self._http = http_client or httpx.Client( + timeout=settings.psk_timeout_s, + headers={ + "Accept-Encoding": "gzip", + "User-Agent": f"ft8af-band-advisor ({settings.psk_contact_email})", + }, + ) + self._last_fetch_mono: dict[tuple, float] = {} + self._backoff_until_mono: float = 0.0 + + def fetch_reports( + self, + *, + mode: str = "FT8", + sender_callsign: str | None = None, + receiver_region_grid: str | None = None, + ) -> FetchResult: + """Fetch the last ``psk_window_s`` of reception reports. + + Either a per-callsign query (``senderCallsign``) or a broad-mode + query later filtered client-side by band/region. Raises + :class:`PskReporterBackoff` when called inside the min-interval or a + 429/503 backoff window — callers should use the cache instead. + """ + now_mono = self._clock.monotonic() + if now_mono < self._backoff_until_mono: + raise PskReporterBackoff( + "PSK Reporter backoff active after 429/503; try later" + ) + + params: dict[str, str] = { + "flowStartSeconds": str(-abs(self._settings.psk_window_s)), + "mode": mode, + "rronly": "1", + "appcontact": self._settings.psk_contact_email, + } + if sender_callsign: + params["senderCallsign"] = sender_callsign.upper() + + key = tuple(sorted(params.items())) + last = self._last_fetch_mono.get(key) + if last is not None and now_mono - last < self._settings.psk_min_interval_s: + raise PskReporterBackoff( + "per-parameter-set minimum interval (5 min) not elapsed" + ) + + try: + response = self._http.get( + self._settings.psk_base_url, + params=params, + timeout=self._settings.psk_timeout_s, + ) + except httpx.HTTPError as exc: + raise PskReporterError(f"PSK Reporter request failed: {exc}") from exc + + if response.status_code in (429, 503): + self._backoff_until_mono = now_mono + self._settings.psk_backoff_s + raise PskReporterError( + f"PSK Reporter returned {response.status_code}; backing off " + f"{self._settings.psk_backoff_s:.0f}s" + ) + if response.status_code != 200: + raise PskReporterError( + f"PSK Reporter returned HTTP {response.status_code}" + ) + + self._last_fetch_mono[key] = now_mono + reports = parse_reception_reports(response.content) + log.info( + "pskreporter fetch ok: %d valid reports (mode=%s personal=%s)", + len(reports), + mode, + bool(sender_callsign), + ) + return FetchResult(reports=tuple(reports), fetched_at=self._clock.now()) diff --git a/server/band-advisor/app/pskreporter/service.py b/server/band-advisor/app/pskreporter/service.py new file mode 100644 index 000000000..ffd559355 --- /dev/null +++ b/server/band-advisor/app/pskreporter/service.py @@ -0,0 +1,219 @@ +"""Facade: fetch + cache + aggregate + baseline in one call. + +The recommendation layer only talks to :class:`PskService`. Every method +degrades by raising :class:`PskReporterError` (never returns partial junk); +the caller flags the source unavailable and renormalizes scores. +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from datetime import datetime +from statistics import median + +from ..clock import Clock +from ..config import Settings +from ..ttl_cache import TTLCache +from ..geo import grid_distance_km, GridError +from .aggregator import BandActivity, aggregate_band_activity +from .baseline import ActivityBaseline +from .cache import PskCache +from .client import ( + FetchResult, + PskReporterBackoff, + PskReporterClient, + PskReporterError, +) + +log = logging.getLogger(__name__) + + +@dataclass +class RegionalActivity: + """Aggregated regional picture for one observer field.""" + + bands: dict[str, BandActivity] + #: band -> live-vs-typical ratio (1.0 == normal for this hour) + activity_ratio: dict[str, float] + #: band -> short-term trend in [-1, 1] + activity_trend: dict[str, float] + fetched_at: datetime + + +@dataclass +class PersonalResult: + """Per-callsign analytics: contract JSON block + per-band performance.""" + + analytics: dict + #: band -> 0..1 relative personal performance (for the scoring component) + per_band_performance: dict[str, float] + fetched_at: datetime + + +class PskService: + """Cached, rate-limit-respecting PSK Reporter aggregation.""" + + def __init__( + self, + settings: Settings, + clock: Clock, + client: PskReporterClient | None = None, + baseline: ActivityBaseline | None = None, + ) -> None: + self._lock = threading.RLock() + self._raw_cache = TTLCache( + ttl_seconds=max(settings.psk_cache_ttl_s, settings.psk_min_interval_s), + time_fn=clock.monotonic, + ) + self._settings = settings + self._clock = clock + self._client = client or PskReporterClient(settings, clock) + self._baseline = baseline or ActivityBaseline(path=settings.baseline_path) + self._cache = PskCache( + regional_ttl_s=settings.psk_cache_ttl_s, + personal_ttl_s=settings.personal_cache_ttl_s, + time_fn=clock.monotonic, + ) + + # Shared client/backoff and baseline state are protected by the service lock. + # -- regional -------------------------------------------------------------- + + def get_regional_activity( + self, observer_grid: str, mode: str = "FT8" + ) -> RegionalActivity: + with self._lock: + window = self._settings.psk_window_s + cached = self._cache.get_regional(observer_grid, None, mode, window) + if cached is not None: + return cached + + raw_key = (mode, window) + fetch = self._raw_cache.get(raw_key) + if fetch is None: + try: + fetch = self._client.fetch_reports(mode=mode) + except PskReporterBackoff: + raise PskReporterError("rate-limited and no cached regional data") + self._raw_cache.set(raw_key, fetch) + # Counts are global; train the baseline once per upstream window, + # regardless of how many observer fields use that window. + global_bands = aggregate_band_activity(fetch.reports, observer_grid) + for band, activity in global_bands.items(): + self._baseline.update(band, fetch.fetched_at.hour, activity.observation_count) + + bands = aggregate_band_activity(list(fetch.reports), observer_grid) + utc_hour = fetch.fetched_at.hour + ratios: dict[str, float] = {} + trends: dict[str, float] = {} + for band, activity in bands.items(): + ratios[band] = self._baseline.activity_ratio( + band, utc_hour, activity.observation_count + ) + trends[band] = self._baseline.activity_trend(band) + + result = RegionalActivity( + bands=bands, + activity_ratio=ratios, + activity_trend=trends, + fetched_at=fetch.fetched_at, + ) + self._cache.set_regional(observer_grid, None, mode, window, result) + return result + + # -- personal ---------------------------------------------------------------- + + def get_personal_analytics( + self, + callsign: str, + observer_grid: str, + mode: str = "FT8", + regional: RegionalActivity | None = None, + ) -> PersonalResult: + """Per-callsign TX analytics (10-min cache; same rate limits).""" + with self._lock: + window = self._settings.psk_window_s + cached = self._cache.get_personal(callsign, mode, window) + if cached is not None: + return cached + + try: + fetch = self._client.fetch_reports(mode=mode, sender_callsign=callsign) + except PskReporterBackoff: + raise PskReporterError("rate-limited and no cached personal data") + + result = build_personal_result( + fetch, callsign=callsign, regional=regional, fetched_at=fetch.fetched_at + ) + self._cache.set_personal(callsign, mode, window, result) + return result + + +def build_personal_result( + fetch: FetchResult, + callsign: str, + regional: RegionalActivity | None, + fetched_at: datetime, +) -> PersonalResult: + """Pure aggregation of a per-callsign fetch into the contract block. + + ``countriesReached`` is approximated by distinct receiver Maidenhead + FIELDS — TODO(scaffold): replace with a real callsign-prefix -> DXCC + lookup (the Android app already ships one). + + ``performanceComparedToBaseline`` compares the operator's median + received SNR against the regional median on the same bands, scaled so + +10 dB above the crowd => +1.0. + """ + reports = [r for r in fetch.reports if r.sender_callsign == callsign.upper()] + per_band_counts: dict[str, int] = {} + distances: list[float] = [] + snrs: list[int] = [] + receivers: set[str] = set() + receiver_fields: set[str] = set() + + for r in reports: + if r.band is not None: + per_band_counts[r.band] = per_band_counts.get(r.band, 0) + 1 + receivers.add(r.receiver_callsign) + receiver_fields.add(r.receiver_grid[:2].upper()) + if r.snr_db is not None: + snrs.append(r.snr_db) + try: + distances.append(grid_distance_km(r.sender_grid, r.receiver_grid)) + except GridError: + pass + + performance = 0.0 + if snrs and regional is not None: + regional_snrs = [ + a.median_snr_db + for b, a in regional.bands.items() + if b in per_band_counts and a.median_snr_db is not None + ] + if regional_snrs: + performance = max( + -1.0, min(1.0, (median(snrs) - median(regional_snrs)) / 10.0) + ) + + analytics = { + "enabled": True, + "reportsReceived": len(reports), + "uniqueReceivers": len(receivers), + "countriesReached": len(receiver_fields), + "maximumDistanceKm": round(max(distances)) if distances else 0, + "medianSnrDb": round(median(snrs)) if snrs else None, + "bestSnrDb": max(snrs) if snrs else None, + "performanceComparedToBaseline": round(performance, 2), + } + max_count = max(per_band_counts.values()) if per_band_counts else 0 + per_band_perf = { + band: (count / max_count if max_count else 0.0) + for band, count in per_band_counts.items() + } + return PersonalResult( + analytics=analytics, + per_band_performance=per_band_perf, + fetched_at=fetched_at, + ) diff --git a/server/band-advisor/app/recommendation.py b/server/band-advisor/app/recommendation.py new file mode 100644 index 000000000..c89f674b0 --- /dev/null +++ b/server/band-advisor/app/recommendation.py @@ -0,0 +1,528 @@ +"""Recommendation orchestrator: VOACAP + PSK Reporter + scoring -> contract JSON. + +This module owns the exact response contract the mobile clients parse. Field +names, omission rules (``callsign``/``personalAnalytics`` only when a callsign +was supplied) and rounding are all load-bearing — change them only together +with the client parsers. + +Degradation policy: a failed upstream NEVER propagates an exception out of +:meth:`RecommendationService.recommend`. The source is flagged unavailable in +``sources``, its score components become ``None`` (dropped + renormalized by +:mod:`app.scoring`) and confidence drops. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from .bands import BAND_ORDER, FT8_DIAL_FREQ_HZ +from .clock import Clock, isoformat_z +from .config import Settings +from .pskreporter.service import PersonalResult, PskService, RegionalActivity +from .scoring import ( + Goal, + agreement_between, + clamp01, + compute_confidence, + score_band, +) +from .voacap.input_builder import AntennaClass, NoiseClass, PowerClass +from .voacap.regions import REGIONS, farthest_region, home_region_for_field +from .voacap.service import VoacapService + +log = logging.getLogger(__name__) + +#: Path length treated as "full DX potential" for normalization. +MAX_DX_KM = 12000.0 + +_SUMMARIES: dict[Goal, str] = { + Goal.MAKE_CONTACT: ( + "Best mix of proven nearby activity and predicted reliability right now." + ), + Goal.DX: "Best combination of live activity and long-distance reliability.", + Goal.TARGET: "Strongest predicted and observed path toward {target}.", + Goal.POTA: "Best regional coverage for activator-style contacts right now.", +} + +_COMPONENT_LABELS = { + "voacapReliability": "predicted reliability", + "targetReliability": "predicted reliability to target", + "targetObservedPaths": "observed paths to target", + "nearbyObservedSuccess": "nearby stations being heard", + "normalizedLiveActivity": "live activity vs normal", + "distancePotential": "distance potential", + "activityTrend": "rising activity", + "directionalDiversity": "many directions open", + "personalPerformance": "your recent results", +} + + +@dataclass(frozen=True) +class RecommendationRequest: + """Validated inputs for one recommendation.""" + + grid: str # normalized 4-char + goal: Goal + mode: str + power: PowerClass + antenna: AntennaClass + noise: NoiseClass + callsign: str | None = None + target_region: str | None = None + #: 0..1 — how many optional station parameters the client actually set. + profile_completeness: float = 0.5 + + +class RecommendationService: + """Combines both upstream services into the client-facing contract.""" + + def __init__( + self, + settings: Settings, + clock: Clock, + voacap: VoacapService, + psk: PskService, + ) -> None: + self._settings = settings + self._clock = clock + self._voacap = voacap + self._psk = psk + + # ------------------------------------------------------------------ public + + def recommend(self, req: RecommendationRequest) -> dict: + now = self._clock.now() + gathered = self._gather(req) + scored = self._score_all_bands(req.goal, gathered) + + best_band, best_result = scored[0] + best_components = gathered["components"][best_band] + + confidence = self._confidence(req, gathered, best_band, best_components) + + response: dict = { + "generatedAt": isoformat_z(now), + "validUntil": isoformat_z( + now.__class__.fromtimestamp( + now.timestamp() + self._settings.recommendation_valid_s, + tz=now.tzinfo, + ) + ), + "grid": req.grid, + } + if req.target_region: + response["targetRegion"] = req.target_region + if req.callsign: + response["callsign"] = req.callsign + response.update( + { + "mode": req.mode, + "goal": req.goal.value, + "recommendedBand": best_band, + "recommendedFrequencyHz": FT8_DIAL_FREQ_HZ[best_band], + "score": round(best_result.score, 2), + "confidence": round(confidence, 2), + "summary": self._summary(req, gathered), + "destinations": self._destinations(req, gathered, best_band), + "evidence": self._evidence(req, gathered, best_band, now), + "scoreComponents": [c.to_json() for c in best_result.components], + } + ) + if req.callsign: + personal: PersonalResult | None = gathered["personal"] + response["personalAnalytics"] = ( + personal.analytics if personal is not None else None + ) + response["alternatives"] = [ + { + "band": band, + "frequencyHz": FT8_DIAL_FREQ_HZ[band], + "score": round(result.score, 2), + "reason": self._top_component_reason(result), + } + for band, result in scored[1:3] + if result.score > 0.0 + ] + response["sources"] = { + "voacapAvailable": gathered["voacap_available"], + "regionalPskReporterAvailable": gathered["psk_available"], + "personalPskReporterAvailable": gathered["personal_available"], + } + return response + + def conditions(self, grid: str) -> dict: + """Cheap per-band summary for the mobile alert checker.""" + now = self._clock.now() + req = RecommendationRequest( + grid=grid, + goal=Goal.MAKE_CONTACT, + mode="FT8", + power=PowerClass.STANDARD, + antenna=AntennaClass.WIRE, + noise=NoiseClass.RESIDENTIAL, + ) + gathered = self._gather(req) + scored = self._score_all_bands(req.goal, gathered) + bands = [] + for band, result in scored: + components = gathered["components"][band] + confidence = self._confidence(req, gathered, band, components) + entry = { + "band": band, + "score": round(result.score, 2), + "confidence": round(confidence, 2), + "activeRegions": self._active_regions(gathered, band), + "openingEvidence": self._opening_evidence(gathered, band), + } + # Machine-readable evidence consumed by the Android alert policy + # (PropagationAlertPolicy.kt) — keep names in sync with the client. + entry.update(self._condition_fields(gathered, band)) + bands.append(entry) + return { + "generatedAt": isoformat_z(now), + "validUntil": isoformat_z( + now.__class__.fromtimestamp( + now.timestamp() + self._settings.recommendation_valid_s, + tz=now.tzinfo, + ) + ), + "grid": grid, + "bands": bands, + "sources": { + "voacapAvailable": gathered["voacap_available"], + "regionalPskReporterAvailable": gathered["psk_available"], + }, + } + + # ------------------------------------------------------------ data fusion + + def _gather(self, req: RecommendationRequest) -> dict: + """Fetch all upstream data with per-source degradation.""" + now = self._clock.now() + + primary_region = self._primary_region(req) + voacap_pred: dict | None = None + try: + voacap_pred = self._voacap.predict( + tx_grid=req.grid, + month=now.month, + year=now.year, + utc_hour=now.hour, + power_class=req.power, + antenna_class=req.antenna, + noise_class=req.noise, + target_region=primary_region, + ) + except Exception as exc: # any engine failure degrades, never raises + log.warning("voacap unavailable: %s", exc) + + regional: RegionalActivity | None = None + try: + regional = self._psk.get_regional_activity(req.grid, mode=req.mode) + except Exception as exc: + log.warning("pskreporter regional unavailable: %s", exc) + + personal: PersonalResult | None = None + if req.callsign: + try: + personal = self._psk.get_personal_analytics( + req.callsign, req.grid, mode=req.mode, regional=regional + ) + except Exception as exc: + log.warning("pskreporter personal unavailable: %s", exc) + + components = { + band: self._band_components(req, band, voacap_pred, regional, personal) + for band in BAND_ORDER + } + return { + "now": now, + "primary_region": primary_region, + "voacap": voacap_pred, + "regional": regional, + "personal": personal, + "components": components, + "voacap_available": voacap_pred is not None, + "psk_available": regional is not None, + "personal_available": personal is not None, + } + + def _primary_region(self, req: RecommendationRequest) -> str: + if req.goal is Goal.TARGET and req.target_region: + return req.target_region + if req.target_region: + return req.target_region + if req.goal is Goal.DX: + return "LONG_DX" + # MAKE_CONTACT and POTA: close-in coverage. For POTA this REGIONAL + # ring is the "regional+national mix" prediction. + return "REGIONAL" + + def _band_components( + self, + req: RecommendationRequest, + band: str, + voacap_pred: dict | None, + regional: RegionalActivity | None, + personal: PersonalResult | None, + ) -> dict[str, float | None]: + """Normalize every scoring input to 0..1 (or None = unavailable).""" + comp: dict[str, float | None] = {} + + rel: float | None = None + if voacap_pred is not None: + band_pred = voacap_pred["bands"].get(band) + if band_pred is not None and band_pred.get("rel") is not None: + rel = clamp01(band_pred["rel"]) + comp["voacapReliability"] = rel + comp["targetReliability"] = rel if req.goal is Goal.TARGET else None + + if regional is not None: + activity = regional.bands.get(band) + if activity is not None: + comp["nearbyObservedSuccess"] = clamp01(activity.nearby_tx_total / 10.0) + comp["distancePotential"] = clamp01( + (activity.max_distance_km or 0.0) / MAX_DX_KM + ) + comp["directionalDiversity"] = clamp01( + activity.directional_octants / 8.0 + ) + ratio = regional.activity_ratio.get(band, 0.0) + comp["normalizedLiveActivity"] = clamp01(ratio / 2.0) + trend = regional.activity_trend.get(band, 0.0) + comp["activityTrend"] = clamp01((trend + 1.0) / 2.0) + comp["targetObservedPaths"] = self._target_paths(req, activity) + else: + # PSK data arrived and this band simply has no activity: that + # is evidence of a dead band, not a missing source. + comp["nearbyObservedSuccess"] = 0.0 + comp["distancePotential"] = 0.0 + comp["directionalDiversity"] = 0.0 + comp["normalizedLiveActivity"] = 0.0 + comp["activityTrend"] = 0.5 # flat + comp["targetObservedPaths"] = 0.0 + else: + for name in ( + "nearbyObservedSuccess", + "distancePotential", + "directionalDiversity", + "normalizedLiveActivity", + "activityTrend", + "targetObservedPaths", + ): + comp[name] = None + + comp["personalPerformance"] = ( + personal.per_band_performance.get(band, 0.0) + if personal is not None + else None + ) + return comp + + def _target_paths(self, req: RecommendationRequest, activity) -> float: + region_name = self._concrete_region_name(req) + if region_name is None: + return 0.0 + count = activity.nearby_tx_to_region.get(region_name, 0) + return clamp01(count / 5.0) + + def _concrete_region_name(self, req: RecommendationRequest) -> str | None: + name = req.target_region or self._primary_region(req) + if name in REGIONS: + return name + if name == "LONG_DX": + return farthest_region(req.grid).name + if name == "REGIONAL": + return home_region_for_field(req.grid) + return None + + def _score_all_bands(self, goal: Goal, gathered: dict) -> list[tuple[str, object]]: + scored = [ + (band, score_band(goal, gathered["components"][band])) + for band in BAND_ORDER + ] + # Deterministic: score desc, then canonical band order. + scored.sort(key=lambda item: (-item[1].score, BAND_ORDER.index(item[0]))) + return scored + + # ---------------------------------------------------------------- outputs + + def _confidence( + self, + req: RecommendationRequest, + gathered: dict, + band: str, + components: dict[str, float | None], + ) -> float: + regional: RegionalActivity | None = gathered["regional"] + obs = 0 + age_s = 900.0 + if regional is not None: + activity = regional.bands.get(band) + obs = activity.observation_count if activity is not None else 0 + age_s = max( + 0.0, (gathered["now"] - regional.fetched_at).total_seconds() + ) + sources_total = 3 if req.callsign else 2 + sources_available = sum( + [ + gathered["voacap_available"], + gathered["psk_available"], + gathered["personal_available"] if req.callsign else 0, + ] + ) + return compute_confidence( + observation_count=obs, + data_age_seconds=age_s, + voacap_observed_agreement=agreement_between( + components.get("voacapReliability"), + components.get("nearbyObservedSuccess"), + ), + sources_available=sources_available, + sources_total=sources_total, + profile_completeness=req.profile_completeness, + ) + + def _summary(self, req: RecommendationRequest, gathered: dict) -> str: + template = _SUMMARIES[req.goal] + if req.goal is Goal.TARGET: + target = ( + gathered["voacap"]["target"] + if gathered["voacap"] is not None + else (req.target_region or "target").replace("_", " ").title() + ) + return template.format(target=target) + return template + + def _destinations( + self, req: RecommendationRequest, gathered: dict, band: str + ) -> list[str]: + regional: RegionalActivity | None = gathered["regional"] + if regional is not None: + activity = regional.bands.get(band) + if activity is not None and activity.nearby_tx_to_region: + ranked = sorted( + activity.nearby_tx_to_region.items(), + key=lambda kv: (-kv[1], kv[0]), + ) + return [REGIONS[name].display_name for name, _ in ranked[:2]] + if gathered["voacap"] is not None: + return [gathered["voacap"]["target"]] + return [] + + def _evidence( + self, req: RecommendationRequest, gathered: dict, band: str, now + ) -> list[dict]: + evidence: list[dict] = [] + voacap = gathered["voacap"] + if voacap is not None: + band_pred = voacap["bands"].get(band) + if band_pred is not None and band_pred.get("rel") is not None: + evidence.append( + { + "type": "VOACAP", + "message": ( + f"VOACAP predicts {band_pred['rel'] * 100:.0f}% " + f"reliability toward {voacap['target']} on {band} " + f"at {now.hour:02d}00Z." + ), + } + ) + regional: RegionalActivity | None = gathered["regional"] + if regional is not None: + activity = regional.bands.get(band) + if activity is not None: + ratio = regional.activity_ratio.get(band, 0.0) + evidence.append( + { + "type": "REGIONAL_PSK_REPORTER", + "message": ( + f"{activity.nearby_tx_total} stations within 500 km " + f"heard on {band} in the last " + f"{self._settings.psk_window_s // 60} minutes " + f"(activity {ratio:.1f}x normal)." + ), + } + ) + personal: PersonalResult | None = gathered["personal"] + if personal is not None: + a = personal.analytics + evidence.append( + { + "type": "PERSONAL_PSK_REPORTER", + "message": ( + f"Your signal was received {a['reportsReceived']} times " + f"recently; best DX {a['maximumDistanceKm']} km." + ), + } + ) + return evidence + + def _top_component_reason(self, result) -> str: + if not result.components: + return "No supporting data." + top = max(result.components, key=lambda c: c.contribution) + label = _COMPONENT_LABELS.get(top.name, top.name) + return f"Strong {label} ({top.value:.2f})." + + def _active_regions(self, gathered: dict, band: str) -> list[str]: + regional: RegionalActivity | None = gathered["regional"] + if regional is None: + return [] + activity = regional.bands.get(band) + if activity is None: + return [] + return [ + REGIONS[name].display_name + for name, count in sorted(activity.nearby_tx_to_region.items()) + if count > 0 + ] + + def _condition_fields(self, gathered: dict, band: str) -> dict: + """Machine-readable per-band evidence for the mobile alert policy. + + ``unusualOpening`` = live activity far above the band-hour baseline + while VOACAP predicts little or nothing — the "the model missed this" + case the client labels explicitly. ``personalImprovement`` is absent: + /v1/conditions is anonymous by design (no callsign parameter). + """ + regional: RegionalActivity | None = gathered["regional"] + activity = regional.bands.get(band) if regional is not None else None + ratio = regional.activity_ratio.get(band, 0.0) if regional is not None else 0.0 + voacap = gathered["voacap"] + rel = None + if voacap is not None: + band_pred = voacap["bands"].get(band) + if band_pred is not None: + rel = band_pred.get("rel") + return { + "observationCount": activity.observation_count if activity else 0, + "activityRatio": round(ratio, 2), + "voacapSupported": rel is not None and rel >= 0.5, + "unusualOpening": ratio >= 2.5 and (rel is None or rel < 0.4), + } + + def _opening_evidence(self, gathered: dict, band: str) -> list[str]: + regional: RegionalActivity | None = gathered["regional"] + out: list[str] = [] + if regional is not None: + activity = regional.bands.get(band) + if activity is not None: + ratio = regional.activity_ratio.get(band, 0.0) + if ratio >= 1.5: + out.append(f"activity {ratio:.1f}x normal") + if activity.paths_over_8000km > 0: + out.append(f"{activity.paths_over_8000km} paths over 8000 km") + elif activity.paths_over_3000km > 0: + out.append(f"{activity.paths_over_3000km} paths over 3000 km") + voacap = gathered["voacap"] + if voacap is not None: + band_pred = voacap["bands"].get(band) + if ( + band_pred is not None + and band_pred.get("rel") is not None + and band_pred["rel"] >= 0.7 + ): + out.append(f"VOACAP reliability {band_pred['rel'] * 100:.0f}%") + return out diff --git a/server/band-advisor/app/scoring.py b/server/band-advisor/app/scoring.py new file mode 100644 index 000000000..9032d41e5 --- /dev/null +++ b/server/band-advisor/app/scoring.py @@ -0,0 +1,181 @@ +"""Deterministic band scoring and confidence — all pure functions. + +Score and confidence are computed SEPARATELY: + +* **score** answers "how good is this band for the stated goal" from the + available signal components (each already normalized to 0..1); +* **confidence** answers "how much should you trust that score" from data + quantity, freshness, cross-source agreement, source availability and + station-profile completeness. + +When a component is unavailable (``None``), it is dropped and the remaining +weights are renormalized to sum to 1, so a missing source never silently +drags a band to zero. Per-component contributions are returned for +explainability (they serialize directly into the ``scoreComponents`` +contract array). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Mapping + + +class Goal(str, Enum): + MAKE_CONTACT = "MAKE_CONTACT" + DX = "DX" + TARGET = "TARGET" + POTA = "POTA" + + +#: Component names use the contract's camelCase so they serialize verbatim. +GOAL_WEIGHTS: dict[Goal, dict[str, float]] = { + Goal.MAKE_CONTACT: { + "voacapReliability": 0.30, + "nearbyObservedSuccess": 0.25, + "normalizedLiveActivity": 0.15, + "distancePotential": 0.10, + "activityTrend": 0.10, + "personalPerformance": 0.10, + }, + Goal.DX: { + "distancePotential": 0.25, + "voacapReliability": 0.25, + "directionalDiversity": 0.15, + "nearbyObservedSuccess": 0.15, + "normalizedLiveActivity": 0.10, + "personalPerformance": 0.10, + }, + Goal.TARGET: { + "targetReliability": 0.40, + "targetObservedPaths": 0.30, + "normalizedLiveActivity": 0.10, + "voacapReliability": 0.10, + "personalPerformance": 0.10, + }, + Goal.POTA: { + # voacapReliability here is a regional+national mix (see recommendation + # layer: REGIONAL target prediction is used for POTA). + "voacapReliability": 0.35, + "nearbyObservedSuccess": 0.25, + "normalizedLiveActivity": 0.20, + "activityTrend": 0.10, + "personalPerformance": 0.10, + }, +} + + +@dataclass(frozen=True) +class ScoreComponent: + """One weighted component of a band score (contract ``scoreComponents``).""" + + name: str + weight: float # renormalized weight actually used + value: float # 0..1 input value + contribution: float # weight * value + + def to_json(self) -> dict: + return { + "name": self.name, + "weight": round(self.weight, 4), + "value": round(self.value, 4), + "contribution": round(self.contribution, 4), + } + + +@dataclass(frozen=True) +class ScoreResult: + score: float + components: tuple[ScoreComponent, ...] + + +def clamp01(value: float) -> float: + return max(0.0, min(1.0, float(value))) + + +def score_band(goal: Goal, components: Mapping[str, float | None]) -> ScoreResult: + """Weighted score for one band. + + Args: + goal: the operator goal (selects the weight table). + components: component name -> value in [0, 1], or ``None`` when that + signal is unavailable. Unknown component names are ignored. + + Missing components are dropped and remaining weights renormalized to sum + to 1. If *nothing* is available the score is 0.0 with no components. + """ + weights = GOAL_WEIGHTS[goal] + available = { + name: clamp01(components[name]) + for name in weights + if components.get(name) is not None + } + if not available: + return ScoreResult(score=0.0, components=()) + + total_weight = sum(weights[name] for name in available) + parts: list[ScoreComponent] = [] + score = 0.0 + for name in weights: # weight-table order => deterministic output order + if name not in available: + continue + w = weights[name] / total_weight + v = available[name] + contribution = w * v + score += contribution + parts.append( + ScoreComponent(name=name, weight=w, value=v, contribution=contribution) + ) + return ScoreResult(score=round(score, 4), components=tuple(parts)) + + +def compute_confidence( + observation_count: int, + data_age_seconds: float, + voacap_observed_agreement: float | None, + sources_available: int, + sources_total: int, + profile_completeness: float, +) -> float: + """Trust level for a recommendation, independent of the score itself. + + Heuristic blend (weights documented in README): + + * observation volume (35 %): saturates at 20 observations; + * freshness (20 %): linear decay to 0 over 15 minutes; + * VOACAP-vs-observed agreement (20 %): 1.0 when prediction and live + observations tell the same story; ``None`` (not comparable) counts 0.5; + * source availability (15 %): fraction of upstream sources that answered; + * profile completeness (10 %): how fully the station described itself + (grid/power/antenna/noise/callsign supplied vs defaulted). + """ + obs = clamp01(observation_count / 20.0) + freshness = clamp01(1.0 - max(0.0, data_age_seconds) / 900.0) + agreement = 0.5 if voacap_observed_agreement is None else clamp01( + voacap_observed_agreement + ) + availability = clamp01( + sources_available / sources_total if sources_total else 0.0 + ) + completeness = clamp01(profile_completeness) + confidence = ( + 0.35 * obs + + 0.20 * freshness + + 0.20 * agreement + + 0.15 * availability + + 0.10 * completeness + ) + return round(clamp01(confidence), 4) + + +def agreement_between( + voacap_reliability: float | None, observed_success: float | None +) -> float | None: + """0..1 agreement between VOACAP reliability and observed activity. + + Returns ``None`` when either side is missing (not comparable). + """ + if voacap_reliability is None or observed_success is None: + return None + return clamp01(1.0 - abs(clamp01(voacap_reliability) - clamp01(observed_success))) diff --git a/server/band-advisor/app/ttl_cache.py b/server/band-advisor/app/ttl_cache.py new file mode 100644 index 000000000..79eaddb10 --- /dev/null +++ b/server/band-advisor/app/ttl_cache.py @@ -0,0 +1,120 @@ +"""Small in-memory TTL cache with optional JSON disk persistence. + +Deliberately simple (a dict + expiry timestamps) — this service runs as a +single process per container; anything fancier (redis, etc.) is a deployment +decision left for later. The clock is injected so tests are deterministic. +""" + +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any, Callable + +log = logging.getLogger(__name__) + +# Cache keys are tuples of primitives; stored values must be JSON-serializable +# when disk persistence is enabled. +CacheKey = tuple[Any, ...] + + +class TTLCache: + """Thread-safe TTL cache keyed by tuples of primitives. + + Args: + ttl_seconds: entry lifetime. + time_fn: monotonic-seconds source (injectable for tests). + max_entries: soft bound; oldest-expiring entries are evicted first. + disk_path: optional JSON file for persistence across restarts. + Only enable for values that are plain JSON data. + """ + + def __init__( + self, + ttl_seconds: float, + time_fn: Callable[[], float], + max_entries: int = 512, + disk_path: Path | None = None, + ) -> None: + self.ttl_seconds = float(ttl_seconds) + self._time_fn = time_fn + self._max_entries = max_entries + self._disk_path = disk_path + self._lock = threading.Lock() + # key -> (expires_at_monotonic, value) + self._entries: dict[CacheKey, tuple[float, Any]] = {} + if disk_path is not None: + self._load_disk() + + def get(self, key: CacheKey) -> Any | None: + now = self._time_fn() + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + expires_at, value = entry + if now >= expires_at: + del self._entries[key] + return None + return value + + def set(self, key: CacheKey, value: Any) -> None: + now = self._time_fn() + with self._lock: + self._entries[key] = (now + self.ttl_seconds, value) + if len(self._entries) > self._max_entries: + self._evict_locked(now) + if self._disk_path is not None: + self._save_disk() + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + def __len__(self) -> int: + with self._lock: + return len(self._entries) + + def _evict_locked(self, now: float) -> None: + # Drop expired first, then oldest-expiring until within bounds. + self._entries = { + k: v for k, v in self._entries.items() if v[0] > now + } + while len(self._entries) > self._max_entries: + oldest = min(self._entries, key=lambda k: self._entries[k][0]) + del self._entries[oldest] + + # -- optional disk persistence ------------------------------------------ + + def _save_disk(self) -> None: + assert self._disk_path is not None + try: + now = self._time_fn() + payload = [ + {"key": list(k), "ttl_remaining": exp - now, "value": v} + for k, (exp, v) in self._entries.items() + if exp > now + ] + self._disk_path.parent.mkdir(parents=True, exist_ok=True) + self._disk_path.write_text(json.dumps(payload)) + except OSError as exc: # persistence is best-effort + log.warning("cache disk save failed: %s", exc) + + def _load_disk(self) -> None: + assert self._disk_path is not None + try: + if not self._disk_path.exists(): + return + payload = json.loads(self._disk_path.read_text()) + now = self._time_fn() + for item in payload: + remaining = float(item.get("ttl_remaining", 0)) + if remaining > 0: + self._entries[tuple(item["key"])] = ( + now + remaining, + item["value"], + ) + except (OSError, ValueError, KeyError, TypeError) as exc: + log.warning("cache disk load failed (ignored): %s", exc) diff --git a/server/band-advisor/app/voacap/__init__.py b/server/band-advisor/app/voacap/__init__.py new file mode 100644 index 000000000..2e5d2e82f --- /dev/null +++ b/server/band-advisor/app/voacap/__init__.py @@ -0,0 +1,7 @@ +"""VOACAP propagation engine wrapper. + +The VOACAP engine is invoked as an EXTERNAL tool: ``voacapl`` +(https://github.com/jawatson/voacapl, GPL), James Watson's Linux port of +NTIA/ITS VOACAP. This package only builds input decks, runs the binary via +subprocess, and parses its text output — no VOACAP code is copied or linked. +""" diff --git a/server/band-advisor/app/voacap/cache.py b/server/band-advisor/app/voacap/cache.py new file mode 100644 index 000000000..78079e653 --- /dev/null +++ b/server/band-advisor/app/voacap/cache.py @@ -0,0 +1,62 @@ +"""TTL cache for VOACAP predictions. + +Keyed EXACTLY by:: + + (tx_grid_4char, month, utc_hour, power_class, antenna_class, + noise_class, target_region) + +Predictions for a given month/hour are deterministic (same deck -> same +output), so the TTL (default 6 h) is mainly a memory bound, not a freshness +requirement. Optional JSON disk persistence survives container restarts. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable + +from ..ttl_cache import TTLCache + + +def voacap_cache_key( + tx_grid: str, + month: int, + utc_hour: int, + power_class: str, + antenna_class: str, + noise_class: str, + target_region: str, +) -> tuple: + """Build the canonical VOACAP cache key.""" + return ( + tx_grid.upper()[:4], + int(month), + int(utc_hour), + str(power_class), + str(antenna_class), + str(noise_class), + str(target_region), + ) + + +class VoacapCache: + """Thin wrapper enforcing the canonical key shape.""" + + def __init__( + self, + ttl_seconds: float, + time_fn: Callable[[], float], + disk_path: Path | None = None, + ) -> None: + self._cache = TTLCache( + ttl_seconds=ttl_seconds, + time_fn=time_fn, + max_entries=2048, + disk_path=disk_path, + ) + + def get(self, **key_parts: Any) -> Any | None: + return self._cache.get(voacap_cache_key(**key_parts)) + + def set(self, value: Any, **key_parts: Any) -> None: + self._cache.set(voacap_cache_key(**key_parts), value) diff --git a/server/band-advisor/app/voacap/ft8_profile.py b/server/band-advisor/app/voacap/ft8_profile.py new file mode 100644 index 000000000..316efacf1 --- /dev/null +++ b/server/band-advisor/app/voacap/ft8_profile.py @@ -0,0 +1,89 @@ +"""FT8 required-SNR profile for VOACAP decks. + +Why this module exists — the units are a classic trap +----------------------------------------------------- +VOACAP's REQ.SNR (the ``RSN`` field on the SYSTEM card) is specified in +**dB-Hz**: the signal-to-noise ratio referenced to a 1 Hz noise bandwidth. + +The commonly quoted FT8 decode threshold of **-21 dB** is instead referenced +to a **2500 Hz** SSB bandwidth (the WSJT-X convention for reporting SNR). +Converting between the two: + + SNR(dB-Hz) = SNR(2500 Hz) + 10*log10(2500) + = -21 + 33.98 + ~ 13 dB-Hz + +So the correct VOACAP requirement for "an FT8 signal is just decodable" is +about **13 dB-Hz**, not -21. Feeding -21 into REQ.SNR would ask VOACAP for a +circuit ~34 dB worse than intended and *everything* would look reliable. + +Assumptions (documented deliberately): + +* -21 dB is the *a-priori single-signal decode threshold* of the standard + FT8 decoder. Deep decodes, AP (a-priori) decoding, and multi-pass + subtraction can pull decodes from a few dB lower, but those are + opportunistic and should not be the planning baseline. +* ``snr_margin_db`` (default 3 dB) is a real-world margin over the bare + threshold: QSB/fading between 15 s slots, multi-signal QRM in the 3 kHz + passband, and imperfect audio chains all eat into the budget. The margin + is added to the REQ.SNR card, so the default card value is 13 + 3 = 16. +* VOACAP then reports REL as the probability that the *median-hour* SNR + exceeds this requirement, which is exactly the "can I work someone there" + question the Band Advisor asks. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +#: WSJT-X SNR reporting reference bandwidth (Hz). +FT8_REFERENCE_BANDWIDTH_HZ = 2500.0 + +#: A-priori single-signal FT8 decode threshold in the 2500 Hz reference. +FT8_DECODE_THRESHOLD_DB_2500 = -21.0 + + +def snr_dbhz_from_2500(snr_db_2500: float, bandwidth_hz: float = FT8_REFERENCE_BANDWIDTH_HZ) -> float: + """Convert an SNR referenced to ``bandwidth_hz`` into dB-Hz (1 Hz reference).""" + return snr_db_2500 + 10.0 * math.log10(bandwidth_hz) + + +def snr_2500_from_dbhz(snr_dbhz: float, bandwidth_hz: float = FT8_REFERENCE_BANDWIDTH_HZ) -> float: + """Convert a dB-Hz SNR back into the 2500 Hz WSJT-X reporting reference.""" + return snr_dbhz - 10.0 * math.log10(bandwidth_hz) + + +@dataclass(frozen=True) +class FT8SnrProfile: + """Configurable FT8 SNR requirement for the VOACAP SYSTEM card. + + Attributes: + required_snr_dbhz: bare decode requirement in dB-Hz. Default 13, + i.e. round(-21 + 10*log10(2500)). + snr_margin_db: planning margin added on top (fading, QRM, audio + chain). Default 3. + """ + + required_snr_dbhz: float = 13.0 + snr_margin_db: float = 3.0 + + @property + def req_snr_card_db(self) -> float: + """Value written to the VOACAP REQ.SNR (RSN) field: threshold + margin.""" + return self.required_snr_dbhz + self.snr_margin_db + + @classmethod + def from_2500hz_threshold( + cls, + threshold_db_2500: float = FT8_DECODE_THRESHOLD_DB_2500, + snr_margin_db: float = 3.0, + ) -> "FT8SnrProfile": + """Build a profile from a 2500 Hz-referenced threshold (e.g. -21 dB).""" + return cls( + required_snr_dbhz=round(snr_dbhz_from_2500(threshold_db_2500)), + snr_margin_db=snr_margin_db, + ) + + +DEFAULT_FT8_PROFILE = FT8SnrProfile() diff --git a/server/band-advisor/app/voacap/input_builder.py b/server/band-advisor/app/voacap/input_builder.py new file mode 100644 index 000000000..fab91504a --- /dev/null +++ b/server/band-advisor/app/voacap/input_builder.py @@ -0,0 +1,153 @@ +"""Build VOACAP input decks (``voacapx.dat`` card format) for voacapl. + +VOACAP is driven by fixed-format "cards" — one keyword per line, values in +Fortran-style fixed-width fields. This builder emits a Method-30 deck for +all 24 UTC hours at the 10 standard FT8 dial frequencies. + +Card layout notes (kept close to the decks pythonprop/voacapl ship): + +* ``SYSTEM 1. . 3.00 90. 3.00 0.10`` — fields are + power-flag(1.), man-made noise at 3 MHz as a positive -dBW number, + minimum takeoff angle (deg), required circuit reliability (%), + required SNR (dB-Hz — see :mod:`.ft8_profile`), multipath power + tolerance (dB) and max multipath delay (ms). +* TX power lives on the TX ANTENNA card's final field, in **kW**. +* The FREQUENCY card takes up to 11 F5.2 MHz values, no separators. + +TODO(scaffold): the column alignment below matches published voacapx.dat +samples; verify byte-for-byte against a real voacapl run once the container +image exists, and replace the generic antenna files with per-class gain +patterns. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from ..bands import FT8_DIAL_FREQ_MHZ +from ..geo import grid_to_latlon, normalize_grid4 +from .ft8_profile import DEFAULT_FT8_PROFILE, FT8SnrProfile + + +class PowerClass(str, Enum): + """Coarse TX power classes exposed by the API, mapped to watts.""" + + QRP = "QRP" # 5 W + LOW = "LOW" # 25 W + STANDARD = "STANDARD" # 100 W + HIGH = "HIGH" # 1000 W + + +POWER_CLASS_WATTS: dict[PowerClass, float] = { + PowerClass.QRP: 5.0, + PowerClass.LOW: 25.0, + PowerClass.STANDARD: 100.0, + PowerClass.HIGH: 1000.0, +} + + +class AntennaClass(str, Enum): + """Coarse antenna classes mapped to built-in voacapl antenna cards. + + TODO(scaffold): these are simple built-in models (isotrope / dipole / + whip); real gain-pattern files per band would be a follow-up. + """ + + ISOTROPE = "ISOTROPE" + WIRE = "WIRE" # horizontal dipole-ish + VERTICAL = "VERTICAL" # whip + BEAM = "BEAM" # placeholder: modelled as dipole until real patterns exist + + +ANTENNA_CLASS_FILE: dict[AntennaClass, str] = { + AntennaClass.ISOTROPE: "default/isotrope", + AntennaClass.WIRE: "default/dipole.voa", + AntennaClass.VERTICAL: "default/swwhip.voa", + AntennaClass.BEAM: "default/dipole.voa", +} + + +class NoiseClass(str, Enum): + """Man-made noise environment (dBW/Hz at 3 MHz, ITU-R P.372 style).""" + + QUIET = "QUIET" # -164 + RURAL = "RURAL" # -154 + RESIDENTIAL = "RESIDENTIAL" # -145 + URBAN = "URBAN" # -136 + + +NOISE_CLASS_DBW: dict[NoiseClass, float] = { + NoiseClass.QUIET: -164.0, + NoiseClass.RURAL: -154.0, + NoiseClass.RESIDENTIAL: -145.0, + NoiseClass.URBAN: -136.0, +} + + +@dataclass(frozen=True) +class DeckParams: + """Everything needed to build one VOACAP run deck.""" + + tx_grid: str # 4-char Maidenhead square + month: int # 1..12 + year: int + rx_lat: float + rx_lon: float + rx_label: str + power_class: PowerClass + antenna_class: AntennaClass + noise_class: NoiseClass + ssn: float + ft8_profile: FT8SnrProfile = DEFAULT_FT8_PROFILE + + +def _coord(value: float, pos: str, neg: str) -> str: + """Format one CIRCUIT-card coordinate: F6.2 + hemisphere letter.""" + hemi = pos if value >= 0 else neg + return f"{abs(value):6.2f}{hemi}" + + +def build_deck(params: DeckParams) -> str: + """Render the full voacapx.dat card deck as text (trailing newline included).""" + if not 1 <= params.month <= 12: + raise ValueError(f"month out of range: {params.month}") + tx_grid = normalize_grid4(params.tx_grid) + tx_lat, tx_lon = grid_to_latlon(tx_grid) + + power_kw = POWER_CLASS_WATTS[params.power_class] / 1000.0 + noise_pos = -NOISE_CLASS_DBW[params.noise_class] # card wants positive + rsn = params.ft8_profile.req_snr_card_db + tx_ant = ANTENNA_CLASS_FILE[params.antenna_class] + rx_label = (params.rx_label or "RX")[:20] + + freqs = "".join(f"{mhz:5.2f}" for mhz in FT8_DIAL_FREQ_MHZ.values()) + + lines = [ + "LINEMAX 55 number of lines-per-page", + "COEFFS CCIR", + "TIME 1 24 1 1", + f"MONTH {params.year:4d}{params.month:5.2f}", + f"SUNSPOT {params.ssn:4.0f}.", + f"LABEL {tx_grid:<20s} {rx_label}", + ( + "CIRCUIT " + + _coord(tx_lat, "N", "S") + + " " + + _coord(tx_lon, "E", "W") + + " " + + _coord(params.rx_lat, "N", "S") + + " " + + _coord(params.rx_lon, "E", "W") + + " S 0" + ), + f"SYSTEM 1. {noise_pos:4.0f}. 3.00 90. {rsn:4.1f} 3.00 0.10", + "FPROB 1.00 1.00 1.00 0.00", + f"ANTENNA 1 1 2 30 0.000[{tx_ant:<21s}] 0.0{power_kw:10.4f}", + "ANTENNA 2 2 2 30 0.000[default/isotrope ] 0.0 0.0000", + f"FREQUENCY {freqs}", + "METHOD 30 0", + "EXECUTE", + "QUIT", + ] + return "\n".join(lines) + "\n" diff --git a/server/band-advisor/app/voacap/parser.py b/server/band-advisor/app/voacap/parser.py new file mode 100644 index 000000000..e553a3ba7 --- /dev/null +++ b/server/band-advisor/app/voacap/parser.py @@ -0,0 +1,177 @@ +"""Parse voacapl Method-30 text output into structured per-band predictions. + +The Method 30 report prints, for every predicted hour, a block of rows in +fixed 5-character Fortran columns. The first row of a block ends with the +label ``FREQ`` and carries: hour (col 1), MUF (col 2), then one column per +frequency from the FREQUENCY card (unused slots print ``0.0``). Each +following row carries one quantity per frequency column with its label at +the end of the line (``MODE``, ``MUFday``, ``SNR``, ``REL``, ``SNRxx`` ...). + +This parser is deliberately defensive: unknown rows are ignored, unparsable +cells become ``None``, and FT8 bands whose frequency column is absent from +the output are reported as ``None`` entries rather than raising. + +Units: ``SNR``/``SNRxx`` are median / 90th-percentile-exceeded SNR in +**dB-Hz** (see :mod:`.ft8_profile`), ``REL`` is 0.00-1.00 circuit +reliability, ``MUFday`` is the fraction of days the band is open. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from ..bands import BAND_ORDER, band_for_frequency_mhz + +_COL_WIDTH = 5 + +#: Row labels we extract, mapped to attribute names. +_ROW_LABELS = { + "MODE": "mode", + "MUFday": "mufday", + "SNR": "snr_dbhz", + "REL": "rel", + "SNRxx": "snrxx_dbhz", + "SNR90": "snrxx_dbhz", # some builds label the percentile row explicitly +} + +_SSN_RE = re.compile(r"SSN\s*=\s*([0-9.]+)") + + +@dataclass +class BandPrediction: + """One (hour, band) VOACAP prediction cell.""" + + band: str + freq_mhz: float + mode: str | None = None + rel: float | None = None + snr_dbhz: float | None = None + snrxx_dbhz: float | None = None + mufday: float | None = None + + +@dataclass +class VoacapPrediction: + """Parsed prediction: ``hours[utc_hour][band]`` -> BandPrediction or None.""" + + ssn: float | None = None + hours: dict[int, dict[str, BandPrediction | None]] = field(default_factory=dict) + + +def _chunks(line: str) -> list[str]: + return [ + line[i : i + _COL_WIDTH] for i in range(0, len(line), _COL_WIDTH) + ] + + +def _try_float(cell: str) -> float | None: + cell = cell.strip() + if not cell or cell == "-": + return None + try: + return float(cell) + except ValueError: + return None + + +def parse_output(text: str) -> VoacapPrediction: + """Parse a full voacapl Method-30 report. Never raises on malformed rows.""" + result = VoacapPrediction() + + ssn_match = _SSN_RE.search(text) + if ssn_match: + try: + result.ssn = float(ssn_match.group(1)) + except ValueError: + pass + + current_hour: int | None = None + current_cols: list[tuple[int, str]] = [] # (column index, band name) + n_value_cols = 0 + + for raw_line in text.splitlines(): + line = raw_line.rstrip("\n") + stripped = line.strip() + + if stripped.endswith("FREQ"): + hour, cols = _parse_freq_line(line) + if hour is None: + current_hour = None + continue + current_hour = hour + current_cols = cols + n_value_cols = _count_numeric_cols(line) + result.hours[hour] = { + band: BandPrediction(band=band, freq_mhz=freq) + for _, band, freq in _named(cols) + } + _fill_missing_bands(result.hours[hour]) + continue + + if current_hour is None or not stripped: + continue + + label = line[n_value_cols * _COL_WIDTH :].strip() + attr = _ROW_LABELS.get(label) + if attr is None: + continue + cells = _chunks(line) + hour_bands = result.hours[current_hour] + for col_idx, band, _freq in _named(current_cols): + pred = hour_bands.get(band) + if pred is None or col_idx >= len(cells): + continue + cell = cells[col_idx] + if attr == "mode": + value: object = cell.strip() or None + if value == "-": + value = None + else: + value = _try_float(cell) + setattr(pred, attr, value) + + return result + + +def _parse_freq_line(line: str) -> tuple[int | None, list[tuple[int, str]]]: + """Return (utc_hour, [(column_index, band, freq_mhz), ...]) for a FREQ row.""" + cells = _chunks(line) + values: list[float | None] = [_try_float(c) for c in cells] + if len(values) < 3 or values[0] is None: + return None, [] + hour_raw = values[0] + # VOACAP hours run 1..24 where 24 means 00 UTC. + utc_hour = int(round(hour_raw)) % 24 + cols: list[tuple[int, str, float]] = [] + for idx in range(2, len(values)): + freq = values[idx] + if freq is None or freq <= 0.0: + continue + band = band_for_frequency_mhz(freq) + if band is None: + continue + cols.append((idx, band, freq)) + return utc_hour, cols # type: ignore[return-value] + + +def _count_numeric_cols(freq_line: str) -> int: + """Number of 5-char value columns before the trailing FREQ label.""" + cells = _chunks(freq_line) + n = 0 + for cell in cells: + if _try_float(cell) is not None: + n += 1 + else: + break + return n + + +def _named(cols: list) -> list[tuple[int, str, float]]: + return list(cols) + + +def _fill_missing_bands(hour_bands: dict[str, BandPrediction | None]) -> None: + """Guarantee an entry (possibly None) for every FT8 band.""" + for band in BAND_ORDER: + hour_bands.setdefault(band, None) diff --git a/server/band-advisor/app/voacap/regions.py b/server/band-advisor/app/voacap/regions.py new file mode 100644 index 000000000..cb37e5712 --- /dev/null +++ b/server/band-advisor/app/voacap/regions.py @@ -0,0 +1,113 @@ +"""Target region definitions for VOACAP receiver endpoints and PSK scoring. + +Centroids are deliberately coarse "center of ham population" points, not +geographic centroids — e.g. EUROPE sits near the DL/OK border, ASIA is pulled +east toward JA. Grid lists are representative squares used for scoring +observed paths into a region. + +Two pseudo-regions are resolved per-observer: + +* ``REGIONAL`` — a ~1500 km ring on the observer's own continent (close-in + single-hop coverage, the POTA/"make contact" case). +* ``LONG_DX`` — the concrete region whose centroid is farthest from the + observer (antipodal-ish long-path target). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..geo import destination_point, grid_to_latlon, haversine_km + + +@dataclass(frozen=True) +class Region: + """A scoring/prediction target region.""" + + name: str + display_name: str + centroid_lat: float + centroid_lon: float + #: Representative 4-char squares, used to score observed paths. + grids: tuple[str, ...] + + +#: Concrete geographic regions (pseudo-regions REGIONAL/LONG_DX are computed). +REGIONS: dict[str, Region] = { + "EUROPE": Region("EUROPE", "Europe", 50.0, 12.0, ("JO01", "JN58", "JN18", "KO85", "JN61")), + "NORTH_AMERICA_EAST": Region( + "NORTH_AMERICA_EAST", "North America (East)", 40.0, -78.0, ("FN31", "FN20", "EM73", "FN03") + ), + "NORTH_AMERICA_WEST": Region( + "NORTH_AMERICA_WEST", "North America (West)", 39.0, -114.0, ("DM04", "CN87", "DM33", "DN70") + ), + "SOUTH_AMERICA": Region( + "SOUTH_AMERICA", "South America", -20.0, -55.0, ("GG66", "GF05", "FF95", "FH52") + ), + "AFRICA": Region("AFRICA", "Africa", 0.0, 20.0, ("KG33", "JJ58", "IL38", "KI88")), + "ASIA": Region("ASIA", "Asia", 35.0, 115.0, ("PM95", "OM89", "MN66", "OL72")), + "OCEANIA": Region("OCEANIA", "Oceania", -27.0, 145.0, ("QF56", "QG62", "RF73", "PF95")), +} + +REGION_NAMES: tuple[str, ...] = tuple(REGIONS.keys()) + +#: Names accepted by the API ``targetRegion`` parameter. +TARGET_REGION_NAMES: tuple[str, ...] = REGION_NAMES + ("REGIONAL", "LONG_DX") + +#: Radius of the REGIONAL pseudo-target ring, km. +REGIONAL_RING_KM = 1500.0 + + +def home_region_for_field(field_or_grid: str) -> str: + """Map a Maidenhead locator (2-char field is enough) to its home region. + + Nearest-centroid assignment — coarse, but stable and good enough to + bucket "which continent is this station on". + """ + lat, lon = grid_to_latlon(field_or_grid[:2]) + best_name = REGION_NAMES[0] + best_km = float("inf") + for region in REGIONS.values(): + km = haversine_km(lat, lon, region.centroid_lat, region.centroid_lon) + if km < best_km: + best_km = km + best_name = region.name + return best_name + + +def farthest_region(grid: str) -> Region: + """The concrete region whose centroid is farthest from ``grid`` (LONG_DX).""" + lat, lon = grid_to_latlon(grid) + return max( + REGIONS.values(), + key=lambda r: haversine_km(lat, lon, r.centroid_lat, r.centroid_lon), + ) + + +def resolve_target(grid: str, target_region: str) -> tuple[str, float, float]: + """Resolve a target-region name to (display_name, rx_lat, rx_lon). + + Handles the two pseudo-regions: + + * ``REGIONAL``: a point ~1500 km from the observer, in the direction of + the observer's home-region centroid (due north if the centroid is + within 500 km, to avoid a degenerate zero-length bearing). + * ``LONG_DX``: centroid of the farthest concrete region. + """ + if target_region == "LONG_DX": + r = farthest_region(grid) + return r.display_name, r.centroid_lat, r.centroid_lon + if target_region == "REGIONAL": + lat, lon = grid_to_latlon(grid) + home = REGIONS[home_region_for_field(grid)] + dist = haversine_km(lat, lon, home.centroid_lat, home.centroid_lon) + if dist < 500.0: + bearing = 0.0 + else: + from ..geo import azimuth_deg + + bearing = azimuth_deg(lat, lon, home.centroid_lat, home.centroid_lon) + rx_lat, rx_lon = destination_point(lat, lon, bearing, REGIONAL_RING_KM) + return "Regional", rx_lat, rx_lon + region = REGIONS[target_region] + return region.display_name, region.centroid_lat, region.centroid_lon diff --git a/server/band-advisor/app/voacap/runner.py b/server/band-advisor/app/voacap/runner.py new file mode 100644 index 000000000..2081375bb --- /dev/null +++ b/server/band-advisor/app/voacap/runner.py @@ -0,0 +1,95 @@ +"""Run the external ``voacapl`` binary on a deck and return its text output. + +Fortran filename gotcha (IMPORTANT, do not "fix" this) +------------------------------------------------------ +voacapl inherits VOACAP's fixed-length Fortran filename buffers. Run files +must live in the classic itshfbc layout — ``/run/`` — and the +*names passed on the command line are relative to that directory* and must +stay short (roughly <=30 characters; long absolute paths inside the deck or +on the command line overflow the buffers and fail in bizarre ways, e.g. +truncated paths or silent empty output). That is why this runner: + +* writes decks only into ``/run/``; +* uses short random names like ``ba3f9a1c.dat`` / ``ba3f9a1c.out``; +* invokes ``voacapl .dat .out`` with bare names, + never absolute paths. +""" + +from __future__ import annotations + +import logging +import secrets +import subprocess +from pathlib import Path +from typing import Callable + +from ..config import Settings + +log = logging.getLogger(__name__) + + +class VoacapError(RuntimeError): + """voacapl failed, timed out, or produced no output.""" + + +RunFn = Callable[..., "subprocess.CompletedProcess[str]"] + + +class VoacapRunner: + """Writes a deck under ``/run/`` and executes voacapl on it. + + ``run_fn`` is injectable (defaults to :func:`subprocess.run`) so tests + never execute a real binary. + """ + + def __init__(self, settings: Settings, run_fn: RunFn | None = None) -> None: + self._settings = settings + self._run_fn: RunFn = run_fn or subprocess.run + + def run(self, deck_text: str) -> str: + """Execute one prediction; returns the raw voacapl output text.""" + run_dir = Path(self._settings.itshfbc_path) / "run" + run_dir.mkdir(parents=True, exist_ok=True) + + name = f"ba{secrets.token_hex(3)}" # e.g. ba3f9a1c — 8 chars, Fortran-safe + in_name, out_name = f"{name}.dat", f"{name}.out" + in_path, out_path = run_dir / in_name, run_dir / out_name + + in_path.write_text(deck_text) + try: + try: + proc = self._run_fn( + [ + self._settings.voacapl_path, + self._settings.itshfbc_path, + in_name, + out_name, + ], + capture_output=True, + text=True, + timeout=self._settings.voacap_timeout_s, + ) + except subprocess.TimeoutExpired as exc: + raise VoacapError( + f"voacapl timed out after {self._settings.voacap_timeout_s}s" + ) from exc + except OSError as exc: + raise VoacapError(f"voacapl could not be executed: {exc}") from exc + + if proc.returncode != 0: + log.warning( + "voacapl exited rc=%d stderr=%.200s", proc.returncode, proc.stderr + ) + raise VoacapError(f"voacapl exited with rc={proc.returncode}") + if not out_path.exists(): + raise VoacapError("voacapl produced no output file") + output = out_path.read_text(errors="replace") + if not output.strip(): + raise VoacapError("voacapl output file is empty") + return output + finally: + for p in (in_path, out_path): + try: + p.unlink(missing_ok=True) + except OSError: # cleanup is best-effort + pass diff --git a/server/band-advisor/app/voacap/service.py b/server/band-advisor/app/voacap/service.py new file mode 100644 index 000000000..27c543f80 --- /dev/null +++ b/server/band-advisor/app/voacap/service.py @@ -0,0 +1,141 @@ +"""Facade tying deck builder + runner + parser + cache together. + +The recommendation layer talks to this service only through +:meth:`VoacapService.predict`, which returns plain JSON-serializable dicts +(so results can be disk-cached). +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any + +from ..clock import Clock +from ..config import Settings +from .cache import VoacapCache +from .ft8_profile import DEFAULT_FT8_PROFILE, FT8SnrProfile +from .input_builder import ( + AntennaClass, + DeckParams, + NoiseClass, + PowerClass, + build_deck, +) +from .parser import parse_output +from .regions import resolve_target +from .runner import VoacapError, VoacapRunner + +log = logging.getLogger(__name__) + + +class VoacapService: + """Cached VOACAP predictions per (grid, month, hour, station, region).""" + + def __init__( + self, + settings: Settings, + clock: Clock, + runner: VoacapRunner | None = None, + profile: FT8SnrProfile = DEFAULT_FT8_PROFILE, + ) -> None: + self._lock = threading.Lock() + self._settings = settings + self._clock = clock + self._runner = runner or VoacapRunner(settings) + self._profile = profile + self._cache = VoacapCache( + ttl_seconds=settings.voacap_cache_ttl_s, + time_fn=clock.monotonic, + disk_path=settings.voacap_cache_disk_path, + ) + + def predict( + self, + tx_grid: str, + month: int, + year: int, + utc_hour: int, + power_class: PowerClass, + antenna_class: AntennaClass, + noise_class: NoiseClass, + target_region: str, + ) -> dict[str, Any]: + """Per-band prediction dict for one UTC hour toward one target region. + + Returns ``{"target": display_name, "bands": {band: {...} | None}}``. + Raises :class:`VoacapError` when the engine fails (callers degrade). + """ + with self._lock: + key = dict( + tx_grid=tx_grid, + month=month, + utc_hour=utc_hour, + power_class=power_class.value, + antenna_class=antenna_class.value, + noise_class=noise_class.value, + target_region=target_region, + ) + cached = self._cache.get(**key) + if cached is not None: + return cached + + display_name, rx_lat, rx_lon = resolve_target(tx_grid, target_region) + deck = build_deck( + DeckParams( + tx_grid=tx_grid, + month=month, + year=year, + rx_lat=rx_lat, + rx_lon=rx_lon, + rx_label=target_region, + power_class=power_class, + antenna_class=antenna_class, + noise_class=noise_class, + ssn=self._settings.sunspot_number, + ft8_profile=self._profile, + ) + ) + output = self._runner.run(deck) # may raise VoacapError + parsed = parse_output(output) + + # The deck predicts all 24 hours; populate the cache for each of them + # (same key shape, different utc_hour) in one engine invocation. + result: dict[str, Any] | None = None + for hour, bands in parsed.hours.items(): + entry = { + "target": display_name, + "targetRegion": target_region, + "bands": { + band: ( + None + if pred is None + else { + "freqMhz": pred.freq_mhz, + "rel": pred.rel, + "snrDbHz": pred.snr_dbhz, + "snr90DbHz": pred.snrxx_dbhz, + "mufday": pred.mufday, + "mode": pred.mode, + } + ) + for band, pred in bands.items() + }, + } + self._cache.set(entry, **{**key, "utc_hour": hour}) + if hour == utc_hour: + result = entry + + if result is None: + raise VoacapError( + f"voacapl output contained no prediction for hour {utc_hour:02d}Z" + ) + return result + + def binary_available(self) -> bool: + """Cheap liveness check used by /healthz.""" + import os + + return os.path.isfile(self._settings.voacapl_path) and os.access( + self._settings.voacapl_path, os.X_OK + ) diff --git a/server/band-advisor/docker-compose.yml b/server/band-advisor/docker-compose.yml new file mode 100644 index 000000000..cb82cb966 --- /dev/null +++ b/server/band-advisor/docker-compose.yml @@ -0,0 +1,15 @@ +services: + band-advisor: + build: . + ports: + - "8000:8000" + environment: + PSK_CONTACT_EMAIL: dev@ft8af.app # replace with a real contact address + SUNSPOT_NUMBER: "68" + volumes: + # feature flags are a mounted document so ops can flip them without a deploy + - ./feature-config.example.json:/etc/band-advisor/feature-config.json:ro + - baseline-data:/var/lib/band-advisor + +volumes: + baseline-data: diff --git a/server/band-advisor/feature-config.example.json b/server/band-advisor/feature-config.example.json new file mode 100644 index 000000000..07aae2381 --- /dev/null +++ b/server/band-advisor/feature-config.example.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "flags": { + "bandAdvisor": true, + "personalPskAnalytics": true, + "propagationAlerts": false + } +} diff --git a/server/band-advisor/pyproject.toml b/server/band-advisor/pyproject.toml new file mode 100644 index 000000000..9bec37016 --- /dev/null +++ b/server/band-advisor/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ft8af-band-advisor" +version = "0.1.0" +description = "FT8AF Band Advisor: VOACAP + PSK Reporter aggregation and band recommendation API" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Proprietary (invokes GPL voacapl as an external tool; see README)" } +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + "httpx>=0.27,<1.0", + "defusedxml>=0.7,<1.0", + "pydantic>=2.7,<3.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8,<9", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/server/band-advisor/tests/conftest.py b/server/band-advisor/tests/conftest.py new file mode 100644 index 000000000..adf7ecf2d --- /dev/null +++ b/server/band-advisor/tests/conftest.py @@ -0,0 +1,39 @@ +"""Shared test fixtures. NO test in this suite touches the network or runs +a real voacapl binary — everything external is faked or fixture-driven.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +from app.clock import FixedClock +from app.config import Settings + +FIXTURES = Path(__file__).parent / "fixtures" + +#: The canonical "now" used across tests: 2026-09-27 12:00:00Z (hour 12, +#: matching the first hour block in tests/fixtures/voacapx.out). +TEST_NOW = datetime(2026, 9, 27, 12, 0, 0, tzinfo=timezone.utc) + + +@pytest.fixture +def fixed_clock() -> FixedClock: + return FixedClock(TEST_NOW) + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return Settings( + voacapl_path=str(tmp_path / "bin" / "voacapl"), + itshfbc_path=str(tmp_path / "itshfbc"), + baseline_path=tmp_path / "baseline.json", + feature_config_path=tmp_path / "feature-config.json", + psk_contact_email="test@example.invalid", + ) + + +@pytest.fixture +def voacap_fixture_text() -> str: + return (FIXTURES / "voacapx.out").read_text() diff --git a/server/band-advisor/tests/fixtures/voacapx.out b/server/band-advisor/tests/fixtures/voacapx.out new file mode 100644 index 000000000..357c7a6f8 --- /dev/null +++ b/server/band-advisor/tests/fixtures/voacapx.out @@ -0,0 +1,48 @@ +1 + CCIR Coefficients METHOD 30 VOACAP 16.1109W PAGE 1 + + Sep 2026 SSN = 68. + EM28 EUROPE AZIMUTHS N. MI. KM + 38.50 N 94.00 W - 50.00 N 12.00 E 38.24 289.71 4262.6 7895.0 + XMTR 2-30 default/dipole.voa Az=360.0 OFFaz=360.0 0.100kW + RCVR 2-30 default/isotrope Az=289.7 OFFaz=360.0 + 3 MHz NOISE = -145.0 dBW REQ. REL = 90% REQ. SNR = 16.0 dB + MULTIPATH POWER TOLERANCE = 3.0 dB MULTIPATH DELAY TOLERANCE = 0.100 ms + + 12.0 24.5 1.8 3.6 5.4 7.1 10.1 14.1 18.1 21.1 24.9 28.1 0.0 0.0 FREQ + F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 - - MODE + 4.4 2.1 2.4 2.9 3.4 3.9 4.6 5.8 7.0 9.1 11.2 0.0 0.0 TANGLE + 26.6 27.1 26.9 26.8 26.7 26.6 26.5 26.6 26.8 27.0 27.3 0.0 0.0 DELAY + 302.4 96.4 99.1104.2110.7202.4302.4311.7322.0341.3360.5 0.0 0.0 V HITE + 0.50 0.99 0.99 0.99 0.98 0.95 0.88 0.62 0.41 0.22 0.09 0.0 0.0 MUFday + 128.3186.2168.9155.1145.3136.4128.3131.5137.2148.0161.4 0.0 0.0 LOSS + 24.5 -9.4 3.2 12.5 19.8 26.2 30.5 27.9 22.6 13.8 3.1 0.0 0.0 DBU + -131 -168 -155 -146 -139 -133 -128 -131 -136 -145 -157 0.0 0.0 S DBW + -181 -156 -158 -164 -170 -177 -181 -179 -175 -170 -167 0.0 0.0 N DBW + 53.0-12.0 3.0 18.1 31.1 44.1 53.0 48.0 39.0 25.0 10.0 0.0 0.0 SNR + -25.0 40.0 25.0 10.0 -3.1-16.1-25.0-20.0-11.0 3.0 18.0 0.0 0.0 RPWRG + 0.93 0.00 0.01 0.15 0.55 0.85 0.93 0.81 0.60 0.32 0.08 0.0 0.0 REL + 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.0 0.0 MPROB + 1.00 0.02 0.09 0.34 0.66 0.92 1.00 0.97 0.84 0.51 0.17 0.0 0.0 S PRB + 8.9 16.4 14.2 12.1 10.8 9.6 8.9 9.2 9.9 11.4 13.2 0.0 0.0 SIG LW + 10.6 18.9 16.5 14.2 12.7 11.3 10.6 10.9 11.6 13.4 15.6 0.0 0.0 SIG UP + 11.1 19.6 17.3 15.0 13.4 12.0 11.1 11.5 12.3 14.1 16.4 0.0 0.0 SNR LW + 13.4 22.9 20.2 17.6 15.8 14.3 13.4 13.8 14.7 16.8 19.4 0.0 0.0 SNR UP + 2.1 -9.8 -5.4 -2.6 -0.9 0.8 2.1 2.8 3.3 4.0 4.6 0.0 0.0 TGAIN + 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 RGAIN + 37.0-35.0-20.0 -5.0 10.0 25.0 37.0 30.0 20.0 5.0-10.0 0.0 0.0 SNRxx + + 13.0 23.8 1.8 3.6 5.4 7.1 10.1 14.1 18.1 21.1 24.9 28.1 0.0 0.0 FREQ + F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 - - MODE + 0.50 0.99 0.99 0.99 0.98 0.94 0.86 0.58 0.38 0.19 0.07 0.0 0.0 MUFday + 52.0-14.0 1.0 16.0 29.5 42.6 52.0 46.5 37.2 23.1 8.4 0.0 0.0 SNR + 0.92 0.00 0.01 0.12 0.50 0.82 0.92 0.78 0.55 0.28 0.06 0.0 0.0 REL + 36.0-37.0-22.0 -7.0 8.0 23.0 36.0 28.0 18.0 3.0-12.0 0.0 0.0 SNRxx + + 24.0 8.2 1.8 3.6 5.4 7.1 10.1 14.1 18.1 21.1 24.9 28.1 0.0 0.0 FREQ + F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 F2 - - MODE + 0.50 0.99 0.98 0.96 0.90 0.55 0.14 0.03 0.01 0.00 0.00 0.0 0.0 MUFday + 34.0 28.0 34.0 31.0 27.0 14.0 -6.0-25.0-38.0-52.0-63.0 0.0 0.0 SNR + 0.85 0.70 0.85 0.80 0.75 0.45 0.10 0.01 0.00 0.00 0.00 0.0 0.0 REL + 18.0 12.0 18.0 15.0 11.0 -2.0-22.0-41.0-54.0-68.0-79.0 0.0 0.0 SNRxx + diff --git a/server/band-advisor/tests/test_aggregator.py b/server/band-advisor/tests/test_aggregator.py new file mode 100644 index 000000000..81b79dbf8 --- /dev/null +++ b/server/band-advisor/tests/test_aggregator.py @@ -0,0 +1,130 @@ +from app.pskreporter.aggregator import ( + SampleQuality, + aggregate_band_activity, + sample_quality_for_count, +) +from app.pskreporter.client import ReceptionReport + +OBSERVER = "EM28" # 38.5N 95.0W + + +def report( + sender="K0AAA", + sender_grid="EM28", + receiver="G0AAA", + receiver_grid="JO01", + freq=14_074_500, + snr=-10, +) -> ReceptionReport: + return ReceptionReport( + sender_callsign=sender, + sender_grid=sender_grid, + receiver_callsign=receiver, + receiver_grid=receiver_grid, + frequency_hz=freq, + snr_db=snr, + mode="FT8", + flow_start_seconds=1_700_000_000, + ) + + +class TestBasicStats: + def test_counts_and_uniques(self): + reports = [ + report(sender="K0AAA", receiver="G0AAA"), + report(sender="K0AAA", receiver="G0BBB"), + report(sender="K0BBB", receiver="G0AAA"), + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.observation_count == 3 + assert stats.unique_transmitters == 2 + assert stats.unique_receivers == 2 + + def test_median_and_max_distance(self): + reports = [ + report(receiver_grid="JO01"), # ~7000+ km to England + report(receiver_grid="EM29"), # ~100 km, next square up + report(receiver_grid="EM48"), # a few hundred km east + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.max_distance_km > 6000 + assert stats.median_distance_km < 600 + + def test_median_snr(self): + reports = [report(snr=-20), report(snr=-10), report(snr=-2)] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.median_snr_db == -10 + + def test_distance_thresholds(self): + reports = [ + report(receiver_grid="EM29"), # short + report(receiver_grid="JO01"), # > 3000 km (transatlantic) + report(receiver_grid="QF56"), # > 8000 km (Australia) + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.paths_over_3000km == 2 + assert stats.paths_over_8000km == 1 + + def test_band_split(self): + reports = [report(freq=14_074_500), report(freq=7_074_100)] + result = aggregate_band_activity(reports, OBSERVER) + assert result["20m"].observation_count == 1 + assert result["40m"].observation_count == 1 + + def test_off_dial_frequency_ignored(self): + result = aggregate_band_activity([report(freq=14_200_000)], OBSERVER) + assert result == {} + + +class TestDirectionalDiversity: + def test_octants_counted_for_nearby_transmitters(self): + reports = [ + report(receiver_grid="JO01"), # NE from EM28 + report(receiver_grid="QF56"), # W/SW-ish long path + report(receiver_grid="FF95"), # SE to South America + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.directional_octants == 3 + + def test_far_transmitters_do_not_count(self): + # transmitter in Japan: not within 1500 km of the observer + reports = [report(sender_grid="PM95", receiver_grid="QF56")] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.directional_octants == 0 + + +class TestNearbyTransmitters: + def test_nearby_tx_reaching_regions(self): + reports = [ + report(sender="K0AAA", sender_grid="EM28", receiver_grid="JO01"), # EU + report(sender="K0BBB", sender_grid="EM29", receiver_grid="JN58"), # EU + report(sender="K0AAA", sender_grid="EM28", receiver_grid="PM95"), # Asia + # not nearby (Japan, way beyond 500 km): + report(sender="JA1AAA", sender_grid="PM95", receiver_grid="JO01"), + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.nearby_tx_to_region["EUROPE"] == 2 + assert stats.nearby_tx_to_region["ASIA"] == 1 + assert stats.nearby_tx_total == 2 + + def test_distinct_transmitters_not_reports(self): + reports = [ + report(sender="K0AAA", receiver="G0AAA", receiver_grid="JO01"), + report(sender="K0AAA", receiver="G0BBB", receiver_grid="JO02"), + ] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.nearby_tx_to_region["EUROPE"] == 1 + + +class TestSampleQuality: + def test_thresholds(self): + assert sample_quality_for_count(0) is SampleQuality.NONE + assert sample_quality_for_count(4) is SampleQuality.NONE + assert sample_quality_for_count(5) is SampleQuality.LOW + assert sample_quality_for_count(19) is SampleQuality.LOW + assert sample_quality_for_count(20) is SampleQuality.GOOD + + def test_quality_on_aggregate(self): + reports = [report(sender=f"K{i}ABC") for i in range(25)] + stats = aggregate_band_activity(reports, OBSERVER)["20m"] + assert stats.sample_quality is SampleQuality.GOOD diff --git a/server/band-advisor/tests/test_api.py b/server/band-advisor/tests/test_api.py new file mode 100644 index 000000000..42ae454ff --- /dev/null +++ b/server/band-advisor/tests/test_api.py @@ -0,0 +1,384 @@ +"""End-to-end API tests with faked VOACAP + PSK layers (no network, no binary).""" + +import json + +import pytest +from fastapi.testclient import TestClient + +from app.api import create_app +from app.pskreporter.aggregator import BandActivity, SampleQuality +from app.pskreporter.service import PersonalResult, RegionalActivity +from app.voacap.runner import VoacapError + +# ---------------------------------------------------------------------- fakes + + +def voacap_entry(target="Europe", rel_by_band=None): + rel_by_band = rel_by_band or {"20m": 0.93, "40m": 0.55, "17m": 0.81, "10m": 0.08} + bands = {} + for band, rel in rel_by_band.items(): + bands[band] = { + "freqMhz": 14.1, + "rel": rel, + "snrDbHz": 40.0, + "snr90DbHz": 20.0, + "mufday": 0.8, + "mode": "F2", + } + return {"target": target, "targetRegion": "EUROPE", "bands": bands} + + +class FakeVoacapService: + def __init__(self, entry=None, fail=False): + self.entry = entry or voacap_entry() + self.fail = fail + self.calls = [] + + def predict(self, **kwargs): + self.calls.append(kwargs) + if self.fail: + raise VoacapError("engine down") + return self.entry + + def binary_available(self): + return not self.fail + + +def band_activity(band, nearby=8, octants=5, max_km=8400.0, obs=25): + return BandActivity( + band=band, + observation_count=obs, + unique_transmitters=nearby + 3, + unique_receivers=18, + median_snr_db=-11, + median_distance_km=2400.0, + max_distance_km=max_km, + paths_over_3000km=6, + paths_over_8000km=1, + directional_octants=octants, + nearby_tx_to_region={"EUROPE": 4, "SOUTH_AMERICA": 2}, + nearby_tx_total=nearby, + sample_quality=SampleQuality.GOOD, + ) + + +class FakePskService: + def __init__(self, fixed_clock, fail=False, personal_fail=False): + self._clock = fixed_clock + self.fail = fail + self.personal_fail = personal_fail + + def get_regional_activity(self, observer_grid, mode="FT8"): + if self.fail: + raise RuntimeError("pskreporter down") + return RegionalActivity( + bands={ + "20m": band_activity("20m"), + "40m": band_activity("40m", nearby=4, octants=2, max_km=3200.0, obs=12), + "17m": band_activity("17m", nearby=5, octants=4, max_km=9100.0, obs=15), + }, + activity_ratio={"20m": 1.8, "40m": 0.9, "17m": 1.2}, + activity_trend={"20m": 0.4, "40m": -0.2, "17m": 0.1}, + fetched_at=self._clock.now(), + ) + + def get_personal_analytics(self, callsign, observer_grid, mode="FT8", regional=None): + if self.fail or self.personal_fail: + raise RuntimeError("personal query down") + return PersonalResult( + analytics={ + "enabled": True, + "reportsReceived": 23, + "uniqueReceivers": 18, + "countriesReached": 7, + "maximumDistanceKm": 8420, + "medianSnrDb": -11, + "bestSnrDb": -3, + "performanceComparedToBaseline": 0.18, + }, + per_band_performance={"20m": 1.0, "40m": 0.4}, + fetched_at=self._clock.now(), + ) + + +@pytest.fixture +def feature_config(settings): + settings.feature_config_path.parent.mkdir(parents=True, exist_ok=True) + settings.feature_config_path.write_text( + json.dumps( + { + "version": 1, + "flags": { + "bandAdvisor": True, + "personalPskAnalytics": True, + "propagationAlerts": False, + }, + } + ) + ) + + +def make_client(settings, fixed_clock, voacap=None, psk=None) -> TestClient: + app = create_app( + settings=settings, + clock=fixed_clock, + voacap_service=voacap or FakeVoacapService(), + psk_service=psk or FakePskService(fixed_clock), + ) + return TestClient(app) + + +# ----------------------------------------------------------------------- tests + + +class TestHealthz: + def test_ok(self, settings, fixed_clock): + client = make_client(settings, fixed_clock) + body = client.get("/healthz").json() + assert body["status"] == "ok" + assert body["service"] == "ft8af-band-advisor" + assert isinstance(body["voacaplPresent"], bool) + + +class TestFeatureConfig: + def test_serves_flags_with_expiry(self, settings, fixed_clock, feature_config): + client = make_client(settings, fixed_clock) + body = client.get("/v1/feature-config").json() + assert body["version"] == 1 + assert body["generatedAt"] == "2026-09-27T12:00:00Z" + assert body["expiresAt"] == "2026-09-27T18:00:00Z" # +6h default TTL + assert body["flags"] == { + "bandAdvisor": True, + "personalPskAnalytics": True, + "propagationAlerts": False, + } + + def test_missing_file_is_503(self, settings, fixed_clock): + client = make_client(settings, fixed_clock) + response = client.get("/v1/feature-config") + assert response.status_code == 503 + assert response.json()["error"]["code"] == "FEATURE_CONFIG_UNAVAILABLE" + + +class TestRecommendationContract: + URL = ( + "/v1/recommendation?grid=EM28&goal=MAKE_CONTACT&mode=FT8" + "&power=STANDARD&antenna=WIRE&noise=RESIDENTIAL&callsign=K1AF" + ) + + def test_contract_shape(self, settings, fixed_clock): + body = make_client(settings, fixed_clock).get(self.URL).json() + assert body["generatedAt"] == "2026-09-27T12:00:00Z" + assert body["validUntil"] == "2026-09-27T12:15:00Z" + assert body["grid"] == "EM28" + assert body["callsign"] == "K1AF" + assert body["mode"] == "FT8" + assert body["goal"] == "MAKE_CONTACT" + assert body["recommendedBand"] == "20m" + assert body["recommendedFrequencyHz"] == 14_074_000 + assert 0 < body["score"] <= 1 + assert 0 < body["confidence"] <= 1 + assert isinstance(body["summary"], str) and body["summary"] + assert body["destinations"] == ["Europe", "South America"] + evidence_types = [e["type"] for e in body["evidence"]] + assert "VOACAP" in evidence_types + assert "REGIONAL_PSK_REPORTER" in evidence_types + assert "PERSONAL_PSK_REPORTER" in evidence_types + for component in body["scoreComponents"]: + assert set(component) == {"name", "weight", "value", "contribution"} + assert body["personalAnalytics"]["reportsReceived"] == 23 + for alt in body["alternatives"]: + assert set(alt) == {"band", "frequencyHz", "score", "reason"} + assert body["sources"] == { + "voacapAvailable": True, + "regionalPskReporterAvailable": True, + "personalPskReporterAvailable": True, + } + + def test_score_components_weights_sum_to_one(self, settings, fixed_clock): + body = make_client(settings, fixed_clock).get(self.URL).json() + total = sum(c["weight"] for c in body["scoreComponents"]) + assert total == pytest.approx(1.0, abs=0.01) + + def test_no_callsign_omits_personal_fields(self, settings, fixed_clock): + body = ( + make_client(settings, fixed_clock) + .get("/v1/recommendation?grid=EM28&goal=DX") + .json() + ) + assert "callsign" not in body + assert "personalAnalytics" not in body + assert body["sources"]["personalPskReporterAvailable"] is False + + def test_grid_truncated_to_four_chars(self, settings, fixed_clock): + body = ( + make_client(settings, fixed_clock) + .get("/v1/recommendation?grid=em28ax&goal=DX") + .json() + ) + assert body["grid"] == "EM28" + + def test_deterministic(self, settings, fixed_clock): + client = make_client(settings, fixed_clock) + assert client.get(self.URL).json() == client.get(self.URL).json() + + def test_target_goal_uses_target_region(self, settings, fixed_clock): + voacap = FakeVoacapService() + client = make_client(settings, fixed_clock, voacap=voacap) + body = client.get( + "/v1/recommendation?grid=EM28&goal=TARGET&targetRegion=EUROPE" + ).json() + assert body["goal"] == "TARGET" + assert body["targetRegion"] == "EUROPE" + assert voacap.calls[0]["target_region"] == "EUROPE" + + +class TestRecommendationValidation: + @pytest.mark.parametrize( + "query,code", + [ + ("", "MISSING_PARAMETER"), + ("grid=ZZ99", "INVALID_GRID"), + ("grid=1", "INVALID_GRID"), + ("grid=EM28&goal=CONQUER", "INVALID_PARAMETER"), + ("grid=EM28&power=MEGA", "INVALID_PARAMETER"), + ("grid=EM28&antenna=TOWER", "INVALID_PARAMETER"), + ("grid=EM28&noise=LOUD", "INVALID_PARAMETER"), + ("grid=EM28&mode=CW", "INVALID_PARAMETER"), + ("grid=EM28&callsign=NOT_A_CALL!", "INVALID_CALLSIGN"), + ("grid=EM28&targetRegion=ATLANTIS", "INVALID_PARAMETER"), + ("grid=EM28&goal=TARGET", "MISSING_PARAMETER"), + ], + ) + def test_bad_requests_are_400(self, settings, fixed_clock, query, code): + response = make_client(settings, fixed_clock).get( + f"/v1/recommendation?{query}" + ) + assert response.status_code == 400 + body = response.json() + assert set(body) == {"error"} + assert body["error"]["code"] == code + assert isinstance(body["error"]["message"], str) + + +class TestDegradation: + def test_voacap_down_still_200(self, settings, fixed_clock): + client = make_client( + settings, fixed_clock, voacap=FakeVoacapService(fail=True) + ) + response = client.get("/v1/recommendation?grid=EM28&goal=DX") + assert response.status_code == 200 + body = response.json() + assert body["sources"]["voacapAvailable"] is False + assert body["sources"]["regionalPskReporterAvailable"] is True + assert body["recommendedBand"] # still recommends from PSK data alone + + def test_psk_down_still_200(self, settings, fixed_clock): + client = make_client( + settings, fixed_clock, psk=FakePskService(fixed_clock, fail=True) + ) + response = client.get("/v1/recommendation?grid=EM28&goal=DX") + assert response.status_code == 200 + body = response.json() + assert body["sources"]["regionalPskReporterAvailable"] is False + assert body["recommendedBand"] == "20m" # VOACAP-only recommendation + + def test_everything_down_still_200(self, settings, fixed_clock): + client = make_client( + settings, + fixed_clock, + voacap=FakeVoacapService(fail=True), + psk=FakePskService(fixed_clock, fail=True), + ) + response = client.get("/v1/recommendation?grid=EM28&goal=DX&callsign=K1AF") + assert response.status_code == 200 + body = response.json() + assert body["sources"] == { + "voacapAvailable": False, + "regionalPskReporterAvailable": False, + "personalPskReporterAvailable": False, + } + assert body["confidence"] < 0.4 + + def test_personal_down_only(self, settings, fixed_clock): + client = make_client( + settings, fixed_clock, psk=FakePskService(fixed_clock, personal_fail=True) + ) + body = client.get("/v1/recommendation?grid=EM28&callsign=K1AF").json() + assert body["sources"]["personalPskReporterAvailable"] is False + assert body["personalAnalytics"] is None # requested but unavailable + + +class TestConditions: + def test_shape(self, settings, fixed_clock): + body = make_client(settings, fixed_clock).get("/v1/conditions?grid=EM28").json() + assert body["grid"] == "EM28" + assert len(body["bands"]) == 10 + top = body["bands"][0] + assert set(top) == { + "band", + "score", + "confidence", + "activeRegions", + "openingEvidence", + # Machine-readable evidence for the Android alert policy + # (PropagationAlertPolicy.kt) — names must stay in sync. + "observationCount", + "activityRatio", + "voacapSupported", + "unusualOpening", + } + assert top["band"] == "20m" + assert "Europe" in top["activeRegions"] + assert any("normal" in e for e in top["openingEvidence"]) + assert top["observationCount"] > 0 + assert top["activityRatio"] > 0 + assert isinstance(top["voacapSupported"], bool) + assert isinstance(top["unusualOpening"], bool) + + def test_bad_grid(self, settings, fixed_clock): + response = make_client(settings, fixed_clock).get("/v1/conditions?grid=XX") + assert response.status_code == 400 + + def test_missing_grid(self, settings, fixed_clock): + response = make_client(settings, fixed_clock).get("/v1/conditions") + assert response.status_code == 400 + + +@pytest.mark.parametrize("path", ["/v1/recommendation?grid=EM28", "/v1/conditions?grid=EM28"]) +def test_health_remains_responsive_during_blocking_upstream(settings, fixed_clock, path): + import asyncio + import threading + import httpx + + started = threading.Event() + release = threading.Event() + + class BlockingRecommender: + def recommend(self, req): + started.set() + assert release.wait(3) + return {"ok": True} + + def conditions(self, grid): + return self.recommend(None) + + app = create_app( + settings, fixed_clock, + voacap_service=FakeVoacapService(), + recommendation_service=BlockingRecommender(), + ) + + async def run(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + pending = asyncio.create_task(client.get(path)) + try: + assert await asyncio.to_thread(started.wait, 1) + assert not release.is_set() + response = await asyncio.wait_for(client.get("/healthz"), 1) + assert response.status_code == 200 + finally: + release.set() + await pending + + asyncio.run(run()) diff --git a/server/band-advisor/tests/test_baseline.py b/server/band-advisor/tests/test_baseline.py new file mode 100644 index 000000000..8a92e31c5 --- /dev/null +++ b/server/band-advisor/tests/test_baseline.py @@ -0,0 +1,82 @@ +import pytest + +from app.pskreporter.baseline import ActivityBaseline + + +class TestBaselineMath: + def test_first_observation_seeds_mean(self): + b = ActivityBaseline() + b.update("20m", 12, 40) + assert b.baseline("20m", 12) == 40.0 + + def test_ewma_moves_toward_new_values(self): + b = ActivityBaseline(alpha=0.5) + b.update("20m", 12, 40) + b.update("20m", 12, 80) + assert b.baseline("20m", 12) == pytest.approx(60.0) + + def test_floor_prevents_divide_by_tiny(self): + b = ActivityBaseline(floor=5.0) + b.update("20m", 3, 1) # very quiet band-hour + # 10 observations on a normally dead band: ratio bounded by floor + assert b.activity_ratio("20m", 3, 10) == pytest.approx(10 / 5.0) + + def test_ratio_normal_hour_is_one(self): + b = ActivityBaseline() + b.update("20m", 12, 40) + assert b.activity_ratio("20m", 12, 40) == pytest.approx(1.0) + + def test_per_hour_keys_are_independent(self): + b = ActivityBaseline() + b.update("20m", 12, 100) + b.update("20m", 3, 5) + assert b.baseline("20m", 12) != b.baseline("20m", 3) + + def test_unseen_band_uses_floor(self): + b = ActivityBaseline(floor=5.0) + assert b.activity_ratio("12m", 12, 10) == pytest.approx(2.0) + + +class TestTrend: + def test_no_history_flat(self): + b = ActivityBaseline() + assert b.activity_trend("20m") == 0.0 + + def test_rising(self): + b = ActivityBaseline() + for count in (10, 20, 30): + b.update("20m", 12, count) + assert b.activity_trend("20m") > 0 + + def test_falling(self): + b = ActivityBaseline() + for count in (30, 20, 10): + b.update("20m", 12, count) + assert b.activity_trend("20m") < 0 + + def test_clamped(self): + b = ActivityBaseline() + b.update("20m", 12, 1) + b.update("20m", 12, 1000) + assert b.activity_trend("20m") == 1.0 + + def test_only_last_three_windows(self): + b = ActivityBaseline() + for count in (1000, 10, 10, 10): + b.update("20m", 12, count) + assert b.activity_trend("20m") == 0.0 + + +class TestPersistence: + def test_roundtrip(self, tmp_path): + path = tmp_path / "baseline.json" + b1 = ActivityBaseline(path=path) + b1.update("20m", 12, 40) + b2 = ActivityBaseline(path=path) + assert b2.baseline("20m", 12) == 40.0 + + def test_corrupt_file_starts_fresh(self, tmp_path): + path = tmp_path / "baseline.json" + path.write_text("{not json") + b = ActivityBaseline(path=path) + assert b.baseline("20m", 12) == b._floor diff --git a/server/band-advisor/tests/test_caches.py b/server/band-advisor/tests/test_caches.py new file mode 100644 index 000000000..41df4961b --- /dev/null +++ b/server/band-advisor/tests/test_caches.py @@ -0,0 +1,112 @@ +from app.pskreporter.cache import PskCache, personal_cache_key, regional_cache_key +from app.ttl_cache import TTLCache +from app.voacap.cache import VoacapCache, voacap_cache_key + + +class FakeTime: + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + +class TestTTLCache: + def test_set_get(self): + time = FakeTime() + cache = TTLCache(60, time) + cache.set(("a",), 1) + assert cache.get(("a",)) == 1 + + def test_expiry(self): + time = FakeTime() + cache = TTLCache(60, time) + cache.set(("a",), 1) + time.t += 61 + assert cache.get(("a",)) is None + + def test_not_expired_at_boundary(self): + time = FakeTime() + cache = TTLCache(60, time) + cache.set(("a",), 1) + time.t += 59 + assert cache.get(("a",)) == 1 + + def test_max_entries_evicts(self): + time = FakeTime() + cache = TTLCache(60, time, max_entries=2) + cache.set(("a",), 1) + time.t += 1 + cache.set(("b",), 2) + time.t += 1 + cache.set(("c",), 3) + assert len(cache) <= 2 + assert cache.get(("c",)) == 3 + + def test_disk_persistence(self, tmp_path): + time = FakeTime() + path = tmp_path / "cache.json" + c1 = TTLCache(60, time, disk_path=path) + c1.set(("a", 1), {"x": 2}) + c2 = TTLCache(60, time, disk_path=path) + assert c2.get(("a", 1)) == {"x": 2} + + +class TestVoacapKey: + def test_exact_key_shape(self): + key = voacap_cache_key("em28xx", 9, 12, "STANDARD", "WIRE", "RESIDENTIAL", "EUROPE") + assert key == ("EM28", 9, 12, "STANDARD", "WIRE", "RESIDENTIAL", "EUROPE") + + def test_cache_roundtrip(self): + time = FakeTime() + cache = VoacapCache(60, time) + parts = dict( + tx_grid="EM28", + month=9, + utc_hour=12, + power_class="STANDARD", + antenna_class="WIRE", + noise_class="RESIDENTIAL", + target_region="EUROPE", + ) + cache.set({"bands": {}}, **parts) + assert cache.get(**parts) == {"bands": {}} + assert cache.get(**{**parts, "utc_hour": 13}) is None + + +class TestPskKeys: + def test_field_granularity(self): + # nearby users in the same 2-char field share a cache entry + assert regional_cache_key("EM28", None, "FT8", 900) == regional_cache_key( + "EM19", None, "FT8", 900 + ) + assert regional_cache_key("EM28", None, "FT8", 900) != regional_cache_key( + "FN31", None, "FT8", 900 + ) + + def test_band_in_key(self): + assert regional_cache_key("EM28", "20m", "FT8", 900) != regional_cache_key( + "EM28", "40m", "FT8", 900 + ) + + def test_personal_key_uppercases(self): + assert personal_cache_key("k1af", "FT8", 900) == personal_cache_key( + "K1AF", "FT8", 900 + ) + + def test_regional_and_personal_isolated(self): + time = FakeTime() + cache = PskCache(300, 600, time) + cache.set_regional("EM28", None, "FT8", 900, "regional") + cache.set_personal("K1AF", "FT8", 900, "personal") + assert cache.get_regional("EM28", None, "FT8", 900) == "regional" + assert cache.get_personal("K1AF", "FT8", 900) == "personal" + + def test_personal_ttl_longer(self): + time = FakeTime() + cache = PskCache(300, 600, time) + cache.set_regional("EM28", None, "FT8", 900, "regional") + cache.set_personal("K1AF", "FT8", 900, "personal") + time.t += 301 + assert cache.get_regional("EM28", None, "FT8", 900) is None + assert cache.get_personal("K1AF", "FT8", 900) == "personal" diff --git a/server/band-advisor/tests/test_ft8_profile.py b/server/band-advisor/tests/test_ft8_profile.py new file mode 100644 index 000000000..66840c84b --- /dev/null +++ b/server/band-advisor/tests/test_ft8_profile.py @@ -0,0 +1,39 @@ +import math + +import pytest + +from app.voacap.ft8_profile import ( + DEFAULT_FT8_PROFILE, + FT8SnrProfile, + snr_2500_from_dbhz, + snr_dbhz_from_2500, +) + + +class TestConversion: + def test_minus_21_in_2500hz_is_about_13_dbhz(self): + # -21 dB @ 2500 Hz + 10*log10(2500) = -21 + 33.98 = 12.98 + assert snr_dbhz_from_2500(-21.0) == pytest.approx(12.98, abs=0.02) + + def test_roundtrip(self): + assert snr_2500_from_dbhz(snr_dbhz_from_2500(-21.0)) == pytest.approx(-21.0) + + def test_bandwidth_term(self): + assert snr_dbhz_from_2500(0.0) == pytest.approx(10 * math.log10(2500)) + + +class TestProfile: + def test_default_card_value_is_16(self): + # 13 dB-Hz decode requirement + 3 dB planning margin + assert DEFAULT_FT8_PROFILE.required_snr_dbhz == 13.0 + assert DEFAULT_FT8_PROFILE.snr_margin_db == 3.0 + assert DEFAULT_FT8_PROFILE.req_snr_card_db == 16.0 + + def test_configurable(self): + profile = FT8SnrProfile(required_snr_dbhz=10.0, snr_margin_db=0.0) + assert profile.req_snr_card_db == 10.0 + + def test_from_2500hz_threshold_rounds_to_13(self): + profile = FT8SnrProfile.from_2500hz_threshold(-21.0, snr_margin_db=3.0) + assert profile.required_snr_dbhz == 13 + assert profile.req_snr_card_db == 16 diff --git a/server/band-advisor/tests/test_geo.py b/server/band-advisor/tests/test_geo.py new file mode 100644 index 000000000..afd2e2783 --- /dev/null +++ b/server/band-advisor/tests/test_geo.py @@ -0,0 +1,85 @@ +import math + +import pytest + +from app.geo import ( + GridError, + destination_point, + grid_azimuth_deg, + grid_distance_km, + grid_to_latlon, + haversine_km, + is_valid_callsign, + normalize_grid4, +) + + +class TestNormalizeGrid4: + def test_truncates_and_uppercases(self): + assert normalize_grid4("em28ax") == "EM28" + + def test_valid_passthrough(self): + assert normalize_grid4("FN31") == "FN31" + + @pytest.mark.parametrize("bad", ["", "E", "1234", "ZZ99", "EM2", "em2x", "SS00"]) + def test_rejects_invalid(self, bad): + with pytest.raises(GridError): + normalize_grid4(bad) + + def test_rejects_non_string(self): + with pytest.raises(GridError): + normalize_grid4(1234) # type: ignore[arg-type] + + +class TestGridToLatLon: + def test_em28_center(self): + lat, lon = grid_to_latlon("EM28") + assert lat == pytest.approx(38.5) + assert lon == pytest.approx(-95.0) + + def test_six_char(self): + lat, lon = grid_to_latlon("JO01ab") + # subsquare 'ab' center: lon subsquare 0, lat subsquare 1 + assert lat == pytest.approx(51.0 + 1.5 / 24.0) + assert lon == pytest.approx(0.0 + 1.0 / 24.0) + + def test_field_center(self): + lat, lon = grid_to_latlon("EM") + assert lat == pytest.approx(35.0) + assert lon == pytest.approx(-90.0) + + def test_invalid(self): + with pytest.raises(GridError): + grid_to_latlon("Q!") + + +class TestDistanceAzimuth: + def test_known_distance(self): + # Kansas City area (EM28) to central Europe (JN58): about 8000 km + km = grid_distance_km("EM28", "JN58") + assert 7500 < km < 8500 + + def test_zero_distance(self): + assert grid_distance_km("EM28", "EM28") == pytest.approx(0.0) + + def test_azimuth_northeast_toward_europe(self): + az = grid_azimuth_deg("EM28", "JN58") + assert 20 < az < 60 # great-circle path to Europe leaves to the NE + + def test_haversine_equator_degree(self): + km = haversine_km(0, 0, 0, 1) + assert km == pytest.approx(111.2, abs=0.5) + + def test_destination_point_roundtrip(self): + lat, lon = destination_point(38.5, -95.0, 45.0, 1500.0) + assert haversine_km(38.5, -95.0, lat, lon) == pytest.approx(1500.0, abs=1.0) + + +class TestCallsign: + @pytest.mark.parametrize("call", ["K1AF", "KS3CKC", "W1AW", "VE3XYZ", "2E0ABC"]) + def test_valid(self, call): + assert is_valid_callsign(call) + + @pytest.mark.parametrize("call", ["", "K", "123", "K1AF!!", "THIS-IS-NOT"]) + def test_invalid(self, call): + assert not is_valid_callsign(call) diff --git a/server/band-advisor/tests/test_input_builder.py b/server/band-advisor/tests/test_input_builder.py new file mode 100644 index 000000000..07d057c53 --- /dev/null +++ b/server/band-advisor/tests/test_input_builder.py @@ -0,0 +1,89 @@ +import pytest + +from app.voacap.ft8_profile import FT8SnrProfile +from app.voacap.input_builder import ( + AntennaClass, + DeckParams, + NoiseClass, + PowerClass, + build_deck, +) + +GOLDEN_DECK = """\ +LINEMAX 55 number of lines-per-page +COEFFS CCIR +TIME 1 24 1 1 +MONTH 2026 9.00 +SUNSPOT 68. +LABEL EM28 EUROPE +CIRCUIT 38.50N 95.00W 50.00N 12.00E S 0 +SYSTEM 1. 145. 3.00 90. 16.0 3.00 0.10 +FPROB 1.00 1.00 1.00 0.00 +ANTENNA 1 1 2 30 0.000[default/dipole.voa ] 0.0 0.1000 +ANTENNA 2 2 2 30 0.000[default/isotrope ] 0.0 0.0000 +FREQUENCY 1.84 3.57 5.36 7.0710.1414.0718.1021.0724.9128.07 +METHOD 30 0 +EXECUTE +QUIT +""" + + +def _params(**overrides) -> DeckParams: + base = dict( + tx_grid="EM28", + month=9, + year=2026, + rx_lat=50.0, + rx_lon=12.0, + rx_label="EUROPE", + power_class=PowerClass.STANDARD, + antenna_class=AntennaClass.WIRE, + noise_class=NoiseClass.RESIDENTIAL, + ssn=68.0, + ) + base.update(overrides) + return DeckParams(**base) + + +class TestBuildDeck: + def test_golden_deck(self): + assert build_deck(_params()) == GOLDEN_DECK + + def test_power_class_kw_conversion(self): + deck = build_deck(_params(power_class=PowerClass.QRP)) + assert " 0.0050" in deck # 5 W = 0.005 kW on the TX antenna card + deck = build_deck(_params(power_class=PowerClass.HIGH)) + assert " 1.0000" in deck # 1000 W = 1 kW + + def test_noise_classes(self): + assert " 164." in build_deck(_params(noise_class=NoiseClass.QUIET)) + assert " 136." in build_deck(_params(noise_class=NoiseClass.URBAN)) + + def test_req_snr_from_profile(self): + deck = build_deck( + _params(ft8_profile=FT8SnrProfile(required_snr_dbhz=13, snr_margin_db=6)) + ) + assert " 19.0 " in deck + + def test_grid_truncated_and_converted(self): + deck = build_deck(_params(tx_grid="em28ax")) + assert "LABEL EM28" in deck + assert " 38.50N" in deck + + def test_southern_western_hemispheres(self): + # OCEANIA-ish receiver + deck = build_deck(_params(rx_lat=-27.0, rx_lon=145.0)) + assert " 27.00S 145.00E" in deck + + def test_invalid_month_rejected(self): + with pytest.raises(ValueError): + build_deck(_params(month=13)) + + def test_all_ten_ft8_frequencies_present(self): + deck = build_deck(_params()) + freq_line = next(l for l in deck.splitlines() if l.startswith("FREQUENCY")) + assert len(freq_line) == len("FREQUENCY ") + 10 * 5 + + def test_isotrope_antenna(self): + deck = build_deck(_params(antenna_class=AntennaClass.ISOTROPE)) + assert "ANTENNA 1 1 2 30 0.000[default/isotrope ]" in deck diff --git a/server/band-advisor/tests/test_parser.py b/server/band-advisor/tests/test_parser.py new file mode 100644 index 000000000..6868d7a14 --- /dev/null +++ b/server/band-advisor/tests/test_parser.py @@ -0,0 +1,86 @@ +import pytest + +from app.bands import BAND_ORDER +from app.voacap.parser import parse_output + + +class TestParseFixture: + def test_parses_three_hour_blocks(self, voacap_fixture_text): + result = parse_output(voacap_fixture_text) + assert set(result.hours) == {12, 13, 0} # 24.0 maps to 00 UTC + + def test_ssn_from_header(self, voacap_fixture_text): + assert parse_output(voacap_fixture_text).ssn == 68.0 + + def test_hour12_20m_values(self, voacap_fixture_text): + pred = parse_output(voacap_fixture_text).hours[12]["20m"] + assert pred is not None + assert pred.freq_mhz == pytest.approx(14.1) + assert pred.rel == pytest.approx(0.93) + assert pred.snr_dbhz == pytest.approx(53.0) + assert pred.snrxx_dbhz == pytest.approx(37.0) + assert pred.mufday == pytest.approx(0.88) + assert pred.mode == "F2" + + def test_hour12_all_ten_bands_present(self, voacap_fixture_text): + bands = parse_output(voacap_fixture_text).hours[12] + for band in BAND_ORDER: + assert bands[band] is not None, band + + def test_hour0_night_profile(self, voacap_fixture_text): + bands = parse_output(voacap_fixture_text).hours[0] + assert bands["80m"].rel == pytest.approx(0.85) + assert bands["10m"].rel == pytest.approx(0.00) + assert bands["160m"].snr_dbhz == pytest.approx(28.0) + + def test_negative_snr_parsed(self, voacap_fixture_text): + bands = parse_output(voacap_fixture_text).hours[0] + assert bands["20m"].snr_dbhz == pytest.approx(-6.0) + assert bands["10m"].snrxx_dbhz == pytest.approx(-79.0) + + +MISSING_FREQ_OUTPUT = """\ + some header text SSN = 33. + + 12.0 15.0 7.1 14.1 21.1 0.0 0.0 FREQ + F2 F2 F2 F2 - - MODE + 0.50 0.98 0.88 0.41 0.0 0.0 MUFday + 53.0 31.1 53.0 39.0 0.0 0.0 SNR + 0.93 0.55 0.93 0.60 0.0 0.0 REL + 37.0 10.0 37.0 20.0 0.0 0.0 SNRxx +""" + + +class TestDefensiveness: + def test_missing_frequency_rows_become_none(self): + bands = parse_output(MISSING_FREQ_OUTPUT).hours[12] + assert bands["40m"] is not None + assert bands["20m"] is not None + assert bands["15m"] is not None + # every other FT8 band exists in the dict but is None + for band in BAND_ORDER: + if band not in ("40m", "20m", "15m"): + assert bands[band] is None, band + + def test_garbage_input_returns_empty(self): + result = parse_output("this is not voacap output\nat all\n") + assert result.hours == {} + + def test_empty_input(self): + assert parse_output("").hours == {} + + def test_missing_rows_leave_none_fields(self): + # Only FREQ + REL rows: SNR/MUFday/etc stay None rather than raising. + text = ( + " 12.0 15.0 7.1 0.0 FREQ\n" + " 0.50 0.55 0.0 REL\n" + ) + pred = parse_output(text).hours[12]["40m"] + assert pred.rel == pytest.approx(0.55) + assert pred.snr_dbhz is None + assert pred.mufday is None + + def test_unknown_row_labels_ignored(self, voacap_fixture_text): + # LOSS / DBU / TANGLE rows exist in the fixture and must not break parsing. + result = parse_output(voacap_fixture_text) + assert result.hours[12]["40m"].rel == pytest.approx(0.55) diff --git a/server/band-advisor/tests/test_psk_client.py b/server/band-advisor/tests/test_psk_client.py new file mode 100644 index 000000000..4fee247cc --- /dev/null +++ b/server/band-advisor/tests/test_psk_client.py @@ -0,0 +1,178 @@ +"""PSK Reporter client tests — all HTTP is served by httpx.MockTransport, +never the network.""" + +import httpx +import pytest + +from app.pskreporter.client import ( + PskReporterBackoff, + PskReporterClient, + PskReporterError, + parse_reception_reports, +) + +VALID_XML = b""" + + + + +""" + +BAD_GRID_XML = b""" + + + +""" + +ABSURD_FREQ_XML = b""" + + + +""" + + +class TestParseReceptionReports: + def test_valid_reports(self): + reports = parse_reception_reports(VALID_XML) + assert len(reports) == 2 + assert reports[0].sender_callsign == "K1AF" + assert reports[0].receiver_grid == "JO01ab" + assert reports[0].frequency_hz == 14_074_500 + assert reports[0].snr_db == -11 + assert reports[0].band == "20m" + assert reports[1].band == "40m" + + def test_malformed_xml_raises(self): + with pytest.raises(PskReporterError): + parse_reception_reports(b"]>' + b"&x;" + ) + with pytest.raises(PskReporterError): + parse_reception_reports(evil) + + def test_bad_grid_dropped(self): + assert parse_reception_reports(BAD_GRID_XML) == [] + + def test_absurd_frequency_dropped(self): + assert parse_reception_reports(ABSURD_FREQ_XML) == [] + + def test_missing_callsign_dropped(self): + xml = ( + b'' + b"" + ) + assert parse_reception_reports(xml) == [] + + def test_out_of_range_snr_nulled(self): + xml = ( + b'' + ) + reports = parse_reception_reports(xml) + assert len(reports) == 1 + assert reports[0].snr_db is None + + +def make_client(settings, fixed_clock, handler): + transport = httpx.MockTransport(handler) + return PskReporterClient( + settings, + fixed_clock, + http_client=httpx.Client(transport=transport), + ) + + +class TestClientBehavior: + def test_fetch_sends_required_params(self, settings, fixed_clock): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, content=VALID_XML) + + client = make_client(settings, fixed_clock, handler) + result = client.fetch_reports(mode="FT8") + assert len(result.reports) == 2 + assert result.fetched_at == fixed_clock.now() + assert seen["flowStartSeconds"] == "-900" + assert seen["mode"] == "FT8" + assert seen["rronly"] == "1" + assert seen["appcontact"] == settings.psk_contact_email + + def test_min_interval_enforced_per_parameter_set(self, settings, fixed_clock): + client = make_client( + settings, fixed_clock, lambda r: httpx.Response(200, content=VALID_XML) + ) + client.fetch_reports(mode="FT8") + with pytest.raises(PskReporterBackoff): + client.fetch_reports(mode="FT8") + # a *different* parameter set (personal query) is allowed immediately + client.fetch_reports(mode="FT8", sender_callsign="K1AF") + # and after 5 minutes the original is allowed again + fixed_clock.advance(301) + client.fetch_reports(mode="FT8") + + def test_429_starts_backoff(self, settings, fixed_clock): + calls = {"n": 0} + + def handler(request): + calls["n"] += 1 + return httpx.Response(429) + + client = make_client(settings, fixed_clock, handler) + with pytest.raises(PskReporterError): + client.fetch_reports(mode="FT8") + with pytest.raises(PskReporterBackoff): + client.fetch_reports(mode="FT8") + assert calls["n"] == 1 # second attempt never hit the wire + fixed_clock.advance(settings.psk_backoff_s + 1) + with pytest.raises(PskReporterError): # allowed to try again (still 429) + client.fetch_reports(mode="FT8") + assert calls["n"] == 2 + + def test_503_starts_backoff(self, settings, fixed_clock): + client = make_client(settings, fixed_clock, lambda r: httpx.Response(503)) + with pytest.raises(PskReporterError): + client.fetch_reports(mode="FT8") + with pytest.raises(PskReporterBackoff): + client.fetch_reports(mode="FT8") + + def test_http_error_raises(self, settings, fixed_clock): + def handler(request): + raise httpx.ConnectError("boom") + + client = make_client(settings, fixed_clock, handler) + with pytest.raises(PskReporterError): + client.fetch_reports(mode="FT8") + + def test_malformed_body_raises(self, settings, fixed_clock): + client = make_client( + settings, fixed_clock, lambda r: httpx.Response(200, content=b"not xml") + ) + with pytest.raises(PskReporterError): + client.fetch_reports(mode="FT8") + + def test_sender_callsign_param(self, settings, fixed_clock): + seen = {} + + def handler(request): + seen.update(dict(request.url.params)) + return httpx.Response(200, content=VALID_XML) + + client = make_client(settings, fixed_clock, handler) + client.fetch_reports(mode="FT8", sender_callsign="k1af") + assert seen["senderCallsign"] == "K1AF" diff --git a/server/band-advisor/tests/test_regions.py b/server/band-advisor/tests/test_regions.py new file mode 100644 index 000000000..02e81d7b0 --- /dev/null +++ b/server/band-advisor/tests/test_regions.py @@ -0,0 +1,73 @@ +import pytest + +from app.geo import grid_distance_km, grid_to_latlon, haversine_km +from app.voacap.regions import ( + REGIONS, + TARGET_REGION_NAMES, + farthest_region, + home_region_for_field, + resolve_target, +) + + +class TestRegionTable: + def test_seven_concrete_regions(self): + assert set(REGIONS) == { + "EUROPE", + "NORTH_AMERICA_EAST", + "NORTH_AMERICA_WEST", + "SOUTH_AMERICA", + "AFRICA", + "ASIA", + "OCEANIA", + } + + def test_pseudo_regions_accepted_by_api(self): + assert "REGIONAL" in TARGET_REGION_NAMES + assert "LONG_DX" in TARGET_REGION_NAMES + + def test_grids_are_valid(self): + for region in REGIONS.values(): + for grid in region.grids: + grid_to_latlon(grid) # must not raise + + +class TestHomeRegion: + @pytest.mark.parametrize( + "grid,expected", + [ + ("EM28", "NORTH_AMERICA_EAST"), + ("CN87", "NORTH_AMERICA_WEST"), + ("JO01", "EUROPE"), + ("PM95", "ASIA"), + ("QF56", "OCEANIA"), + ("GG66", "SOUTH_AMERICA"), + ("KG33", "AFRICA"), + ], + ) + def test_field_mapping(self, grid, expected): + assert home_region_for_field(grid) == expected + + +class TestResolveTarget: + def test_concrete_region(self): + name, lat, lon = resolve_target("EM28", "EUROPE") + assert name == "Europe" + assert (lat, lon) == (50.0, 12.0) + + def test_regional_is_1500km_ring(self): + _, lat, lon = resolve_target("EM28", "REGIONAL") + obs_lat, obs_lon = grid_to_latlon("EM28") + assert haversine_km(obs_lat, obs_lon, lat, lon) == pytest.approx( + 1500.0, abs=2.0 + ) + + def test_long_dx_is_farthest_region(self): + name, lat, lon = resolve_target("EM28", "LONG_DX") + region = farthest_region("EM28") + assert name == region.display_name + # from Kansas, the far side of the planet is the Indian Ocean side + assert region.name in ("OCEANIA", "AFRICA", "ASIA") + assert grid_distance_km("EM28", "EM28") < haversine_km( + *grid_to_latlon("EM28"), lat, lon + ) diff --git a/server/band-advisor/tests/test_runner.py b/server/band-advisor/tests/test_runner.py new file mode 100644 index 000000000..7d7cfafd3 --- /dev/null +++ b/server/band-advisor/tests/test_runner.py @@ -0,0 +1,88 @@ +import subprocess +from pathlib import Path + +import pytest + +from app.voacap.runner import VoacapError, VoacapRunner + + +def make_run_fn(settings, output_text="OUTPUT", rc=0, write_output=True): + """Fake subprocess.run that emulates voacapl's file behavior.""" + calls = [] + + def run_fn(argv, capture_output, text, timeout): + calls.append({"argv": argv, "timeout": timeout}) + itshfbc = Path(argv[1]) + if write_output: + (itshfbc / "run" / argv[3]).write_text(output_text) + return subprocess.CompletedProcess(argv, rc, stdout="", stderr="") + + run_fn.calls = calls + return run_fn + + +class TestRunner: + def test_success_returns_output(self, settings): + run_fn = make_run_fn(settings, output_text="HELLO VOACAP") + runner = VoacapRunner(settings, run_fn=run_fn) + assert runner.run("DECK") == "HELLO VOACAP" + + def test_invocation_uses_short_relative_names(self, settings): + """Fortran fixed-length buffers: names must be short and relative.""" + run_fn = make_run_fn(settings) + VoacapRunner(settings, run_fn=run_fn).run("DECK") + argv = run_fn.calls[0]["argv"] + assert argv[0] == settings.voacapl_path + assert argv[1] == settings.itshfbc_path + in_name, out_name = argv[2], argv[3] + assert "/" not in in_name and "/" not in out_name + assert in_name.startswith("ba") and in_name.endswith(".dat") + assert out_name.startswith("ba") and out_name.endswith(".out") + assert len(in_name) <= 30 and len(out_name) <= 30 + + def test_deck_written_into_itshfbc_run_dir(self, settings): + seen = {} + + def run_fn(argv, capture_output, text, timeout): + deck_path = Path(argv[1]) / "run" / argv[2] + seen["deck"] = deck_path.read_text() + (Path(argv[1]) / "run" / argv[3]).write_text("OUT") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + VoacapRunner(settings, run_fn=run_fn).run("MY DECK\n") + assert seen["deck"] == "MY DECK\n" + + def test_run_files_cleaned_up(self, settings): + run_fn = make_run_fn(settings) + VoacapRunner(settings, run_fn=run_fn).run("DECK") + run_dir = Path(settings.itshfbc_path) / "run" + assert list(run_dir.iterdir()) == [] + + def test_nonzero_rc_raises(self, settings): + run_fn = make_run_fn(settings, rc=1) + with pytest.raises(VoacapError, match="rc=1"): + VoacapRunner(settings, run_fn=run_fn).run("DECK") + + def test_missing_output_raises(self, settings): + run_fn = make_run_fn(settings, write_output=False) + with pytest.raises(VoacapError, match="no output"): + VoacapRunner(settings, run_fn=run_fn).run("DECK") + + def test_timeout_raises(self, settings): + def run_fn(argv, capture_output, text, timeout): + raise subprocess.TimeoutExpired(argv, timeout) + + with pytest.raises(VoacapError, match="timed out"): + VoacapRunner(settings, run_fn=run_fn).run("DECK") + + def test_missing_binary_raises_voacap_error(self, settings): + def run_fn(argv, capture_output, text, timeout): + raise FileNotFoundError(argv[0]) + + with pytest.raises(VoacapError, match="could not be executed"): + VoacapRunner(settings, run_fn=run_fn).run("DECK") + + def test_timeout_from_settings(self, settings): + run_fn = make_run_fn(settings) + VoacapRunner(settings, run_fn=run_fn).run("DECK") + assert run_fn.calls[0]["timeout"] == settings.voacap_timeout_s diff --git a/server/band-advisor/tests/test_scoring.py b/server/band-advisor/tests/test_scoring.py new file mode 100644 index 000000000..0e5b62ce1 --- /dev/null +++ b/server/band-advisor/tests/test_scoring.py @@ -0,0 +1,149 @@ +import pytest + +from app.scoring import ( + GOAL_WEIGHTS, + Goal, + agreement_between, + clamp01, + compute_confidence, + score_band, +) + +FULL_COMPONENTS = { + "voacapReliability": 0.8, + "nearbyObservedSuccess": 0.6, + "normalizedLiveActivity": 0.5, + "distancePotential": 0.7, + "activityTrend": 0.5, + "personalPerformance": 0.4, + "directionalDiversity": 0.9, + "targetReliability": 0.8, + "targetObservedPaths": 0.3, +} + + +class TestWeightTables: + @pytest.mark.parametrize("goal", list(Goal)) + def test_weights_sum_to_one(self, goal): + assert sum(GOAL_WEIGHTS[goal].values()) == pytest.approx(1.0) + + def test_spec_weights_make_contact(self): + w = GOAL_WEIGHTS[Goal.MAKE_CONTACT] + assert w["voacapReliability"] == 0.30 + assert w["nearbyObservedSuccess"] == 0.25 + assert w["normalizedLiveActivity"] == 0.15 + + def test_spec_weights_target(self): + w = GOAL_WEIGHTS[Goal.TARGET] + assert w["targetReliability"] == 0.40 + assert w["targetObservedPaths"] == 0.30 + + +class TestScoreBand: + def test_full_components_weighted_sum(self): + result = score_band(Goal.MAKE_CONTACT, FULL_COMPONENTS) + expected = ( + 0.30 * 0.8 + 0.25 * 0.6 + 0.15 * 0.5 + 0.10 * 0.7 + 0.10 * 0.5 + 0.10 * 0.4 + ) + assert result.score == pytest.approx(expected, abs=1e-4) + + def test_contributions_sum_to_score(self): + result = score_band(Goal.DX, FULL_COMPONENTS) + assert sum(c.contribution for c in result.components) == pytest.approx( + result.score, abs=1e-4 + ) + + def test_renormalization_when_component_missing(self): + components = dict(FULL_COMPONENTS) + components["personalPerformance"] = None + result = score_band(Goal.MAKE_CONTACT, components) + # remaining weights must be scaled to sum to 1 + assert sum(c.weight for c in result.components) == pytest.approx(1.0) + assert not any(c.name == "personalPerformance" for c in result.components) + + def test_missing_source_does_not_zero_score(self): + full = score_band(Goal.MAKE_CONTACT, FULL_COMPONENTS).score + without = score_band( + Goal.MAKE_CONTACT, + {**FULL_COMPONENTS, "personalPerformance": None}, + ).score + # personal (0.4) was the weakest component, so dropping it raises the score + assert without > full > 0 + + def test_all_missing_scores_zero(self): + result = score_band(Goal.DX, {}) + assert result.score == 0.0 + assert result.components == () + + def test_goals_rank_bands_differently(self): + dx_band = {**FULL_COMPONENTS, "distancePotential": 1.0, "nearbyObservedSuccess": 0.1} + local_band = {**FULL_COMPONENTS, "distancePotential": 0.1, "nearbyObservedSuccess": 1.0} + assert ( + score_band(Goal.DX, dx_band).score + > score_band(Goal.DX, local_band).score - 0.06 + ) + assert ( + score_band(Goal.MAKE_CONTACT, local_band).score + > score_band(Goal.MAKE_CONTACT, dx_band).score + ) + + def test_deterministic(self): + a = score_band(Goal.POTA, FULL_COMPONENTS) + b = score_band(Goal.POTA, dict(FULL_COMPONENTS)) + assert a == b + + def test_values_clamped(self): + result = score_band(Goal.MAKE_CONTACT, {"voacapReliability": 2.5}) + assert result.score == pytest.approx(1.0) + + def test_unknown_components_ignored(self): + result = score_band( + Goal.MAKE_CONTACT, {"voacapReliability": 0.5, "bogus": 1.0} + ) + assert [c.name for c in result.components] == ["voacapReliability"] + + +class TestConfidence: + def test_range(self): + c = compute_confidence(20, 0, 1.0, 3, 3, 1.0) + assert c == pytest.approx(1.0) + assert compute_confidence(0, 900, 0.0, 0, 3, 0.0) == pytest.approx(0.0) + + def test_more_observations_more_confidence(self): + low = compute_confidence(2, 60, None, 2, 2, 0.5) + high = compute_confidence(40, 60, None, 2, 2, 0.5) + assert high > low + + def test_stale_data_less_confidence(self): + fresh = compute_confidence(10, 0, None, 2, 2, 0.5) + stale = compute_confidence(10, 899, None, 2, 2, 0.5) + assert fresh > stale + + def test_source_outage_drops_confidence(self): + all_up = compute_confidence(10, 60, 0.8, 3, 3, 0.5) + one_down = compute_confidence(10, 60, 0.8, 2, 3, 0.5) + assert all_up > one_down + + def test_unknown_agreement_is_neutral(self): + neutral = compute_confidence(10, 60, None, 2, 2, 0.5) + bad = compute_confidence(10, 60, 0.0, 2, 2, 0.5) + good = compute_confidence(10, 60, 1.0, 2, 2, 0.5) + assert bad < neutral < good + + +class TestAgreement: + def test_perfect(self): + assert agreement_between(0.8, 0.8) == pytest.approx(1.0) + + def test_disagreement(self): + assert agreement_between(1.0, 0.0) == pytest.approx(0.0) + + def test_missing_side_is_none(self): + assert agreement_between(None, 0.5) is None + assert agreement_between(0.5, None) is None + + +def test_clamp01(): + assert clamp01(-1) == 0.0 + assert clamp01(0.5) == 0.5 + assert clamp01(2) == 1.0 diff --git a/server/band-advisor/tests/test_services.py b/server/band-advisor/tests/test_services.py new file mode 100644 index 000000000..ca46bf7fa --- /dev/null +++ b/server/band-advisor/tests/test_services.py @@ -0,0 +1,240 @@ +"""VoacapService + PskService facades with faked runner/client.""" + +from datetime import datetime, timezone + +import pytest + +from app.pskreporter.baseline import ActivityBaseline +from app.pskreporter.client import FetchResult, PskReporterError, ReceptionReport +from app.pskreporter.service import PskService, build_personal_result +from app.voacap.input_builder import AntennaClass, NoiseClass, PowerClass +from app.voacap.runner import VoacapError +from app.voacap.service import VoacapService + + +class FakeRunner: + def __init__(self, output: str): + self.output = output + self.calls = 0 + + def run(self, deck_text: str) -> str: + self.calls += 1 + self.last_deck = deck_text + return self.output + + +PREDICT_ARGS = dict( + tx_grid="EM28", + month=9, + year=2026, + power_class=PowerClass.STANDARD, + antenna_class=AntennaClass.WIRE, + noise_class=NoiseClass.RESIDENTIAL, + target_region="EUROPE", +) + + +class TestVoacapService: + def test_predict_returns_band_dict(self, settings, fixed_clock, voacap_fixture_text): + runner = FakeRunner(voacap_fixture_text) + service = VoacapService(settings, fixed_clock, runner=runner) + result = service.predict(utc_hour=12, **PREDICT_ARGS) + assert result["target"] == "Europe" + assert result["bands"]["20m"]["rel"] == pytest.approx(0.93) + assert result["bands"]["20m"]["snrDbHz"] == pytest.approx(53.0) + + def test_one_run_caches_all_hours(self, settings, fixed_clock, voacap_fixture_text): + runner = FakeRunner(voacap_fixture_text) + service = VoacapService(settings, fixed_clock, runner=runner) + service.predict(utc_hour=12, **PREDICT_ARGS) + service.predict(utc_hour=13, **PREDICT_ARGS) + service.predict(utc_hour=0, **PREDICT_ARGS) + assert runner.calls == 1 + + def test_different_region_reruns(self, settings, fixed_clock, voacap_fixture_text): + runner = FakeRunner(voacap_fixture_text) + service = VoacapService(settings, fixed_clock, runner=runner) + service.predict(utc_hour=12, **PREDICT_ARGS) + service.predict(utc_hour=12, **{**PREDICT_ARGS, "target_region": "ASIA"}) + assert runner.calls == 2 + + def test_hour_missing_from_output_raises( + self, settings, fixed_clock, voacap_fixture_text + ): + service = VoacapService( + settings, fixed_clock, runner=FakeRunner(voacap_fixture_text) + ) + with pytest.raises(VoacapError): + service.predict(utc_hour=5, **PREDICT_ARGS) + + def test_runner_failure_propagates(self, settings, fixed_clock): + class BoomRunner: + def run(self, deck): + raise VoacapError("boom") + + service = VoacapService(settings, fixed_clock, runner=BoomRunner()) + with pytest.raises(VoacapError): + service.predict(utc_hour=12, **PREDICT_ARGS) + + +def _report(sender, sender_grid, receiver, receiver_grid, freq=14_074_500, snr=-10): + return ReceptionReport( + sender_callsign=sender, + sender_grid=sender_grid, + receiver_callsign=receiver, + receiver_grid=receiver_grid, + frequency_hz=freq, + snr_db=snr, + mode="FT8", + flow_start_seconds=None, + ) + + +class FakeClient: + def __init__(self, reports, fetched_at): + self._reports = tuple(reports) + self._fetched_at = fetched_at + self.calls = [] + + def fetch_reports(self, *, mode="FT8", sender_callsign=None, **_): + self.calls.append(sender_callsign) + reports = self._reports + if sender_callsign: + reports = tuple( + r for r in reports if r.sender_callsign == sender_callsign.upper() + ) + return FetchResult(reports=reports, fetched_at=self._fetched_at) + + +class FailingClient: + def fetch_reports(self, **_): + raise PskReporterError("down") + + +class TestPskService: + def _service(self, settings, fixed_clock, client): + return PskService( + settings, + fixed_clock, + client=client, + baseline=ActivityBaseline(path=settings.baseline_path), + ) + + def test_regional_aggregation(self, settings, fixed_clock): + client = FakeClient( + [ + _report("K0AAA", "EM28", "G0AAA", "JO01"), + _report("K0BBB", "EM29", "G0BBB", "JN58"), + ], + fixed_clock.now(), + ) + service = self._service(settings, fixed_clock, client) + regional = service.get_regional_activity("EM28") + assert regional.bands["20m"].unique_transmitters == 2 + assert regional.activity_ratio["20m"] > 0 + assert regional.fetched_at == fixed_clock.now() + + def test_regional_cached_within_ttl(self, settings, fixed_clock): + client = FakeClient( + [_report("K0AAA", "EM28", "G0AAA", "JO01")], fixed_clock.now() + ) + service = self._service(settings, fixed_clock, client) + service.get_regional_activity("EM28") + service.get_regional_activity("EM29") # same field EM -> same entry + assert len(client.calls) == 1 + + def test_failure_propagates(self, settings, fixed_clock): + service = self._service(settings, fixed_clock, FailingClient()) + with pytest.raises(PskReporterError): + service.get_regional_activity("EM28") + + def test_personal_analytics(self, settings, fixed_clock): + client = FakeClient( + [ + _report("K1AF", "EM28", "G0AAA", "JO01", snr=-11), + _report("K1AF", "EM28", "VK2AAA", "QF56", snr=-15), + _report("K0ZZZ", "EM28", "G0AAA", "JO01", snr=-5), + ], + fixed_clock.now(), + ) + service = self._service(settings, fixed_clock, client) + personal = service.get_personal_analytics("K1AF", "EM28") + analytics = personal.analytics + assert analytics["enabled"] is True + assert analytics["reportsReceived"] == 2 + assert analytics["uniqueReceivers"] == 2 + assert analytics["maximumDistanceKm"] > 8000 + assert analytics["medianSnrDb"] == -13 + assert analytics["bestSnrDb"] == -11 + assert personal.per_band_performance["20m"] == 1.0 + + def test_personal_cached(self, settings, fixed_clock): + client = FakeClient( + [_report("K1AF", "EM28", "G0AAA", "JO01")], fixed_clock.now() + ) + service = self._service(settings, fixed_clock, client) + service.get_personal_analytics("K1AF", "EM28") + service.get_personal_analytics("K1AF", "EM28") + assert client.calls == ["K1AF"] + + +class TestBuildPersonalResult: + def test_empty_reports(self, fixed_clock): + result = build_personal_result( + FetchResult(reports=(), fetched_at=fixed_clock.now()), + callsign="K1AF", + regional=None, + fetched_at=fixed_clock.now(), + ) + assert result.analytics["reportsReceived"] == 0 + assert result.analytics["maximumDistanceKm"] == 0 + assert result.analytics["medianSnrDb"] is None + assert result.per_band_performance == {} + + +class TestRegionalRawCache: + def test_different_fields_share_fetch_but_not_nearby_statistics(self, settings, fixed_clock): + client = FakeClient([ + _report("K0AAA", "EM28", "G0AAA", "JO01"), + _report("VK2AAA", "QF56", "G0BBB", "JN58"), + ], fixed_clock.now()) + service = PskService(settings, fixed_clock, client=client) + kansas = service.get_regional_activity("EM28") + europe = service.get_regional_activity("JO01") + assert client.calls == [None] + assert kansas.bands["20m"].nearby_tx_total == 1 + assert europe.bands["20m"].nearby_tx_total == 0 + assert europe.fetched_at == kansas.fetched_at + # One upstream window must not become two baseline observations. + assert len(service._baseline._recent["20m"]) == 1 + + def test_raw_cache_expires_and_fetches_new_window(self, settings, fixed_clock): + client = FakeClient([_report("K0AAA", "EM28", "G0AAA", "JO01")], fixed_clock.now()) + service = PskService(settings, fixed_clock, client=client) + service.get_regional_activity("EM28") + fixed_clock.advance(settings.psk_cache_ttl_s) + service.get_regional_activity("JO01") + assert client.calls == [None, None] + + def test_concurrent_fields_fetch_only_once(self, settings, fixed_clock): + from concurrent.futures import ThreadPoolExecutor + from threading import Event + + started, release = Event(), Event() + + class BlockingClient(FakeClient): + def fetch_reports(self, **kwargs): + started.set() + assert release.wait(3) + return super().fetch_reports(**kwargs) + + client = BlockingClient([_report("K0AAA", "EM28", "G0AAA", "JO01")], fixed_clock.now()) + service = PskService(settings, fixed_clock, client=client) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(service.get_regional_activity, "EM28") + assert started.wait(1) + second = pool.submit(service.get_regional_activity, "JO01") + release.set() + assert first.result().bands + assert second.result().bands + assert client.calls == [None]