Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ca122a734 | ||
|
|
904d1ffbea | ||
|
|
9deb517874 | ||
|
|
fa91e89024 | ||
|
|
1a3c94f1e0 | ||
|
|
abb73ffef7 | ||
|
|
cc4f156c83 | ||
|
|
3810e76ea2 | ||
|
|
5ed76dba31 | ||
|
|
78a6f270bf | ||
|
|
8445c17033 |
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# First step of the Kotlin Multiplatform port: core:domain becomes shareable code that compiles
|
||||
# and runs on iOS as well as Android. This workflow is the evidence for that claim - the same
|
||||
# orbital math (SGP4/SDP4), models and repository contracts are compiled by the Kotlin/Native
|
||||
# compiler for iOS and their unit tests run on an iOS simulator. It deliberately does not touch
|
||||
# the Android build rules: the second job only proves the existing app still builds.
|
||||
name: ios-kmp
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- feat/ios-kmp
|
||||
- ios-kmp
|
||||
|
||||
jobs:
|
||||
ios:
|
||||
name: iOS shared module
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: Compile shared module for iOS
|
||||
run: ./gradlew :core:domain:compileKotlinIosSimulatorArm64 --console=plain
|
||||
- name: Unit tests on the iOS simulator
|
||||
run: ./gradlew :core:domain:iosSimulatorArm64Test --console=plain
|
||||
- name: Upload iOS test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-domain-test-report
|
||||
path: core/domain/build/reports/tests/
|
||||
|
||||
android:
|
||||
name: Android regression
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: JVM unit tests (domain and data)
|
||||
run: ./gradlew :core:domain:jvmTest :core:data:testDebugUnitTest --console=plain
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew :app:assembleDebug --console=plain
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-reports
|
||||
path: |
|
||||
core/domain/build/reports/tests/
|
||||
core/data/build/reports/tests/
|
||||
@@ -25,7 +25,7 @@ no tracking, no network required after initial data download.
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `app` | Entry point. Aggregates all modules |
|
||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||
| `core:domain` | Multiplatform: JVM + iOS. Orbital math (SGP4/SDP4), models, contracts |
|
||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||
| `feature:map` | OSMDroid map with ground tracks |
|
||||
| `feature:passes` | Pass predictions and upcoming events |
|
||||
@@ -50,7 +50,7 @@ no tracking, no network required after initial data download.
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 21 (`jdkVersion` in the version catalog)
|
||||
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
|
||||
|
||||
## Tech Stack
|
||||
@@ -71,7 +71,7 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
|
||||
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
|
||||
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
|
||||
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
|
||||
- `DataParser.kt` supports both via `parseTLE()` and `parseCSV()`, each taking the file text
|
||||
- Downloads auto-detect format; both produce identical `OrbitalData` objects
|
||||
- Existing code already supports transparent source transition without feature changes
|
||||
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
|
||||
@@ -103,11 +103,31 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||
- **KMP migration**: `core:domain` is now a Kotlin Multiplatform module (jvm + iosArm64/iosSimulatorArm64),
|
||||
so the orbital math, models and repository contracts are compiled once and shared with the iOS app; Android
|
||||
modules consume its jvm target. `commonMain` must stay free of JVM-only APIs (no `java.*`, `org.json`,
|
||||
`String.format`, `Locale`, `InputStream`) - `formatString` in `utility/CommonFormat.kt` covers printf.
|
||||
- **iOS app**: next step - an iOS shell that consumes the `Look4SatCore` framework plus the `expect`/`actual`
|
||||
platform pieces (map, location, sensors, notifications).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
|
||||
- `core:domain` is compiled for iOS too: anything added to its `commonMain` must exist in Kotlin/Native.
|
||||
`.github/workflows/ios-kmp.yml` compiles it for iOS and runs the shared tests on an iOS simulator.
|
||||
- Kotlin/JVM-only declarations still *resolve* in `commonMain` and only fail when the iOS target compiles:
|
||||
`@Synchronized` and `@Volatile` (the `kotlin.jvm` ones) are errors in common code since Kotlin 2.1, as are
|
||||
`toUpperCase`/`toLowerCase`/`capitalize` and `BigDecimal`. Use `kotlin.concurrent.Volatile`, and
|
||||
`utility/SynchronizedOn.kt` (a platform actual) when a monitor is needed. `check-multiplatform.sh` in the
|
||||
working copy's parent directory flags the rest.
|
||||
- Source sets: `commonTest` runs on both jvm and iOS, so no JUnit4, no `javaClass.classLoader` and no bare
|
||||
`assert()` there - a build without `-ea` skips those silently, and `-ea` is a JVM flag. Use `kotlin.test`.
|
||||
JVM-only tests (classpath resources, `Locale.setDefault`) belong in `jvmTest`; platform code in
|
||||
`jvmMain`/`iosMain`.
|
||||
- `formatString` has to match `java.lang.String.format` exactly, and that rounds the *shortest decimal
|
||||
representation* of a double half-up: `"%.3f"` of 0.5005 is `"0.501"`, even though the stored double is
|
||||
0.50049999999999994493. `CommonFormatOracleTest` (jvmTest) compares against real `String.format` over
|
||||
sampled doubles; `CommonFormatRoundingTest` (commonTest) pins literals so iOS checks the same digits.
|
||||
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
|
||||
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
|
||||
- AMSAT status colours are ARGB literals in `core:data` (`AmSatRepository.statusColorOf`) and duplicated in
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.IBinder
|
||||
import com.rtbishop.look4sat.BuildConfig
|
||||
import com.rtbishop.look4sat.MainApplication
|
||||
import com.rtbishop.look4sat.core.presentation.R
|
||||
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||
@@ -109,6 +110,8 @@ class AprsForegroundService : Service() {
|
||||
startForegroundWithNotification(cfg)
|
||||
val rep = AprsReporter(
|
||||
configProvider = { AprsStore.loadConfig(this) },
|
||||
// The real version, so the login line cannot drift from the build again.
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
positionProvider = { stationPosition() },
|
||||
onState = { lastState = it },
|
||||
onReport = { report ->
|
||||
@@ -123,13 +126,13 @@ class AprsForegroundService : Service() {
|
||||
// An unverified login needs its own message: the write succeeded, so a bare
|
||||
// failure notice would send the operator looking at their network when the
|
||||
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
|
||||
// No receive-only notice. APRS-IS lets an unverified station connect and then
|
||||
// discards its packets, but this app only reports its own position - there is no
|
||||
// receiving side to it - so telling the operator they are "in receive-only mode"
|
||||
// named a state that does not exist here. Without a passcode the packet does not
|
||||
// arrive, and that is what the failure notice says.
|
||||
val msg = when {
|
||||
report.ok -> getString(R.string.aprs_toast_ok)
|
||||
// Receive-only first: it logs in with -1 exactly as a wrong passcode does and
|
||||
// the server answers "unverified" to both, so without this branch the one safe
|
||||
// way to test a setup reported itself as a configuration error.
|
||||
!report.verified && report.receiveOnly ->
|
||||
getString(R.string.aprs_toast_receive_only)
|
||||
!report.verified -> getString(R.string.aprs_toast_unverified)
|
||||
else -> getString(R.string.aprs_toast_fail, report.detail)
|
||||
}
|
||||
|
||||
+39
-6
@@ -19,17 +19,50 @@ package com.rtbishop.look4sat.convention
|
||||
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||
|
||||
/**
|
||||
* core:domain is the one module shared by every platform: it holds the orbital math, the data
|
||||
* models and the repository contracts, and none of it touches Android APIs. It is a Kotlin
|
||||
* Multiplatform module (JVM for Android, Kotlin/Native for iOS) rather than a JVM one so the
|
||||
* same compiled logic runs on both platforms instead of being reimplemented.
|
||||
*
|
||||
* Android modules consume the jvm target; the iOS app consumes the framework built from the
|
||||
* ios targets. Anything JVM-only - org.json, java.net, java.io, java.util.Locale,
|
||||
* String.format - cannot live in commonMain, because Kotlin/Native has none of them.
|
||||
*/
|
||||
@Suppress("Unused")
|
||||
internal class CoreDomainPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) = with(target) {
|
||||
applyPlugin(libs.plugins.kotlin.jvm)
|
||||
applyPlugin(libs.plugins.kotlin.multiplatform)
|
||||
applyPlugin(libs.plugins.kotlin.serialization)
|
||||
setupKotlin()
|
||||
dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
extensions.configure<KotlinMultiplatformExtension> {
|
||||
jvmToolchain(libs.versions.jdkVersion.get().toInt())
|
||||
jvm()
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
|
||||
iosTarget.binaries.framework {
|
||||
baseName = "Look4SatCore"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
// The bare name accessors (commonMain, jvmTest, ...) are script-only syntax;
|
||||
// plugin source has to resolve through the container members, so configure each
|
||||
// source set by name. getByName is safe here: jvm() above has just created the
|
||||
// jvm source sets, the same pattern the local probe build script relies on.
|
||||
sourceSets.getByName("commonMain").dependencies {
|
||||
implementation(libs.kotlin.coroutines)
|
||||
implementation(libs.kotlin.serialization)
|
||||
}
|
||||
sourceSets.getByName("commonTest").dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.test.coroutines)
|
||||
}
|
||||
// JVM-only tests live here: the AndroidManifest check reads the file system, and
|
||||
// the formatter oracle tests compare against java.lang.String.format.
|
||||
sourceSets.getByName("jvmTest").dependencies {
|
||||
implementation(libs.test.junit4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
}
|
||||
// The APRS login line reports the app version to every station on the network. It used
|
||||
// to be a literal in core:data and drifted twice, so the app module now reads
|
||||
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
|
||||
@@ -52,12 +52,20 @@ data class AprsReport(
|
||||
* test a setup without putting anything on the network - was reported as a wrong passcode
|
||||
* and sent the operator to fix something they had set on purpose.
|
||||
*/
|
||||
val receiveOnly: Boolean = false
|
||||
|
||||
)
|
||||
|
||||
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||
class AprsReporter(
|
||||
private val configProvider: () -> AprsConfig,
|
||||
/**
|
||||
* The app's own version, reported to APRS-IS in the login line.
|
||||
*
|
||||
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
|
||||
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
|
||||
* network was told the wrong version. A caller in the app module can read the real one.
|
||||
*/
|
||||
private val appVersion: String,
|
||||
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||
private val onState: (AprsState) -> Unit = {},
|
||||
private val onReport: (AprsReport) -> Unit = {}
|
||||
@@ -127,13 +135,6 @@ class AprsReporter(
|
||||
}
|
||||
val packetLine = (beacon as AprsBeacon.Result.Line).text
|
||||
|
||||
// Receive-only is a deliberate choice and has to be told apart from a typo, because
|
||||
// both log in with -1 and the server answers "unverified" to each. Classifying rather
|
||||
// than asking canTransmit: that collapses ReceiveOnly and Mismatch into one boolean,
|
||||
// so a mistyped passcode would be told it is in receive-only mode - the same
|
||||
// mis-diagnosis as before, pointing the other way.
|
||||
val wantsReceiveOnly =
|
||||
AprsPasscode.classify(cfg.callsign, cfg.passcode) is AprsPasscode.Entry.ReceiveOnly
|
||||
val c = client ?: AprsIsClient(
|
||||
host = cfg.server,
|
||||
port = cfg.port,
|
||||
@@ -143,11 +144,8 @@ class AprsReporter(
|
||||
// transmitting under a passcode the app invented for an unchecked licence.
|
||||
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
|
||||
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
|
||||
// The version is hardcoded and drifts - it read 4.5.4 while the app was 4.6.0.
|
||||
// core:data has no BuildConfig, so fixing that properly means passing it in from
|
||||
// the app module; noting rather than doing it here to keep this change small.
|
||||
softwareName = "Look4Sat",
|
||||
version = "4.6.0"
|
||||
version = appVersion
|
||||
).also { client = it }
|
||||
if (!c.isConnected) c.connect()
|
||||
onState(AprsState.Connected)
|
||||
@@ -166,14 +164,13 @@ class AprsReporter(
|
||||
// report, say that instead.
|
||||
val reported = when {
|
||||
ok -> detail
|
||||
refused && wantsReceiveOnly -> "receive-only, not forwarded"
|
||||
refused -> "login not verified"
|
||||
else -> detail
|
||||
}
|
||||
onReport(
|
||||
AprsReport(
|
||||
System.currentTimeMillis(), packetLine, ok, reported,
|
||||
verified = !refused, receiveOnly = wantsReceiveOnly
|
||||
verified = !refused
|
||||
)
|
||||
)
|
||||
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
|
||||
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.nio.FloatBuffer
|
||||
@@ -64,13 +66,28 @@ class CwDeepDecoder(
|
||||
private val isToneShiftEnabled: () -> Boolean = { false }
|
||||
) : ICwDecoder {
|
||||
|
||||
private companion object {
|
||||
// internal, not private: the archive timing is user-visible behaviour and its test asserts
|
||||
// against these constants directly rather than a copy that could silently drift.
|
||||
internal companion object {
|
||||
const val TAG = "CwDeepDecoder"
|
||||
const val MODEL_ASSET = "deepcw/model.onnx"
|
||||
const val METADATA_ASSET = "deepcw/model.onnx.json"
|
||||
|
||||
/** Evicted audio is decoded into permanent history once this much accumulates. */
|
||||
const val ARCHIVE_SECONDS = 15.0
|
||||
/**
|
||||
* Evicted audio is decoded into permanent history once this much accumulates.
|
||||
*
|
||||
* This is the delay before decoded text reaches the record, and it is additive with
|
||||
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
|
||||
* of a session, and thereafter text that had scrolled out of the live window sat
|
||||
* invisible for up to 15 s before landing - the record appeared to stall and, when
|
||||
* it was still concatenating the live window, to delete what it had just shown.
|
||||
*
|
||||
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
|
||||
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
|
||||
* stall for longer than the one thing an operator most needs to read back - while the
|
||||
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
|
||||
*/
|
||||
const val ARCHIVE_SECONDS = 4.0
|
||||
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
|
||||
|
||||
/**
|
||||
@@ -104,13 +121,12 @@ class CwDeepDecoder(
|
||||
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
|
||||
|
||||
/**
|
||||
* Archived text, plus a provisional decode of audio not yet archived.
|
||||
* Archived text: only appended to, so a pane bound to it never loses what it showed.
|
||||
*
|
||||
* Kept as one flow of the two parts concatenated. Without the provisional part the
|
||||
* transcript visibly shrank: audio leaving the 20 s window waits for a full
|
||||
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
|
||||
* characters were in neither place - measured, up to 30 characters at 20 WPM would
|
||||
* vanish and reappear later, which reads as the box deleting text.
|
||||
* This once carried a provisional tail meant to cover the gap while audio waited to be
|
||||
* archived, but the decode feeding that tail was never wired up, so the tail was always
|
||||
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
|
||||
* batches, which no longer concatenate anything that gets rewritten.
|
||||
*/
|
||||
private val _historyText = MutableStateFlow("")
|
||||
override val historyText: StateFlow<String> = _historyText.asStateFlow()
|
||||
@@ -118,9 +134,6 @@ class CwDeepDecoder(
|
||||
/** Permanently archived text; the provisional tail is appended to this for display. */
|
||||
private var committedText = ""
|
||||
|
||||
/** Provisional decode of the pending archive batch, replaced when it is archived. */
|
||||
private var pendingText = ""
|
||||
|
||||
private val _estimatedPitch = MutableStateFlow<Float?>(null)
|
||||
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
|
||||
|
||||
@@ -142,18 +155,54 @@ class CwDeepDecoder(
|
||||
private val buffer = CwDeepBuffer()
|
||||
|
||||
/**
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
|
||||
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
|
||||
* keeps the extra inference cheap (short window) while long enough to be
|
||||
* decoded accurately — the content has already been through the 20 s window
|
||||
* many times, so a slightly shorter archive decode loses almost nothing.
|
||||
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
|
||||
* once and appended to [historyText]. The batch stays short enough that the record does
|
||||
* not visibly stall, and accuracy barely suffers: this content has already been through
|
||||
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
|
||||
*
|
||||
* Sized to the full window rather than the batch: [flush] hands over whatever the live
|
||||
* window holds, which can be the whole 20 s.
|
||||
*/
|
||||
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
|
||||
private var archiveSize = 0
|
||||
|
||||
/**
|
||||
* Window contents retired by a change of shift, waiting to be archived.
|
||||
*
|
||||
* The live window is the only route into the archive - audio gets there by being pushed
|
||||
* out - so clearing the window used to mean its audio was never decoded at all. When the
|
||||
* shift changed more often than the window took to fill, that was every sample: modelled
|
||||
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
|
||||
* Synchronised on itself: written from the capture path and drained from both there and
|
||||
* [flush], which run on different coroutines. Capped, because a signal drifting on every
|
||||
* detection scan would otherwise queue windows faster than they can be decoded and grow
|
||||
* without bound; past the cap the oldest goes, since newer audio is what is being read.
|
||||
*/
|
||||
private val retiredAudio = ArrayDeque<FloatArray>()
|
||||
|
||||
/** Windows held awaiting archival before the oldest is dropped. */
|
||||
private val retiredAudioLimit = 4
|
||||
|
||||
/** Held while inference runs so slow devices skip work instead of queuing it. */
|
||||
private val inferenceLock = Mutex()
|
||||
|
||||
/**
|
||||
* Serialises the archive path, which mutates state the capture coroutine also touches.
|
||||
*
|
||||
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
|
||||
* scope as the screen leaves - and both append to [committedText], which is a read, an
|
||||
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
|
||||
* later write wins and a whole batch of text is lost, precisely at the moment the
|
||||
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
|
||||
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
|
||||
* makes the same audio decode twice.
|
||||
*
|
||||
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
|
||||
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
|
||||
* archive batch discards the audio for good).
|
||||
*/
|
||||
private val archiveLock = Mutex()
|
||||
|
||||
/** Decides what shift to apply from successive tone estimates. */
|
||||
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
|
||||
|
||||
@@ -172,6 +221,17 @@ class CwDeepDecoder(
|
||||
/** Carries Hilbert filter history and mixer phase across capture chunks. */
|
||||
private val streamingShifter = CwToneShifter.Streaming()
|
||||
|
||||
/**
|
||||
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
|
||||
*
|
||||
* Built on the first chunk because the capture rate is not known until then. Without
|
||||
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
|
||||
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
|
||||
* folded down on top of the signal.
|
||||
*/
|
||||
private var antiAlias: CwAntiAlias.Streaming? = null
|
||||
private var antiAliasRate = 0
|
||||
|
||||
/**
|
||||
* Previous value of the setting, so a toggle can invalidate buffered audio.
|
||||
* Null until the first chunk: a decoder created while the setting is already on
|
||||
@@ -255,10 +315,35 @@ class CwDeepDecoder(
|
||||
if (samples.isEmpty()) return
|
||||
if (!ensureLoaded()) return
|
||||
|
||||
// Filter before decimating. resampleLinear interpolates without removing anything
|
||||
// above the new Nyquist, so this has to happen first or the fold is already baked in.
|
||||
if (antiAlias == null || antiAliasRate != sampleRate) {
|
||||
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
|
||||
antiAliasRate = sampleRate
|
||||
}
|
||||
val bandLimited = antiAlias?.process(samples) ?: samples
|
||||
// The filter holds back its group delay, so the first call returns nothing.
|
||||
if (bandLimited.isEmpty()) return
|
||||
|
||||
val resampled = CwDeepSpectrogram.resampleLinear(
|
||||
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
|
||||
)
|
||||
val prepared = applyToneShift(resampled)
|
||||
|
||||
// Anything applyToneShift just retired from the window is older than what follows, so
|
||||
// it is archived before the new audio is buffered - otherwise the record comes out
|
||||
// with its text transposed. Archived whole rather than accumulated: it is already a
|
||||
// full window's worth, and holding it back would only expose it to the next retirement.
|
||||
val retired = drainRetiredAudio()
|
||||
for (batch in retired) {
|
||||
try {
|
||||
archiveDecode(batch)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
Log.e(TAG, "retired audio decode failed", t)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldRedecode = buffer.append(prepared)
|
||||
|
||||
// Archive audio that scrolled out of the live window. It is decoded once
|
||||
@@ -385,12 +470,50 @@ class CwDeepDecoder(
|
||||
* cannot be un-shifted, so they are dropped rather than decoded against the new
|
||||
* shift. Text already committed to [historyText] stays: it was correct when decoded.
|
||||
*/
|
||||
/**
|
||||
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
|
||||
*
|
||||
* The live window has to go: it holds samples moved by two different amounts, and one
|
||||
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
|
||||
* already left the window, they were shifted consistently, and they are complete, so
|
||||
* discarding them threw away decodable audio for no reason. They are left in place here to
|
||||
* be archived by the normal path, which keeps this function non-suspending: its two
|
||||
* callers sit on the synchronous capture path, and making them suspend to run an inference
|
||||
* here would put a decode inside the tone-detection scan.
|
||||
*
|
||||
* It mattered because this runs on every change of shift, which tracks the detected tone,
|
||||
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
|
||||
* permanently empty however long the operator listened: the window was wiped before any
|
||||
* batch could complete, so nothing was ever committed. With the record still concatenating
|
||||
* the live decode at the time, each wipe visibly cut the transcript short as well - text
|
||||
* going backwards, then never accumulating at all.
|
||||
*/
|
||||
/** Take every retired window, oldest first, leaving the queue empty. */
|
||||
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
|
||||
if (retiredAudio.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
retiredAudio.toList().also { retiredAudio.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropBufferedAudio() {
|
||||
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
|
||||
// in one spectrogram smears the tone - but they are internally consistent and
|
||||
// complete, so they decode fine on their own. Handed to the archive path rather than
|
||||
// decoded here, because both callers sit on the synchronous capture path.
|
||||
val retiring = buffer.snapshot()
|
||||
if (retiring.isNotEmpty()) {
|
||||
synchronized(retiredAudio) {
|
||||
while (retiredAudio.size >= retiredAudioLimit) {
|
||||
Log.w(TAG, "retired audio queue full, dropping the oldest window")
|
||||
retiredAudio.removeFirst()
|
||||
}
|
||||
retiredAudio.addLast(retiring)
|
||||
}
|
||||
}
|
||||
buffer.reset()
|
||||
archiveSize = 0
|
||||
// The provisional text describes audio being discarded, so it goes with it.
|
||||
// Committed text stays: it was correct for audio that really was archived.
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
@@ -475,40 +598,63 @@ class CwDeepDecoder(
|
||||
updateSignalMetrics(spectrogram)
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
|
||||
*
|
||||
* Two places hold audio that would otherwise never be decoded into the record: the
|
||||
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
|
||||
* contents only ever reach the archive by being pushed out by newer audio. Together
|
||||
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
|
||||
* - which includes the end of every transmission, the part with the call sign in it.
|
||||
*
|
||||
* Order matters: the pending batch left the window before anything still in it, so it
|
||||
* has to be archived first or the record comes out with its text transposed.
|
||||
*/
|
||||
override suspend fun flush() = archiveLock.withLock {
|
||||
// Oldest first, all the way down: retired window contents, then the batch accumulating
|
||||
// towards the threshold, then what is still live.
|
||||
for (batch in drainRetiredAudio()) {
|
||||
runCatching { archiveDecodeLocked(batch) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
if (archiveSize > 0) {
|
||||
val pending = archiveBuffer.copyOf(archiveSize)
|
||||
archiveSize = 0
|
||||
runCatching { archiveDecodeLocked(pending) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// Draining the window empties it, so a second flush cannot double-archive the tail.
|
||||
val window = buffer.snapshot()
|
||||
if (window.isNotEmpty()) {
|
||||
buffer.reset()
|
||||
runCatching { archiveDecodeLocked(window) }
|
||||
.onFailure { if (it is CancellationException) throw it }
|
||||
}
|
||||
// The live line described audio that is now in the record; leaving it would show the
|
||||
// same characters twice, in two places, one of them stale.
|
||||
_decodedText.value = ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a chunk of audio that has scrolled out of the live window and
|
||||
* append it to [historyText]. Unlike the live window this never replaces —
|
||||
* the archived audio is final, so its text is permanent.
|
||||
*/
|
||||
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
|
||||
archiveDecodeLocked(audio)
|
||||
}
|
||||
|
||||
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
|
||||
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
val text = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
// This batch is final, so its provisional decode is superseded rather than kept.
|
||||
committedText += text
|
||||
pendingText = ""
|
||||
_historyText.value = committedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the audio waiting to be archived, so it stays on screen until it is.
|
||||
*
|
||||
* Provisional: the batch is still growing, and the final decode sees all of it at once
|
||||
* with more context. Runs on the redecode cycle rather than per capture chunk -
|
||||
* decoding every 100 ms chunk separately measured 14x the inference load, over 250% of
|
||||
* one core, and a chunk that short carries under two dot-lengths of context anyway.
|
||||
*/
|
||||
private suspend fun decodePending(audio: FloatArray) = withContext(Dispatchers.Default) {
|
||||
val activeSession = session ?: return@withContext
|
||||
val activeEnvironment = environment ?: return@withContext
|
||||
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
|
||||
val spectrogram = CwDeepSpectrogram.compute(audio)
|
||||
pendingText = runInference(activeSession, activeEnvironment, spectrogram)
|
||||
_historyText.value = committedText + pendingText
|
||||
}
|
||||
|
||||
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
|
||||
private fun runInference(
|
||||
activeSession: OrtSession,
|
||||
@@ -583,12 +729,19 @@ class CwDeepDecoder(
|
||||
_signalStrength.value = if (decodable) prominence else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
|
||||
* flight can land its batch after this returns, leaving a few characters behind. The
|
||||
* operator asked to clear and can ask again; making this suspend to close that window
|
||||
* would push it onto every caller, including a synchronous button handler.
|
||||
*/
|
||||
override fun reset() {
|
||||
antiAlias?.reset()
|
||||
buffer.reset()
|
||||
synchronized(retiredAudio) { retiredAudio.clear() }
|
||||
_decodedText.value = ""
|
||||
_historyText.value = ""
|
||||
committedText = ""
|
||||
pendingText = ""
|
||||
archiveSize = 0
|
||||
_estimatedPitch.value = null
|
||||
_detectedToneHz.value = null
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||
import com.rtbishop.look4sat.core.data.source.OkHttpHttpClient
|
||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||
@@ -73,11 +74,21 @@ import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
|
||||
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
|
||||
import okhttp3.OkHttpClient
|
||||
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
|
||||
|
||||
class MainContainer(private val context: Context) : IMainContainer {
|
||||
|
||||
private val localSource = provideLocalSource()
|
||||
private val remoteSource by lazy { provideRemoteSource() }
|
||||
|
||||
/**
|
||||
* WaveLogApi is a plain object in core:domain, and shared code has no socket API of its own on
|
||||
* iOS, so the container hands it the platform client. Its requests used to be made by an
|
||||
* HttpURLConnection built inside WaveLogApi, which only ever existed on the JVM.
|
||||
*/
|
||||
init {
|
||||
WaveLogApi.installHttpClient(OkHttpHttpClient(OkHttpClient.Builder().build()))
|
||||
}
|
||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||
override val settingsRepo = provideSettingsRepo()
|
||||
|
||||
+24
-25
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
class DatabaseRepo(
|
||||
@@ -55,8 +55,8 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data))
|
||||
localSource.insertEntries(entries)
|
||||
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
|
||||
importedCount = entries.size
|
||||
@@ -67,8 +67,8 @@ class DatabaseRepo(
|
||||
|
||||
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
|
||||
var importedCount = 0
|
||||
remoteSource.getFileStream(uri)?.let { stream ->
|
||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||
remoteSource.getFileBytes(uri)?.let { data ->
|
||||
val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString())
|
||||
localSource.insertRadios(transceivers)
|
||||
importedCount = transceivers.size
|
||||
}
|
||||
@@ -105,8 +105,8 @@ class DatabaseRepo(
|
||||
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
|
||||
}
|
||||
// launch all network requests concurrently
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
|
||||
val tleResults = tleJobs.awaitAll()
|
||||
val radioResults = radioJobs.awaitAll()
|
||||
// Orbital elements are counted on their own. A combined count let a successful transceivers
|
||||
@@ -120,14 +120,14 @@ class DatabaseRepo(
|
||||
throw java.io.IOException("No orbital data source could be downloaded")
|
||||
}
|
||||
// parse fetched data concurrently and associate with types
|
||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||
val importedEntries = tleResults.flatMap { (url, data) ->
|
||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
data?.let { parseSatelliteData(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||
}
|
||||
}
|
||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||
val importedRadios = radioResults.flatMap { (url, data) ->
|
||||
data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty()
|
||||
}
|
||||
// insert parsed data into the database
|
||||
localSource.insertEntries(importedEntries)
|
||||
@@ -141,11 +141,11 @@ class DatabaseRepo(
|
||||
setUpdateSuccessful(0L)
|
||||
}
|
||||
|
||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||
val bufferedStream = stream.buffered()
|
||||
private suspend fun parseSatelliteData(url: String, data: ByteArray): List<OrbitalData> {
|
||||
val text = data.decodeToString()
|
||||
return when {
|
||||
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
|
||||
else -> dataParser.parseTLEStream(bufferedStream)
|
||||
hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text)
|
||||
else -> dataParser.parseTLE(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,14 +155,9 @@ class DatabaseRepo(
|
||||
url.endsWith(".csv.zip", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun looksLikeCsv(stream: InputStream): Boolean {
|
||||
if (!stream.markSupported()) return false
|
||||
stream.mark(4096)
|
||||
val preview = ByteArray(4096)
|
||||
val length = stream.read(preview)
|
||||
stream.reset()
|
||||
if (length <= 0) return false
|
||||
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
private fun looksLikeCsv(text: String): Boolean {
|
||||
val line = text.lineSequence().firstOrNull()?.trim().orEmpty()
|
||||
if (line.isEmpty()) return false
|
||||
return line.contains("OBJECT_NAME", ignoreCase = true) ||
|
||||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
|
||||
line.count { it == ',' } >= 4
|
||||
@@ -174,6 +169,10 @@ class DatabaseRepo(
|
||||
)
|
||||
}
|
||||
|
||||
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
|
||||
private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray =
|
||||
if (url.endsWith(".zip", ignoreCase = true)) {
|
||||
ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.source
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.source.HttpResult
|
||||
import com.rtbishop.look4sat.core.domain.source.IHttpClient
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Android [IHttpClient] for the shared Wavelog/QRZ code, which cannot reach java.net on iOS.
|
||||
*
|
||||
* Connect and read timeouts are the 15 s the previous HttpURLConnection client used, applied to
|
||||
* the passed client so the caller keeps one connection pool. The response body is returned for
|
||||
* error codes as well, which is what reading errorStream did.
|
||||
*/
|
||||
class OkHttpHttpClient(
|
||||
baseClient: OkHttpClient,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : IHttpClient {
|
||||
|
||||
private val dispatcher = dispatcher
|
||||
|
||||
private val client = baseClient.newBuilder()
|
||||
.connectTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
override suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult =
|
||||
execute {
|
||||
Request.Builder().url(url).post(body.toRequestBody(JSON_MEDIA_TYPE)).withHeaders(headers).build()
|
||||
}
|
||||
|
||||
override suspend fun get(url: String, headers: Map<String, String>): HttpResult =
|
||||
execute { Request.Builder().url(url).withHeaders(headers).build() }
|
||||
|
||||
private suspend fun execute(buildRequest: () -> Request): HttpResult = withContext(dispatcher) {
|
||||
try {
|
||||
// Built in here, not by the caller: a URL OkHttp refuses to parse has to come back as
|
||||
// the HTTP -1 the old client reported, not as an exception thrown at the caller.
|
||||
val request = buildRequest()
|
||||
client.newCall(request).execute().use { response ->
|
||||
HttpResult(response.code, response.body.string())
|
||||
}
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
HttpResult(NO_RESPONSE, "", exception.message ?: exception.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.Builder.withHeaders(headers: Map<String, String>): Request.Builder {
|
||||
headers.forEach { (name, value) -> header(name, value) }
|
||||
return this
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches HttpURLConnection's connect/read timeout in the original WaveLog client. */
|
||||
const val TIMEOUT_MS = 15_000L
|
||||
|
||||
/** What HttpURLConnection's responseCode() reported when a request never got a response. */
|
||||
const val NO_RESPONSE = -1
|
||||
|
||||
/** WaveLog's v2 and v1 endpoints take application/json in both directions. */
|
||||
val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
|
||||
class RemoteSource(
|
||||
private val dispatcher: CoroutineDispatcher,
|
||||
@@ -33,10 +32,10 @@ class RemoteSource(
|
||||
private val httpClient: OkHttpClient
|
||||
) : IRemoteSource {
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val fileUri = uri.toUri()
|
||||
contentResolver.openInputStream(fileUri)?.buffered()
|
||||
contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
@@ -45,17 +44,14 @@ class RemoteSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = withContext(dispatcher) {
|
||||
try {
|
||||
val networkRequest = Request.Builder().url(url).build()
|
||||
val response = httpClient.newCall(networkRequest).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
return@withContext null
|
||||
// The whole body is read here, which also returns the connection to OkHttp's pool
|
||||
httpClient.newCall(networkRequest).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use null
|
||||
response.body.bytes()
|
||||
}
|
||||
// Return the body stream directly as the caller is responsible for closing it
|
||||
// That returns the connection to OkHttp's pool
|
||||
response.body.byteStream().buffered()
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (exception: Exception) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.data.cw
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
|
||||
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.floor
|
||||
|
||||
/**
|
||||
* How long audio waits before its text can reach the record pane.
|
||||
*
|
||||
* The record binds to archived text only. The live decode is rewritten from scratch every
|
||||
* cycle, so a pane that concatenated it lost characters the operator had already read - the
|
||||
* decoder appeared to delete its own output while still running. Binding to archived text
|
||||
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
|
||||
* pushed out of the live window, then accumulate into a full archive batch.
|
||||
*
|
||||
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
|
||||
* These tests read its real constants rather than copies, so retuning one without
|
||||
* reconsidering the user-visible delay fails here.
|
||||
*/
|
||||
class CwArchiveTimingTest {
|
||||
|
||||
/** Sending speed for the character counts, typical for satellite CW. */
|
||||
private val wpm = 18.0
|
||||
|
||||
/** PARIS standard: one word is five characters. */
|
||||
private val charsPerSecond = wpm * 5 / 60.0
|
||||
|
||||
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
|
||||
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
|
||||
|
||||
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
|
||||
private fun archivedSeconds(elapsed: Double): Double {
|
||||
val evicted = elapsed - window
|
||||
if (evicted <= 0.0) return 0.0
|
||||
return floor(evicted / batch) * batch
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdMatchesTheDeclaredBatchLength() {
|
||||
assertEquals(
|
||||
"threshold must be the batch length in samples",
|
||||
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveBatchIsShorterThanACallSign() {
|
||||
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
|
||||
// means the record can stall for longer than the single most important thing being
|
||||
// sent, which is what made the stall read as deletion.
|
||||
val callSignSeconds = 7 / charsPerSecond
|
||||
assertTrue(
|
||||
"batch $batch s must not exceed a call sign at $wpm WPM " +
|
||||
"(${"%.1f".format(callSignSeconds)} s)",
|
||||
batch <= callSignSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstTextReachesTheRecordWithinHalfAMinute() {
|
||||
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
|
||||
// still completely empty: every decoded character had only ever been in the live line,
|
||||
// which shows 64 characters and overwrites them.
|
||||
val firstArchive = window + batch
|
||||
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aThirtySecondSessionStillProducesARecord() {
|
||||
val archived = archivedSeconds(30.0)
|
||||
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theRecordNeverStallsForLongerThanOneBatch() {
|
||||
var longestStall = 0.0
|
||||
var lastGrowthAt = window
|
||||
var previous = 0.0
|
||||
var t = window
|
||||
while (t <= 600.0) {
|
||||
val archived = archivedSeconds(t)
|
||||
if (archived > previous) {
|
||||
longestStall = maxOf(longestStall, t - lastGrowthAt)
|
||||
lastGrowthAt = t
|
||||
previous = archived
|
||||
}
|
||||
t += 0.1
|
||||
}
|
||||
assertTrue(
|
||||
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
|
||||
longestStall <= batch + 0.11
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
|
||||
// Why flush() exists. Neither holding place drains on its own: the live window only
|
||||
// reaches the archive by being pushed out by newer audio, and the pending batch only
|
||||
// by filling up. Both hold the end of the transmission, where the call sign is.
|
||||
val worstCase = window + batch
|
||||
val lostCharacters = worstCase * charsPerSecond
|
||||
assertTrue(
|
||||
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
|
||||
lostCharacters > 20
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivingStaysRarerThanTheLiveDecode() {
|
||||
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
|
||||
// stay rarer than the live redecode or the archive path becomes the dominant cost.
|
||||
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
|
||||
assertTrue(
|
||||
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
|
||||
batch > liveIntervalSeconds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBatchIsLongEnoughToDecode() {
|
||||
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
|
||||
// silently dropped by archiveDecode's size guard and its text lost outright.
|
||||
assertTrue(
|
||||
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
|
||||
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
|
||||
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.GregorianCalendar
|
||||
import java.util.Locale
|
||||
@@ -18,8 +17,8 @@ import java.util.TimeZone
|
||||
class AmSatAuditTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
|
||||
+2
-3
@@ -4,7 +4,6 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
@@ -24,8 +23,8 @@ import java.util.TimeZone
|
||||
class AmSatSlotBuildTest {
|
||||
|
||||
private object UnusedSource : IRemoteSource {
|
||||
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||
override suspend fun getNetworkStream(url: String): InputStream? = null
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = null
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? = null
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||
override suspend fun getAmSatSummary(hours: Int): String? = null
|
||||
|
||||
+14
-15
@@ -41,7 +41,6 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.InputStream
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DatabaseRepoTest {
|
||||
@@ -54,7 +53,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/satellites"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validCsvStream() }
|
||||
fileData[uri] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -72,7 +71,7 @@ class DatabaseRepoTest {
|
||||
val uri = "content://look4sat/import/legacy"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
fileStreams[uri] = { validTleStream() }
|
||||
fileData[uri] = { validTleBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo()
|
||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||
@@ -88,7 +87,7 @@ class DatabaseRepoTest {
|
||||
val customCsvUrl = "https://example.com/custom-omm.csv"
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
networkStreams[customCsvUrl] = { validCsvStream() }
|
||||
networkData[customCsvUrl] = { validCsvBytes() }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
@@ -134,7 +133,7 @@ class DatabaseRepoTest {
|
||||
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
|
||||
// parse a radio payload on the JVM anyway.
|
||||
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkStreams[it] = { validCsvStream() } }
|
||||
.forEach { networkData[it] = { validCsvBytes() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
@@ -172,7 +171,7 @@ class DatabaseRepoTest {
|
||||
val localSource = FakeLocalSource()
|
||||
val remoteSource = FakeRemoteSource().apply {
|
||||
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
|
||||
.forEach { networkStreams[it] = { "[]".byteInputStream() } }
|
||||
.forEach { networkData[it] = { "[]".encodeToByteArray() } }
|
||||
}
|
||||
val settingsRepo = FakeSettingsRepo(
|
||||
dataSources = DataSourcesSettings(
|
||||
@@ -195,30 +194,30 @@ class DatabaseRepoTest {
|
||||
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
|
||||
}
|
||||
|
||||
private fun validCsvStream(): InputStream = """
|
||||
private fun validCsvBytes(): ByteArray = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
|
||||
private fun validTleStream(): InputStream = """
|
||||
private fun validTleBytes(): ByteArray = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent().encodeToByteArray()
|
||||
}
|
||||
|
||||
private class FakeRemoteSource : IRemoteSource {
|
||||
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
|
||||
val fileData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
val networkData: MutableMap<String, () -> ByteArray> = mutableMapOf()
|
||||
|
||||
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
|
||||
val requestedUrls = mutableListOf<String>()
|
||||
|
||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||
override suspend fun getFileBytes(uri: String): ByteArray? = fileData[uri]?.invoke()
|
||||
|
||||
override suspend fun getNetworkStream(url: String): InputStream? {
|
||||
override suspend fun getNetworkBytes(url: String): ByteArray? {
|
||||
requestedUrls += url
|
||||
return networkStreams[url]?.invoke()
|
||||
return networkData[url]?.invoke()
|
||||
}
|
||||
|
||||
override suspend fun getAmSatCatalog(): String? = null
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
plugins {
|
||||
alias(libs.plugins.convention.coreDomainPlugin)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||
compileOnly("org.json:json:20240303")
|
||||
}
|
||||
+1
-1
@@ -91,7 +91,7 @@ object AprsBeacon {
|
||||
)
|
||||
val header = "$source>$DESTINATION,$PATH:="
|
||||
val body = position.toUncompressedString()
|
||||
val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size
|
||||
val room = MAX_LINE_BYTES - CRLF_BYTES - header.encodeToByteArray().size - body.encodeToByteArray().size
|
||||
return Result.Line(header + body + sanitiseComment(comment, room))
|
||||
}
|
||||
|
||||
File renamed without changes.
+10
-10
@@ -2,7 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
|
||||
/**
|
||||
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||
@@ -30,7 +30,7 @@ object AprsPacket {
|
||||
|
||||
/** Optional distance filter: filter r/lat/lon/dist */
|
||||
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
return formatString("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ object AprsPacket {
|
||||
fun formatAltitude(altitudeMeters: Double?): String {
|
||||
if (altitudeMeters == null) return ""
|
||||
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
|
||||
return String.format(Locale.ROOT, "/A=%06d", feet)
|
||||
return formatString("/A=%06d", feet)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ object AprsPacket {
|
||||
if (speedMps == null || bearing == null) return ""
|
||||
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
|
||||
val course = ((bearing.toInt() % 360) + 360) % 360
|
||||
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
|
||||
return formatString("/%03d/%03d", course, knots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +106,17 @@ class AprsPosition(
|
||||
val hundredths = iRound % 100
|
||||
val frac = when (positionAmbiguity) {
|
||||
1 -> " . "
|
||||
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
|
||||
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
|
||||
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
|
||||
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
|
||||
2 -> formatString("%d . ", minutes / 10)
|
||||
3 -> formatString("%02d. ", minutes)
|
||||
4 -> formatString("%02d.%d ", minutes, hundredths / 10)
|
||||
else -> formatString("%02d.%02d", minutes, hundredths)
|
||||
}
|
||||
return if (isLat) {
|
||||
val ns = if (value >= 0) 'N' else 'S'
|
||||
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
|
||||
formatString("%02d%s%c", degrees, frac, ns)
|
||||
} else {
|
||||
val ew = if (value >= 0) 'E' else 'W'
|
||||
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
|
||||
formatString("%03d%s%c", degrees, frac, ew)
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Low-pass filter applied before decimating to the model's sample rate.
|
||||
*
|
||||
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
|
||||
* interpolating between samples, with nothing removing the content above the new
|
||||
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
|
||||
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
|
||||
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
|
||||
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
|
||||
* of hiss folds down on top of the signal and lifts the noise floor across the entire
|
||||
* display.
|
||||
*
|
||||
* The resampler itself is deliberately left alone: its comment notes it matches the
|
||||
* reference implementation the model was trained against, so changing its arithmetic
|
||||
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
|
||||
* aliasing without touching that contract.
|
||||
*
|
||||
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
|
||||
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
|
||||
* smear the keying envelope the way a high-order IIR would.
|
||||
*/
|
||||
object CwAntiAlias {
|
||||
|
||||
/**
|
||||
* Cut-off as a fraction of the target Nyquist.
|
||||
*
|
||||
* Below 1.0 so the transition band lands inside the discarded region rather than
|
||||
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
|
||||
* model's 1200 Hz window and the shifter's detection range with room to spare.
|
||||
*/
|
||||
private const val CUTOFF_FRACTION = 0.92
|
||||
|
||||
/**
|
||||
* Filter length. Odd so the group delay is a whole number of samples.
|
||||
*
|
||||
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
|
||||
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
|
||||
* be sharper and slower; this runs on a phone during a pass.
|
||||
*/
|
||||
private const val TAPS = 127
|
||||
|
||||
/** Delay introduced by [TAPS], for callers that need to align another path. */
|
||||
const val GROUP_DELAY_SAMPLES = TAPS / 2
|
||||
|
||||
/**
|
||||
* Filter [audio] so that decimating to [targetRate] cannot alias.
|
||||
*
|
||||
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
|
||||
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
|
||||
*/
|
||||
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
|
||||
if (audio.isEmpty() || sourceRate <= targetRate) return audio
|
||||
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
|
||||
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
|
||||
*
|
||||
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
|
||||
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
|
||||
* survives.
|
||||
*/
|
||||
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
|
||||
val normalised = cutoffHz / sampleRate
|
||||
val half = TAPS / 2
|
||||
val raw = DoubleArray(TAPS) { i ->
|
||||
val n = i - half
|
||||
val sinc = if (n == 0) {
|
||||
2.0 * normalised
|
||||
} else {
|
||||
sin(2.0 * PI * normalised * n) / (PI * n)
|
||||
}
|
||||
val window = 0.42 -
|
||||
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
|
||||
0.08 * cos(4.0 * PI * i / (TAPS - 1))
|
||||
sinc * window
|
||||
}
|
||||
val sum = raw.sum()
|
||||
// Unity DC gain, so filtering does not change the level the model was trained on.
|
||||
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convolve, compensating for the filter's own delay so the output lines up with
|
||||
* the input. Edge taps that fall outside the buffer see zeros, which costs the
|
||||
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
|
||||
*/
|
||||
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
|
||||
val out = FloatArray(audio.size)
|
||||
for (i in audio.indices) {
|
||||
var sum = 0f
|
||||
for (k in kernel.indices) {
|
||||
val j = i - k + GROUP_DELAY_SAMPLES
|
||||
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
|
||||
*
|
||||
* Two things are needed for concatenated chunks to match a whole-buffer filter.
|
||||
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
|
||||
* outputs need the tail of the one before it.
|
||||
*
|
||||
* The second is less obvious and was measured rather than reasoned about. A
|
||||
* linear-phase FIR is centred, so output sample `i` needs input up to
|
||||
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
|
||||
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
|
||||
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
|
||||
* last 44 samples of every chunk, which is a click at each boundary rather than a
|
||||
* rounding difference.
|
||||
*
|
||||
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
|
||||
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
|
||||
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
|
||||
* against a 20 WPM dot of roughly 60 ms.
|
||||
*
|
||||
* Not thread-safe: driven from the single capture coroutine.
|
||||
*/
|
||||
class Streaming(sourceRate: Int, targetRate: Int) {
|
||||
|
||||
private val kernel: FloatArray? =
|
||||
if (sourceRate <= targetRate) {
|
||||
null
|
||||
} else {
|
||||
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
|
||||
}
|
||||
|
||||
/** Samples not yet emitted: filter history plus the lookahead still owed. */
|
||||
private var pending = FloatArray(0)
|
||||
|
||||
/** Filter one chunk, continuing from the previous call. */
|
||||
fun process(chunk: FloatArray): FloatArray {
|
||||
val k = kernel ?: return chunk
|
||||
if (chunk.isEmpty()) return chunk
|
||||
|
||||
val combined = FloatArray(pending.size + chunk.size)
|
||||
pending.copyInto(combined)
|
||||
chunk.copyInto(combined, pending.size)
|
||||
|
||||
// Only samples with a full window on both sides are ready. Everything from
|
||||
// here on still needs input that has not arrived.
|
||||
val ready = combined.size - TAPS + 1
|
||||
if (ready <= 0) {
|
||||
pending = combined
|
||||
return FloatArray(0)
|
||||
}
|
||||
|
||||
val out = FloatArray(ready)
|
||||
for (i in 0 until ready) {
|
||||
var sum = 0f
|
||||
for (t in k.indices) {
|
||||
sum += k[t] * combined[i + TAPS - 1 - t]
|
||||
}
|
||||
out[i] = sum
|
||||
}
|
||||
|
||||
// Carry the tail that the next chunk will complete.
|
||||
pending = combined.copyOfRange(ready, combined.size)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Clear pending state, e.g. after a decoder reset. */
|
||||
fun reset() {
|
||||
pending = FloatArray(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+16
-3
@@ -37,9 +37,11 @@ interface ICwDecoder {
|
||||
val decodedText: StateFlow<String>
|
||||
|
||||
/**
|
||||
* Permanent transcript of everything that has scrolled out of the live
|
||||
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
|
||||
* what the user reads back after a signal has passed.
|
||||
* Transcript of audio that has been archived, and will not be revised.
|
||||
*
|
||||
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
|
||||
* redecode, so a pane that concatenates it loses text the operator has already read -
|
||||
* which a paper log does not do. This is the flow such a pane must bind to.
|
||||
*/
|
||||
val historyText: StateFlow<String>
|
||||
|
||||
@@ -74,6 +76,17 @@ interface ICwDecoder {
|
||||
/** Non-null when the decoder cannot run, for example the model failed to load. */
|
||||
val errorMessage: StateFlow<String?>
|
||||
|
||||
/**
|
||||
* Decode whatever audio is still held in the pipeline into [historyText].
|
||||
*
|
||||
* Nothing reaches [historyText] until audio has been pushed out of the live window and
|
||||
* then accumulated into a full archive batch, so the last stretch of a session is always
|
||||
* still in flight when capture stops - and neither holding place drains on its own. That
|
||||
* stretch is the end of the transmission, the part with the call sign in it. Call on
|
||||
* pause and before [close].
|
||||
*/
|
||||
suspend fun flush()
|
||||
|
||||
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
|
||||
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-2
@@ -91,8 +91,7 @@ object QrzGridParser {
|
||||
*
|
||||
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
|
||||
* since the operator pastes whatever their browser handed them. Parsed by regex rather
|
||||
* than a JSON library because org.json is compileOnly here - it is supplied by Android at
|
||||
* runtime and absent from unit tests, so a JSON path could not be tested.
|
||||
* than a JSON library so it needs no extra dependency and stays testable as plain text.
|
||||
*/
|
||||
fun cookieHeader(raw: String): String {
|
||||
val text = raw.trim()
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
-1
@@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
interface ISensorsRepo {
|
||||
val sensorData: StateFlow<Pair<Float, Float>>
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||
|
||||
// The default used to be System.currentTimeMillis(), which Kotlin/Native does not have;
|
||||
// kotlin.time.Clock is the multiplatform equivalent.
|
||||
fun getMagDeclination(geoPos: GeoPos, time: Long = Clock.System.now().toEpochMilliseconds()): Float
|
||||
fun enableSensor()
|
||||
fun disableSensor()
|
||||
}
|
||||
File renamed without changes.
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
/** Minimal platform HTTP client used by Wavelog/QRZ features. Implemented per platform
|
||||
* (OkHttp on Android, NSURLSession on iOS). */
|
||||
interface IHttpClient {
|
||||
suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult
|
||||
suspend fun get(url: String, headers: Map<String, String>): HttpResult
|
||||
}
|
||||
|
||||
/** [code] is the HTTP status code, or 0 when the request could not be sent at all. */
|
||||
data class HttpResult(val code: Int, val body: String, val failure: String? = null)
|
||||
File renamed without changes.
+2
-4
@@ -17,11 +17,9 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
interface IRemoteSource {
|
||||
suspend fun getFileStream(uri: String): InputStream?
|
||||
suspend fun getNetworkStream(url: String): InputStream?
|
||||
suspend fun getFileBytes(uri: String): ByteArray?
|
||||
suspend fun getNetworkBytes(url: String): ByteArray?
|
||||
|
||||
/** Fetch AMSAT API catalog (JSON string; null on failure) */
|
||||
suspend fun getAmSatCatalog(): String?
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Dependency-free replacement for jvm/Android `java.lang.String.format`, required because
|
||||
* JVM formatting APIs do not exist on Kotlin/Native (iOS).
|
||||
*
|
||||
* Supported conversions: `%d` `%x` `%X` `%f` `%s` `%c` `%%`, plus the `0` flag, a width and
|
||||
* `.precision` (for `%f`). Anything else throws, so an unsupported pattern never silently
|
||||
* produces a wrong string.
|
||||
*
|
||||
* `%f` rounding matches java.lang.String.format (half-up on the exact double value) whenever
|
||||
* the scaled value fits in a Long (< 2^53), which covers every frequency/coordinate string
|
||||
* the app builds. Negative zero is preserved like the JVM does ("-0.000").
|
||||
*/
|
||||
fun formatString(pattern: String, vararg args: Any?): String {
|
||||
val out = StringBuilder(pattern.length + 16)
|
||||
var argIndex = 0
|
||||
var i = 0
|
||||
while (i < pattern.length) {
|
||||
val ch = pattern[i]
|
||||
if (ch != '%') {
|
||||
out.append(ch); i++; continue
|
||||
}
|
||||
i++
|
||||
if (i >= pattern.length) throw IllegalArgumentException("dangling '%' in pattern: $pattern")
|
||||
if (pattern[i] == '%') {
|
||||
out.append('%'); i++; continue
|
||||
}
|
||||
var zeroPadded = false
|
||||
if (pattern[i] == '0') {
|
||||
zeroPadded = true; i++
|
||||
}
|
||||
var width = 0
|
||||
while (i < pattern.length && pattern[i].isDigit()) {
|
||||
width = width * 10 + (pattern[i] - '0'); i++
|
||||
}
|
||||
// java.lang.String.format throws MissingFormatWidthException for this; an illegal
|
||||
// pattern must not quietly format one way on Android and another way on iOS.
|
||||
if (zeroPadded && width == 0) {
|
||||
throw IllegalArgumentException("'0' flag without a width in pattern: $pattern")
|
||||
}
|
||||
var precision = -1 // java.lang.String.format defaults %f to 6 decimals
|
||||
if (i < pattern.length && pattern[i] == '.') {
|
||||
i++
|
||||
precision = 0 // the digits accumulate from zero; -1 means "not specified"
|
||||
while (i < pattern.length && pattern[i].isDigit()) {
|
||||
precision = precision * 10 + (pattern[i] - '0'); i++
|
||||
}
|
||||
}
|
||||
if (i >= pattern.length) throw IllegalArgumentException("truncated conversion in pattern: $pattern")
|
||||
val conversion = pattern[i]
|
||||
i++
|
||||
val arg = if (argIndex < args.size) args[argIndex++] else null
|
||||
val rendered = when (conversion) {
|
||||
'd' -> longArg(arg, conversion, pattern).toString()
|
||||
'x' -> longArg(arg, conversion, pattern).toString(16)
|
||||
'X' -> longArg(arg, conversion, pattern).toString(16).uppercase()
|
||||
'f' -> formatFixed(doubleArg(arg, pattern), if (precision < 0) 6 else precision, pattern)
|
||||
's' -> arg?.toString() ?: "null"
|
||||
'c' -> when (arg) {
|
||||
is Char -> arg.toString()
|
||||
is Int -> arg.toChar().toString()
|
||||
else -> throw IllegalArgumentException("unsupported %c argument: $arg in pattern: $pattern")
|
||||
}
|
||||
else -> throw IllegalArgumentException("unsupported conversion %$conversion in pattern: $pattern")
|
||||
}
|
||||
if (width <= rendered.length) {
|
||||
out.append(rendered)
|
||||
} else if (zeroPadded && !rendered.startsWith("-") && !rendered.startsWith("+")) {
|
||||
repeat(width - rendered.length) { out.append('0') }
|
||||
out.append(rendered)
|
||||
} else if (zeroPadded) {
|
||||
out.append(rendered[0])
|
||||
repeat(width - rendered.length) { out.append('0') }
|
||||
out.append(rendered.substring(1))
|
||||
} else {
|
||||
repeat(width - rendered.length) { out.append(' ') }
|
||||
out.append(rendered)
|
||||
}
|
||||
}
|
||||
// Extra arguments are ignored, exactly like java.lang.String.format: call sites already
|
||||
// pass what they pass and a port should not turn a latent extra argument into a crash.
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
/** `"%.3f".format(1.2345)` -> `"1.235"` */
|
||||
fun String.format(vararg args: Any?): String = formatString(this, *args)
|
||||
|
||||
private val POWERS_OF_TEN = longArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
|
||||
private const val MAX_LONG_DIGITS = 18 // the most decimal digits that still fit in a Long
|
||||
|
||||
private fun longArg(arg: Any?, conversion: Char, pattern: String): Long = when (arg) {
|
||||
is Int -> arg.toLong()
|
||||
is Long -> arg
|
||||
is Short -> arg.toLong()
|
||||
is Byte -> arg.toLong()
|
||||
else -> throw IllegalArgumentException("unsupported %$conversion argument: $arg in pattern: $pattern")
|
||||
}
|
||||
|
||||
private fun doubleArg(arg: Any?, pattern: String): Double = when (arg) {
|
||||
is Double -> arg
|
||||
is Float -> arg.toDouble()
|
||||
is Int -> arg.toDouble()
|
||||
is Long -> arg.toDouble()
|
||||
else -> throw IllegalArgumentException("unsupported %f argument: $arg in pattern: $pattern")
|
||||
}
|
||||
|
||||
private fun formatFixed(value: Double, precision: Int, pattern: String): String {
|
||||
if (precision !in 0..8) throw IllegalArgumentException("precision $precision too large in pattern: $pattern")
|
||||
if (value.isNaN()) return "NaN"
|
||||
if (value.isInfinite()) return if (value > 0.0) "Infinity" else "-Infinity"
|
||||
val negative = value < 0.0 || (value == 0.0 && 1.0 / value < 0.0)
|
||||
val rounded = roundHalfUp(abs(value), precision, value, pattern)
|
||||
val power = POWERS_OF_TEN[precision]
|
||||
val integerPart = rounded / power
|
||||
val fractionPart = rounded % power
|
||||
val result = StringBuilder()
|
||||
if (negative) result.append('-')
|
||||
result.append(integerPart)
|
||||
if (precision > 0) {
|
||||
result.append('.')
|
||||
result.append(fractionPart.toString().padStart(precision, '0'))
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds to [precision] decimals the way java.lang.String.format does: it rounds the shortest
|
||||
* decimal representation of the double half-up, not its binary value. `"%.3f"` of 0.5005 is
|
||||
* therefore `"0.501"`, even though the double holds 0.50049999999999994493.
|
||||
*
|
||||
* Scaling in binary first - floor(magnitude * 10^precision + 0.5) - loses exactly that and printed
|
||||
* "0.500", so the digits come from the decimal representation and are rounded by integer
|
||||
* arithmetic instead. Returns the value scaled by 10^precision.
|
||||
*/
|
||||
private fun roundHalfUp(magnitude: Double, precision: Int, value: Double, pattern: String): Long {
|
||||
val text = magnitude.toString() // shortest representation that still round-trips
|
||||
val exponentIndex = text.indexOfFirst { it == 'E' || it == 'e' }
|
||||
val mantissa = if (exponentIndex < 0) text else text.substring(0, exponentIndex)
|
||||
val exponent = if (exponentIndex < 0) 0 else text.substring(exponentIndex + 1).toInt()
|
||||
val pointIndex = mantissa.indexOf('.')
|
||||
val integerDigits = if (pointIndex < 0) mantissa else mantissa.substring(0, pointIndex)
|
||||
val fractionDigits = if (pointIndex < 0) "" else mantissa.substring(pointIndex + 1)
|
||||
val digits = integerDigits + fractionDigits
|
||||
// magnitude == digits * 10^scale, so digits * 10^(scale + precision) is the scaled value.
|
||||
val shift = exponent - fractionDigits.length + precision
|
||||
val unscaled = digits.toLong()
|
||||
if (shift >= 0) {
|
||||
if (digits.length + shift > MAX_LONG_DIGITS) throw ValueTooLarge(value, precision, pattern)
|
||||
var scaled = unscaled
|
||||
repeat(shift) { scaled *= 10 }
|
||||
return scaled
|
||||
}
|
||||
// Below half of the last printed digit everything rounds to zero, and 10^divisorDigits would
|
||||
// no longer fit in a Long, so stop before building it.
|
||||
val divisorDigits = -shift
|
||||
if (divisorDigits > MAX_LONG_DIGITS) return 0L
|
||||
var divisor = 1L
|
||||
repeat(divisorDigits) { divisor *= 10 }
|
||||
val quotient = unscaled / divisor
|
||||
val remainder = unscaled % divisor
|
||||
return if (2 * remainder >= divisor) quotient + 1 else quotient
|
||||
}
|
||||
|
||||
// Values this large are never produced by the app; avoid silently wrong digits.
|
||||
private class ValueTooLarge(value: Double, precision: Int, pattern: String) :
|
||||
IllegalArgumentException("value $value too large for %.$precision" + "f in pattern: $pattern")
|
||||
+6
-9
@@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import java.io.InputStream
|
||||
import kotlin.math.pow
|
||||
|
||||
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
@@ -34,22 +33,20 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
suspend fun parseCSV(data: String): List<OrbitalData> = withContext(dispatcher) {
|
||||
data.lineSequence().drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().readLines()
|
||||
suspend fun parseTLE(data: String): List<OrbitalData> = withContext(dispatcher) {
|
||||
data.lineSequence().toList()
|
||||
.chunked(3)
|
||||
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
|
||||
.mapNotNull { parseTLE(it) }
|
||||
}
|
||||
|
||||
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
||||
suspend fun parseJSON(data: String): List<SatRadio> = withContext(dispatcher) {
|
||||
runCatching {
|
||||
val root = json.parseToJsonElement(stream.bufferedReader().readText())
|
||||
val root = json.parseToJsonElement(data)
|
||||
(root as? JsonArray)?.mapNotNull { element ->
|
||||
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
|
||||
.onFailure { println("JSON parsing exception: $it") }
|
||||
+2
-3
@@ -11,7 +11,6 @@ package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||
@@ -110,10 +109,10 @@ object DopplerFrequencyCalculator {
|
||||
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||
if (!isLinearTransponder(transponder)) return false
|
||||
|
||||
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||
val info = transponder.info.lowercase()
|
||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase(Locale.ENGLISH)
|
||||
.lowercase()
|
||||
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||
info.contains("xponder") || info.contains("xpdr")
|
||||
+4
-7
@@ -17,16 +17,13 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun Long.toTimerString(): String {
|
||||
val millis = coerceAtLeast(0L)
|
||||
val format = "%02d:%02d:%02d"
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(millis)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
|
||||
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
|
||||
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
|
||||
val hours = millis / 3_600_000L
|
||||
val minutes = millis / 60_000L % 60
|
||||
val seconds = millis / 1_000L % 60
|
||||
return formatString(format, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
fun Float.round(decimals: Int): Float {
|
||||
File renamed without changes.
File renamed without changes.
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.utility
|
||||
|
||||
/**
|
||||
* Runs [block] while holding [lock]'s monitor, the way kotlin.jvm.Synchronized used to hold it
|
||||
* before core:domain became a multiplatform module.
|
||||
*
|
||||
* The annotation survives in common code as an optional expectation, but the stdlib deprecated it
|
||||
* there in Kotlin 1.8 and made it an error in 2.1: "Synchronizing methods on a class instance is
|
||||
* not supported on platforms other than JVM." The monitor therefore moves behind a platform
|
||||
* actual, which keeps the JVM semantics exactly and lets the iOS side say what it does instead.
|
||||
*/
|
||||
internal expect fun <T> synchronizedOn(lock: Any, block: () -> T): T
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
// kotlin.jvm.Volatile 是 common 里 2.1 起的编译错误;kotlin.concurrent.Volatile 才是多平台的那个,
|
||||
// 且在 JVM 与 Kotlin/Native 上都生效。
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
object LotwSatellites {
|
||||
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
|
||||
|
||||
File renamed without changes.
+121
-105
@@ -10,15 +10,17 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.source.HttpResult
|
||||
import com.rtbishop.look4sat.core.domain.source.IHttpClient
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/** Station info (GET /api/v2/station/{id} result) */
|
||||
data class WavelogStation(
|
||||
@@ -35,7 +37,19 @@ sealed class WavelogResult {
|
||||
|
||||
object WaveLogApi {
|
||||
|
||||
private const val TIMEOUT_MS = 15000
|
||||
/** Milliseconds in a day; date fields are derived from the QSO timestamp without Calendar. */
|
||||
private const val MS_PER_DAY = 86_400_000L
|
||||
|
||||
/**
|
||||
* Platform HTTP client for every request below. Handed over by the DI container because an
|
||||
* object has no constructor for it, and a shared object has no platform socket API to use
|
||||
* on its own.
|
||||
*/
|
||||
private var httpClient: IHttpClient? = null
|
||||
|
||||
fun installHttpClient(client: IHttpClient) {
|
||||
httpClient = client
|
||||
}
|
||||
|
||||
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
|
||||
fun normalizeUrl(raw: String): String {
|
||||
@@ -47,54 +61,54 @@ object WaveLogApi {
|
||||
}
|
||||
|
||||
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
// v2: GET /index.php/api/v2/token
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||
val v2 = httpRequest("$base/index.php/api/v2/token", apiKey, null)
|
||||
if (v2.code in 200..299) return WavelogResult.Success("连接成功 (API v2)")
|
||||
|
||||
// v1: POST /index.php/api/get_contacts_adif (key in body)
|
||||
if (stationId.isNotBlank()) {
|
||||
val body = JSONObject().apply {
|
||||
val body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", apiKey, body)
|
||||
if (v1.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
}
|
||||
// v1 attempt without index.php
|
||||
val body = JSONObject().apply {
|
||||
val body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_id", stationId)
|
||||
put("fetchfromid", 0)
|
||||
}.toString()
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
val v1b = httpRequest("$base/api/get_contacts_adif", apiKey, body)
|
||||
if (v1b.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
|
||||
if (v1b.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||
|
||||
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||
return WavelogResult.Failure("连接失败: v2 HTTP ${v2.code}, v1 HTTP ${v1b.code} — 请确认服务器地址/密钥正确")
|
||||
}
|
||||
|
||||
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", apiKey, null)
|
||||
if (code in 200..299) {
|
||||
return@withContext try {
|
||||
val obj = JSONObject(resp)
|
||||
val data = obj.optJSONObject("data") ?: obj
|
||||
return try {
|
||||
val obj = Json.parseToJsonElement(resp).jsonObject
|
||||
val data = obj["data"] as? JsonObject ?: obj
|
||||
val station = WavelogStation(
|
||||
id = data.optInt("id"),
|
||||
name = data.optString("name"),
|
||||
callsign = data.optString("callsign"),
|
||||
gridsquare = data.optString("gridsquare")
|
||||
id = data["id"]?.jsonPrimitive?.intOrNull ?: 0,
|
||||
name = data["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
callsign = data["callsign"]?.jsonPrimitive?.contentOrNull.orEmpty(),
|
||||
gridsquare = data["gridsquare"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
)
|
||||
WavelogResult.Success(JSONObject().apply {
|
||||
WavelogResult.Success(buildJsonObject {
|
||||
put("id", station.id); put("name", station.name)
|
||||
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||
}.toString())
|
||||
@@ -103,7 +117,7 @@ object WaveLogApi {
|
||||
}
|
||||
}
|
||||
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
|
||||
WavelogResult.Success("")
|
||||
return WavelogResult.Success("")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +196,7 @@ object WaveLogApi {
|
||||
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
|
||||
?.let { return it }
|
||||
}
|
||||
return trimmed.uppercase(Locale.ENGLISH)
|
||||
return trimmed.uppercase()
|
||||
}
|
||||
|
||||
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
|
||||
@@ -206,7 +220,7 @@ object WaveLogApi {
|
||||
return parts.filter { it.isNotEmpty() }.distinct()
|
||||
}
|
||||
|
||||
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH)
|
||||
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase()
|
||||
|
||||
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||
suspend fun postQso(
|
||||
@@ -215,23 +229,23 @@ object WaveLogApi {
|
||||
stationProfileId: String,
|
||||
qso: WavelogQso,
|
||||
gridsquare: String
|
||||
): WavelogResult = withContext(Dispatchers.IO) {
|
||||
): WavelogResult {
|
||||
val base = normalizeUrl(url)
|
||||
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
|
||||
|
||||
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
|
||||
|
||||
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
val v2Body = JSONObject().apply {
|
||||
val v2Body = buildJsonObject {
|
||||
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||
put("call", qso.call)
|
||||
put("band", bandFromHz(qso.freqTxHz))
|
||||
put("mode", qso.mode)
|
||||
put("qso_date", utcDate(qso.timeUtcMs))
|
||||
put("time_on", utcTime(qso.timeUtcMs))
|
||||
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||
put("freq", formatString("%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||
put("freq_rx", formatString("%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||
put("gridsquare", gridsquare)
|
||||
put("rst_sent", "59")
|
||||
put("rst_rcvd", "59")
|
||||
@@ -241,11 +255,11 @@ object WaveLogApi {
|
||||
// The body decides, not the status code: Wavelog validates after responding, so a rejected
|
||||
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
|
||||
// and dropped it from the queue.
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", apiKey, v2Body.toString())
|
||||
val v2Verdict = WavelogResponse.verdict(code, resp)
|
||||
when (v2Verdict) {
|
||||
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
|
||||
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v2")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
|
||||
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
|
||||
// from uploading at all. The v1 attempt below is the one that can speak for them.
|
||||
@@ -253,29 +267,29 @@ object WaveLogApi {
|
||||
}
|
||||
|
||||
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||
val v1Body = JSONObject().apply {
|
||||
val v1Body = buildJsonObject {
|
||||
put("key", apiKey)
|
||||
put("station_profile_id", stationProfileId)
|
||||
put("type", "adif")
|
||||
put("string", toAdif(qso, gridsquare, satName))
|
||||
}
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||
val (code1, resp1) = httpRequest("$base/index.php/api/qso", apiKey, v1Body.toString())
|
||||
val v1Verdict = WavelogResponse.verdict(code1, resp1)
|
||||
when (v1Verdict) {
|
||||
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
// Also falls through: a server with different rewrite rules answers this path with a
|
||||
// 404 page, which is a rejection but says nothing about whether the QSO can be stored.
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
// v1 without index.php, for a server whose rewrite rules differ
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||
val (code1b, resp1b) = httpRequest("$base/api/qso", apiKey, v1Body.toString())
|
||||
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
|
||||
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
|
||||
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
|
||||
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
|
||||
is WavelogResponse.Verdict.Rejected ->
|
||||
return@withContext WavelogResult.Failure(verdict.reason)
|
||||
return WavelogResult.Failure(verdict.reason)
|
||||
is WavelogResponse.Verdict.Unreadable -> Unit
|
||||
}
|
||||
|
||||
@@ -288,7 +302,7 @@ object WaveLogApi {
|
||||
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
|
||||
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
|
||||
).filter { it.isNotBlank() }
|
||||
WavelogResult.Failure(
|
||||
return WavelogResult.Failure(
|
||||
reasons.firstOrNull()
|
||||
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
|
||||
" - " + shortError(resp1.ifBlank { resp1b }))
|
||||
@@ -298,7 +312,7 @@ object WaveLogApi {
|
||||
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||
fun field(name: String, value: String): String {
|
||||
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||
val bytes = value.encodeToByteArray().size
|
||||
return "<$name:$bytes>$value"
|
||||
}
|
||||
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
|
||||
@@ -306,9 +320,9 @@ object WaveLogApi {
|
||||
append(field("call", qso.call))
|
||||
append(field("band", bandFromHz(qso.freqTxHz)))
|
||||
append(field("mode", qso.mode))
|
||||
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
append(field("freq", formatString("%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||
if (qso.freqRxHz > 0) {
|
||||
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
append(field("freq_rx", formatString("%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||
}
|
||||
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||
@@ -328,76 +342,78 @@ object WaveLogApi {
|
||||
}
|
||||
}
|
||||
|
||||
/** Generic HTTP request (returns code + body) */
|
||||
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||
return try {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
conn.requestMethod = method
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||
/**
|
||||
* POST [jsonBody] when one is given, GET otherwise. A request that could not be sent at all
|
||||
* comes back as code 0, so callers only have to look at the code.
|
||||
*/
|
||||
private suspend fun httpRequest(url: String, apiKey: String, jsonBody: String?): HttpResult {
|
||||
val client = httpClient ?: error("WaveLogApi has no HTTP client installed")
|
||||
val headers = buildMap {
|
||||
if (apiKey.isNotBlank()) put("Authorization", "Bearer $apiKey")
|
||||
if (jsonBody != null) {
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||
put("Content-Type", "application/json")
|
||||
put("Accept", "application/json")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val body = if (stream != null) {
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||
} else ""
|
||||
code to body
|
||||
} catch (e: Exception) {
|
||||
-1 to (e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
val result = if (jsonBody != null) client.post(url, headers, jsonBody) else client.get(url, headers)
|
||||
// A request that never left the phone used to report the exception text where the body
|
||||
// goes, which is what the failure messages below read; keep it there.
|
||||
val failure = result.failure
|
||||
return if (result.body.isEmpty() && failure != null) result.copy(body = failure) else result
|
||||
}
|
||||
|
||||
private fun shortError(body: String): String {
|
||||
if (body.startsWith("<")) return body.take(80) // HTML error page
|
||||
return try {
|
||||
val obj = JSONObject(body)
|
||||
val err = obj.optJSONObject("error")
|
||||
err?.optString("message")?.ifBlank { body.take(120) }
|
||||
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||
val obj = Json.parseToJsonElement(body).jsonObject
|
||||
val err = obj["error"] as? JsonObject
|
||||
if (err != null) {
|
||||
err["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) }
|
||||
} else {
|
||||
obj["reason"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
.ifBlank { obj["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) } }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
/** UTC civil time of a Unix millisecond stamp; the JVM Calendar is not multiplatform. */
|
||||
private data class UtcFields(val year: Int, val month: Int, val day: Int, val hour: Int, val minute: Int, val second: Int)
|
||||
|
||||
private fun utcFieldsOf(ms: Long): UtcFields {
|
||||
val days = ms.floorDiv(MS_PER_DAY)
|
||||
val millisOfDay = ms.mod(MS_PER_DAY)
|
||||
val shifted = days + 719_468
|
||||
val era = shifted.floorDiv(146_097)
|
||||
val dayOfEra = shifted - era * 146_097
|
||||
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
|
||||
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
|
||||
val monthPart = (5 * dayOfYear + 2) / 153
|
||||
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
|
||||
val month = (if (monthPart < 10) monthPart + 3 else monthPart - 9).toInt()
|
||||
val year = yearOfEra.toInt() + era.toInt() * 400 + (if (month <= 2) 1 else 0)
|
||||
val secondOfDay = (millisOfDay / 1000).toInt()
|
||||
return UtcFields(year, month, day, secondOfDay / 3_600, secondOfDay / 60 % 60, secondOfDay % 60)
|
||||
}
|
||||
|
||||
private fun utcDate(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d-%02d-%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%04d-%02d-%02d", utc.year, utc.month, utc.day)
|
||||
}
|
||||
|
||||
private fun utcTime(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d:%02d:%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%02d:%02d:%02d", utc.hour, utc.minute, utc.second)
|
||||
}
|
||||
|
||||
private fun utcDateCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%04d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%04d%02d%02d", utc.year, utc.month, utc.day)
|
||||
}
|
||||
|
||||
private fun utcTimeCompact(ms: Long): String {
|
||||
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||
cal.timeInMillis = ms
|
||||
return "%02d%02d%02d".format(
|
||||
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||
cal.get(java.util.Calendar.SECOND)
|
||||
)
|
||||
val utc = utcFieldsOf(ms)
|
||||
return formatString("%02d%02d%02d", utc.hour, utc.minute, utc.second)
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
|
||||
*
|
||||
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
|
||||
* implemented with SharedPreferences in core/data.
|
||||
* Queue capped at 500 entries (oldest dropped beyond that).
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.synchronizedOn
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/** Storage abstraction (SharedPreferences impl lives in core/data) */
|
||||
interface IWavelogQueueStore {
|
||||
fun load(): String
|
||||
fun save(json: String)
|
||||
}
|
||||
|
||||
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
|
||||
data class WavelogQso(
|
||||
val id: String, // 本地唯一 id(UUID)
|
||||
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||
val call: String,
|
||||
val mode: String,
|
||||
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||
val freqRxHz: Long, // 下行
|
||||
val satName: String,
|
||||
/**
|
||||
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
|
||||
*
|
||||
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
|
||||
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
|
||||
* this field existed; those fall back to resolving from the name.
|
||||
*/
|
||||
val catnum: Int = 0,
|
||||
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||
)
|
||||
|
||||
/**
|
||||
* Every mutator is a read-modify-write over the single stored blob and serialises on a private
|
||||
* monitor, so the Compose thread and the upload coroutine cannot drop each other's entries. The
|
||||
* monitor is a platform actual (utility/SynchronizedOn.kt) because kotlin.jvm.Synchronized is an
|
||||
* error in common code since Kotlin 2.1.
|
||||
*/
|
||||
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||
|
||||
private val key = "wavelog_queue"
|
||||
private val lock = Any()
|
||||
|
||||
fun all(): List<WavelogQso> {
|
||||
val raw = store.load()
|
||||
return try {
|
||||
Json.parseToJsonElement(raw).jsonArray.map { element ->
|
||||
val o = element.jsonObject
|
||||
WavelogQso(
|
||||
id = o.getValue("id").jsonPrimitive.content,
|
||||
timeUtcMs = o["timeUtcMs"].readLong(),
|
||||
call = o["call"].readString(),
|
||||
mode = o["mode"].readString(),
|
||||
freqTxHz = o["freqTxHz"].readLong(),
|
||||
freqRxHz = o["freqRxHz"].readLong(),
|
||||
satName = o["satName"].readString(),
|
||||
catnum = o["catnum"].readInt(),
|
||||
sessionId = o["sessionId"].readString(),
|
||||
gridsquare = o["gridsquare"].readString(),
|
||||
uploaded = o["uploaded"].readBoolean()
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun add(qso: WavelogQso) {
|
||||
synchronizedOn(lock) {
|
||||
val list = all().toMutableList()
|
||||
list.add(0, qso) // 最新在前
|
||||
if (list.size > 500) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
synchronizedOn(lock) { save(all().filter { it.id != id }) }
|
||||
}
|
||||
|
||||
fun removeAll(ids: Set<String>) {
|
||||
synchronizedOn(lock) { save(all().filter { it.id !in ids }) }
|
||||
}
|
||||
|
||||
/** Mark as uploaded (kept in the queue; checkmark in the table) */
|
||||
fun markUploaded(id: String) {
|
||||
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(uploaded = true) else it }) }
|
||||
}
|
||||
|
||||
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||
fun updateGridsquare(id: String, grid: String) {
|
||||
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) }
|
||||
}
|
||||
|
||||
/** Remove all uploaded entries (optional; keeps the queue lean) */
|
||||
fun removeUploaded() {
|
||||
synchronizedOn(lock) { save(all().filter { !it.uploaded }) }
|
||||
}
|
||||
|
||||
private fun save(list: List<WavelogQso>) {
|
||||
val arr = buildJsonArray {
|
||||
list.forEach { q ->
|
||||
add(buildJsonObject {
|
||||
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||
put("catnum", q.catnum)
|
||||
put("sessionId", q.sessionId)
|
||||
put("gridsquare", q.gridsquare)
|
||||
put("uploaded", q.uploaded)
|
||||
})
|
||||
}
|
||||
}
|
||||
store.save(arr.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* org.json's opt* readers never threw: a decimal ("1234.0", or the string "1234.0") was coerced to
|
||||
* a whole number, a missing or mismatched field fell back to the default, and a field holding an
|
||||
* object was stringified. kotlinx answers null for the first two - which turned a readable
|
||||
* timestamp into 0L, i.e. a QSO uploaded as 1970 - and throws for the third, which took the whole
|
||||
* list down with it. These keep the old behaviour, except that a JSON null becomes the empty
|
||||
* string or 0 instead of the literal "null".
|
||||
*/
|
||||
private fun JsonElement?.readLong(default: Long = 0L): Long {
|
||||
val primitive = this as? JsonPrimitive ?: return default
|
||||
primitive.longOrNull?.let { return it }
|
||||
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong() ?: default
|
||||
}
|
||||
|
||||
private fun JsonElement?.readInt(default: Int = 0): Int {
|
||||
val primitive = this as? JsonPrimitive ?: return default
|
||||
primitive.intOrNull?.let { return it }
|
||||
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toInt() ?: default
|
||||
}
|
||||
|
||||
private fun JsonElement?.readString(default: String = ""): String =
|
||||
(this as? JsonPrimitive)?.contentOrNull ?: default
|
||||
|
||||
private fun JsonElement?.readBoolean(default: Boolean = false): Boolean =
|
||||
(this as? JsonPrimitive)?.booleanOrNull ?: default
|
||||
+2
-2
@@ -25,8 +25,8 @@ package com.rtbishop.look4sat.core.domain.wavelog
|
||||
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as
|
||||
* the APRS reporter claiming a send succeeded when nothing had left the phone.
|
||||
*
|
||||
* Parsed as text rather than with JSONObject on purpose: org.json is compileOnly in core:domain,
|
||||
* so a JVM unit test gets the stub and every assertion against it would be vacuous.
|
||||
* Parsed as text rather than through a JSON library on purpose: what Wavelog answers is sparse and
|
||||
* freely worded, and matching it as text keeps the verdict testable without a parser.
|
||||
*/
|
||||
object WavelogResponse {
|
||||
|
||||
+6
-2
@@ -9,7 +9,10 @@
|
||||
package com.rtbishop.look4sat.core.domain.wavelog
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
sealed class UploadOutcome {
|
||||
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||
@@ -99,7 +102,8 @@ class WavelogUploader(
|
||||
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||
if (result is WavelogResult.Success) {
|
||||
return try {
|
||||
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||
Json.parseToJsonElement(result.message).jsonObject["gridsquare"]
|
||||
?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: cachedStationGrid
|
||||
} catch (_: Exception) { cachedStationGrid }
|
||||
}
|
||||
+113
-109
@@ -19,104 +19,108 @@ package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@ExperimentalCoroutinesApi
|
||||
class DataParserTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(testDispatcher)
|
||||
private val validCSVStream = """
|
||||
private val validCSV = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
ISS (ZARYA),1998-067A,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidCSVStream = """
|
||||
""".trimIndent()
|
||||
private val invalidCSV = """
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
""".trimIndent().byteInputStream()
|
||||
private val validTLEStream = """
|
||||
""".trimIndent()
|
||||
private val validTLE = """
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999
|
||||
2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidTLEStream = """
|
||||
""".trimIndent()
|
||||
private val invalidTLE = """
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
private val validJSONStream = """
|
||||
""".trimIndent()
|
||||
private val validJSON = """
|
||||
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidJSONStream = """
|
||||
""".trimIndent()
|
||||
private val invalidJSON = """
|
||||
[{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
val parsedList = dataParser.parseCSV(validCSV)
|
||||
assertTrue(parsedList.size == 2)
|
||||
assertTrue(parsedList[0].epoch == 21320.51955234)
|
||||
assertTrue(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.bstar == 0.31985E-4)
|
||||
assert(sat.ndot == 0.1288E-4)
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
assertTrue(sat.name == "ISS (ZARYA)")
|
||||
assertTrue(sat.catnum == 25544)
|
||||
assertTrue(sat.meanmo == 15.48582035)
|
||||
assertTrue(sat.eccn == 0.0004694)
|
||||
assertTrue(sat.incl == 51.6447)
|
||||
assertTrue(sat.raan == 309.4881)
|
||||
assertTrue(sat.argper == 203.6966)
|
||||
assertTrue(sat.meanan == 299.8876)
|
||||
assertTrue(sat.bstar == 0.31985E-4)
|
||||
assertTrue(sat.ndot == 0.1288E-4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
// ISS is healthy, should not be decayed even years later
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||
val csvStream = """
|
||||
val csv = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseCSV(csv)[0]
|
||||
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||
assertTrue(sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||
assertTrue(dataParser.parseCSV(invalidCSV).isEmpty())
|
||||
}
|
||||
|
||||
private fun csvWithEpoch(epoch: String) = """
|
||||
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
@@ -125,41 +129,41 @@ class DataParserTest {
|
||||
// notation below 1e-3, so the leading significant digit was truncated.
|
||||
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
|
||||
// roughly 26 years off, with no exception to reveal it.
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
|
||||
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
|
||||
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
|
||||
assertEquals(25001.0, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, 1e-9)
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
|
||||
assertEquals(25001.5, sat.epoch, absoluteTolerance = 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
|
||||
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
|
||||
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
|
||||
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
|
||||
assertTrue(sat.epoch > 25001.999, "expected almost a full day, got ${sat.epoch}")
|
||||
assertTrue(sat.epoch < 25002.0, "day fraction must not roll into the next day, got ${sat.epoch}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(parsedList.size == 2)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
assert(parsedList[1].epoch == 24069.23963816)
|
||||
val parsedList = dataParser.parseTLE(validTLE)
|
||||
assertTrue(parsedList.size == 2)
|
||||
assertTrue(parsedList[0].epoch == 21320.51955234)
|
||||
assertTrue(parsedList[1].epoch == 24069.23963816)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -168,17 +172,17 @@ class DataParserTest {
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.eccn == 0.0004694)
|
||||
assert(sat.incl == 51.6447)
|
||||
assert(sat.raan == 309.4881)
|
||||
assert(sat.argper == 203.6966)
|
||||
assert(sat.meanan == 299.8876)
|
||||
assert(sat.ndot == 0.00001288)
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseTLE(tleStream)[0]
|
||||
assertTrue(sat.name == "ISS (ZARYA)")
|
||||
assertTrue(sat.catnum == 25544)
|
||||
assertTrue(sat.meanmo == 15.48582035)
|
||||
assertTrue(sat.eccn == 0.0004694)
|
||||
assertTrue(sat.incl == 51.6447)
|
||||
assertTrue(sat.raan == 309.4881)
|
||||
assertTrue(sat.argper == 203.6966)
|
||||
assertTrue(sat.meanan == 299.8876)
|
||||
assertTrue(sat.ndot == 0.00001288)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -187,66 +191,66 @@ class DataParserTest {
|
||||
ISS (ZARYA)
|
||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||
""".trimIndent()
|
||||
val sat = dataParser.parseTLE(tleStream)[0]
|
||||
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||
assertTrue(dataParser.parseTLE(invalidTLE).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||
assertTrue(dataParser.parseJSON(validJSON)[0].downlinkLow == 136658500L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||
assert(radio.info == "Upper side band (drifting)")
|
||||
assert(radio.isAlive)
|
||||
assert(radio.downlinkLow == 136658500L)
|
||||
assert(radio.downlinkHigh == 136700000L)
|
||||
assert(radio.downlinkMode == "USB")
|
||||
assert(radio.uplinkLow == 145900000L)
|
||||
assert(radio.uplinkHigh == 146000000L)
|
||||
assert(radio.uplinkMode == "FM")
|
||||
assert(radio.isInverted)
|
||||
assert(radio.catnum == 965)
|
||||
""".trimIndent()
|
||||
val radio = dataParser.parseJSON(jsonStream)[0]
|
||||
assertTrue(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||
assertTrue(radio.info == "Upper side band (drifting)")
|
||||
assertTrue(radio.isAlive)
|
||||
assertTrue(radio.downlinkLow == 136658500L)
|
||||
assertTrue(radio.downlinkHigh == 136700000L)
|
||||
assertTrue(radio.downlinkMode == "USB")
|
||||
assertTrue(radio.uplinkLow == 145900000L)
|
||||
assertTrue(radio.uplinkHigh == 146000000L)
|
||||
assertTrue(radio.uplinkMode == "FM")
|
||||
assertTrue(radio.isInverted)
|
||||
assertTrue(radio.catnum == 965)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||
val jsonStream = """
|
||||
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||
""".trimIndent().byteInputStream()
|
||||
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||
assert(radio.uuid == "abc123")
|
||||
assert(!radio.isAlive)
|
||||
assert(radio.downlinkLow == 145800000L)
|
||||
assert(radio.downlinkHigh == null)
|
||||
assert(radio.downlinkMode == null)
|
||||
assert(radio.uplinkLow == null)
|
||||
assert(radio.uplinkHigh == null)
|
||||
assert(radio.uplinkMode == null)
|
||||
assert(!radio.isInverted)
|
||||
assert(radio.catnum == 12345)
|
||||
""".trimIndent()
|
||||
val radio = dataParser.parseJSON(jsonStream)[0]
|
||||
assertTrue(radio.uuid == "abc123")
|
||||
assertTrue(!radio.isAlive)
|
||||
assertTrue(radio.downlinkLow == 145800000L)
|
||||
assertTrue(radio.downlinkHigh == null)
|
||||
assertTrue(radio.downlinkMode == null)
|
||||
assertTrue(radio.uplinkLow == null)
|
||||
assertTrue(radio.uplinkHigh == null)
|
||||
assertTrue(radio.uplinkMode == null)
|
||||
assertTrue(!radio.isInverted)
|
||||
assertTrue(radio.catnum == 12345)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||
assertTrue(dataParser.parseJSON(invalidJSON).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
|
||||
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
||||
assertTrue(dataParser.parseCSV(validCSV) == dataParser.parseTLE(validTLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -254,38 +258,38 @@ class DataParserTest {
|
||||
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||
val results = years.map { dataParser.isLeapYear(it) }
|
||||
assert(results == expected)
|
||||
assertTrue(results == expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for January 1st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||
assertTrue(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||
assertTrue(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||
assertTrue(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||
assertTrue(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for December 31st`() {
|
||||
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||
assertTrue(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||
assertTrue(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDayOfYear returns correct day for November 16th`() {
|
||||
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
assertTrue(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `check APRS passcode calculation`() {
|
||||
assert("M7LNB".aprsPasscode() == 12443)
|
||||
assert("N0CALL".aprsPasscode() == 13023)
|
||||
assertTrue("M7LNB".aprsPasscode() == 12443)
|
||||
assertTrue("N0CALL".aprsPasscode() == 13023)
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,12 +3,12 @@ package com.rtbishop.look4sat.core.domain
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DopplerFrequencyCalculatorTest {
|
||||
|
||||
@@ -268,6 +268,6 @@ class DopplerFrequencyCalculatorTest {
|
||||
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
|
||||
assertNotNull(roundTripDownlink)
|
||||
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
|
||||
assertTrue("Round-trip error too large: $error", error < 10000)
|
||||
assertTrue(error < 10000, "Round-trip error too large: $error")
|
||||
}
|
||||
}
|
||||
+52
-47
@@ -21,59 +21,60 @@ import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class QthConverterTest {
|
||||
|
||||
@Test
|
||||
fun `Given valid QTH returns correct POS`() {
|
||||
var result = qthToPosition("io91VL39FX")
|
||||
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||
assertTrue(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
||||
result = qthToPosition("gf15vc")
|
||||
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||
assertTrue(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
||||
// 8-char locators: finer 30" x 15" cell center
|
||||
result = qthToPosition("io91vl47")
|
||||
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||
assertTrue(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
||||
result = qthToPosition("jn58td25")
|
||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
assertTrue(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid QTH returns null`() {
|
||||
assert(qthToPosition("ZZ00zz") == null)
|
||||
assert(qthToPosition("JN58") == null)
|
||||
assert(qthToPosition("io9") == null)
|
||||
assert(qthToPosition("IO91VL7") == null)
|
||||
assert(qthToPosition("IO91VL4X") == null)
|
||||
assertTrue(qthToPosition("ZZ00zz") == null)
|
||||
assertTrue(qthToPosition("JN58") == null)
|
||||
assertTrue(qthToPosition("io9") == null)
|
||||
assertTrue(qthToPosition("IO91VL7") == null)
|
||||
assertTrue(qthToPosition("IO91VL4X") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid POS returns correct QTH`() {
|
||||
// default precision is 8 chars
|
||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
assertTrue(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
||||
assertTrue(positionToQth(48.1466, 11.6083) == "JN58td25")
|
||||
// 6-char precision still available for backwards compatibility
|
||||
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
assertTrue(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
|
||||
assertTrue(positionToQth(48.1466, 11.6083, 6) == "JN58td")
|
||||
// 10-char precision
|
||||
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
assertTrue(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
|
||||
assertTrue(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid POS returns null`() {
|
||||
assert(positionToQth(91.0542, -170.1142) == null)
|
||||
assert(positionToQth(89.0542, -240.1142) == null)
|
||||
assertTrue(positionToQth(91.0542, -170.1142) == null)
|
||||
assertTrue(positionToQth(89.0542, -240.1142) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary POS stays in valid grid`() {
|
||||
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
|
||||
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
assertTrue(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
|
||||
// Exact positive bounds belong to the final cell, not a modulo-wrapped
|
||||
// R-field/0-square combination that decodes 10°/20° away.
|
||||
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
assertTrue(positionToQth(90.0, 180.0, 8) == "RR99xx99")
|
||||
assertTrue(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
|
||||
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
|
||||
val positions = listOf(
|
||||
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
|
||||
@@ -83,7 +84,7 @@ class QthConverterTest {
|
||||
val qth = positionToQth(lat, lon, 8)
|
||||
val pos = qthToPosition(qth!!)
|
||||
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
|
||||
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
|
||||
assertTrue(qth == qth2, "Roundtrip failed for ($lat, $lon): $qth -> $qth2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,64 +114,68 @@ class QthConverterTest {
|
||||
}
|
||||
lat += 0.5
|
||||
}
|
||||
assert(worstLat <= 0.01 && worstLon <= 0.01) {
|
||||
assertTrue(
|
||||
worstLat <= 0.01 && worstLon <= 0.01,
|
||||
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given out of range longitude returns null`() {
|
||||
// Maidenhead only covers -180..180; 181..360 used to be accepted and
|
||||
// encoded into a plausible-looking locator 20-200 deg away.
|
||||
assert(positionToQth(0.0, 181.0) == null)
|
||||
assert(positionToQth(0.0, 270.0) == null)
|
||||
assert(positionToQth(0.0, 360.0) == null)
|
||||
assertTrue(positionToQth(0.0, 181.0) == null)
|
||||
assertTrue(positionToQth(0.0, 270.0) == null)
|
||||
assertTrue(positionToQth(0.0, 360.0) == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given locator with out of range field returns null`() {
|
||||
// Fields run A-R; S-X in the first pair decoded past the poles.
|
||||
assert(qthToPosition("SS00aa") == null)
|
||||
assert(qthToPosition("XX99xx") == null)
|
||||
assert(qthToPosition("AS00aa") == null)
|
||||
assert(qthToPosition("AX99xx") == null)
|
||||
assertTrue(qthToPosition("SS00aa") == null)
|
||||
assertTrue(qthToPosition("XX99xx") == null)
|
||||
assertTrue(qthToPosition("AS00aa") == null)
|
||||
assertTrue(qthToPosition("AX99xx") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given square returns correct 3x3 neighbors`() {
|
||||
// Reference grid from the QTH Locator screenshot: OL42
|
||||
val neighbors = qthNeighbors("OL42")
|
||||
assert(neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
)) { "OL42 grid mismatch: $neighbors" }
|
||||
assertTrue(
|
||||
neighbors == listOf(
|
||||
"OL33", "OL43", "OL53",
|
||||
"OL32", "OL42", "OL52",
|
||||
"OL31", "OL41", "OL51"
|
||||
),
|
||||
"OL42 grid mismatch: $neighbors"
|
||||
)
|
||||
// Center cell must be the input itself
|
||||
assert(neighbors[4] == "OL42")
|
||||
assertTrue(neighbors[4] == "OL42")
|
||||
// 9 cells, all distinct
|
||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
assertTrue(neighbors.size == 9 && neighbors.toSet().size == 9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given boundary square wraps fields correctly`() {
|
||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
||||
val sw = qthNeighbors("AA00")
|
||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
assertTrue(sw.size == 9 && sw.toSet().size == 9)
|
||||
assertTrue(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
||||
// North-east corner: RR99 wraps to AA00
|
||||
val ne = qthNeighbors("RR99")
|
||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
assertTrue(ne.size == 9 && ne.toSet().size == 9)
|
||||
assertTrue(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
||||
// Field boundary: IO91's east neighbors cross into J field
|
||||
val london = qthNeighbors("IO91")
|
||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
||||
assertTrue(london[2] == "JO02" && london[5] == "JO01")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given full locator returns square part`() {
|
||||
assert(qthToSquare("OL42ih45") == "OL42")
|
||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
||||
assert(qthToSquare("JN58") == "JN58")
|
||||
assert(qthToSquare("garbage!!") == "----")
|
||||
assertTrue(qthToSquare("OL42ih45") == "OL42")
|
||||
assertTrue(qthToSquare("io91VL39FX") == "IO91")
|
||||
assertTrue(qthToSquare("JN58") == "JN58")
|
||||
assertTrue(qthToSquare("garbage!!") == "----")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain
|
||||
|
||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class TransponderMapperTest {
|
||||
|
||||
+17
-26
@@ -1,11 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import java.util.Locale
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
|
||||
@@ -13,13 +11,6 @@ import org.junit.Test
|
||||
*/
|
||||
class AprsBeaconTest {
|
||||
|
||||
private val original: Locale = Locale.getDefault()
|
||||
|
||||
@After
|
||||
fun restoreLocale() {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
|
||||
private fun line(
|
||||
latitude: Double? = 51.5,
|
||||
longitude: Double? = -0.12,
|
||||
@@ -30,7 +21,7 @@ class AprsBeaconTest {
|
||||
comment: String = "Look4Sat"
|
||||
): String {
|
||||
val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
|
||||
assertTrue("expected a line, got $result", result is AprsBeacon.Result.Line)
|
||||
assertTrue(result is AprsBeacon.Result.Line, "expected a line, got $result")
|
||||
return (result as AprsBeacon.Result.Line).text
|
||||
}
|
||||
|
||||
@@ -41,7 +32,7 @@ class AprsBeaconTest {
|
||||
@Test
|
||||
fun `the path is exactly TCPIP star`() {
|
||||
val text = line()
|
||||
assertTrue(text, text.startsWith("BG7NTA-5>APRS,TCPIP*:="))
|
||||
assertTrue(text.startsWith("BG7NTA-5>APRS,TCPIP*:="), text)
|
||||
assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
|
||||
}
|
||||
|
||||
@@ -103,22 +94,22 @@ class AprsBeaconTest {
|
||||
// Only the line break matters. The text of a second packet surviving inside the comment
|
||||
// is harmless - without a terminator the server reads one line, and a comment is free to
|
||||
// contain any printable characters the operator likes.
|
||||
assertFalse(text, text.contains('\n'))
|
||||
assertFalse(text, text.contains('\r'))
|
||||
assertEquals("must remain a single line", 1, text.lines().size)
|
||||
assertFalse(text.contains('\n'), text)
|
||||
assertFalse(text.contains('\r'), text)
|
||||
assertEquals(1, text.lines().size, "must remain a single line")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `control characters are stripped from the comment`() {
|
||||
val text = line(comment = "a\tb\u0000c")
|
||||
assertTrue(text, text.endsWith("abc"))
|
||||
assertTrue(text.endsWith("abc"), text)
|
||||
}
|
||||
|
||||
/** The line must fit in 512 bytes including the CRLF the client appends. */
|
||||
@Test
|
||||
fun `an over-long comment is trimmed to keep the line legal`() {
|
||||
val text = line(comment = "x".repeat(600))
|
||||
assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512)
|
||||
assertTrue(text.encodeToByteArray().size + 2 <= 512, "line was ${text.encodeToByteArray().size} bytes")
|
||||
}
|
||||
|
||||
/** The comment limit for this format is 43 characters. */
|
||||
@@ -155,20 +146,20 @@ class AprsBeaconTest {
|
||||
/**
|
||||
* Coordinates are fixed-width digits. A locale that formats decimals with a comma would
|
||||
* corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
|
||||
*
|
||||
* The three-locale sweep this replaced needed Locale.setDefault, which is JVM-only and so
|
||||
* cannot be compiled for iOS. The line is built by a formatter that never consults a locale,
|
||||
* so a literal on the default locale now covers what the sweep used to check.
|
||||
*/
|
||||
@Test
|
||||
fun `a comma-decimal locale does not corrupt the coordinates`() {
|
||||
val reference = line()
|
||||
for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
|
||||
Locale.setDefault(Locale.forLanguageTag(tag))
|
||||
assertEquals("locale $tag changed the packet", reference, line())
|
||||
}
|
||||
assertEquals("BG7NTA-5>APRS,TCPIP*:=5130.00N/00007.20W[Look4Sat", line())
|
||||
}
|
||||
|
||||
/** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
|
||||
@Test
|
||||
fun `the line is pure ascii`() {
|
||||
val text = line(comment = "café 北京")
|
||||
assertTrue(text, text.all { it.code in 0x20..0x7E })
|
||||
assertTrue(text.all { it.code in 0x20..0x7E }, text)
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -1,9 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The verified/unverified distinction is the point of these tests: an unverified client stays
|
||||
@@ -38,7 +38,7 @@ class AprsLoginTest {
|
||||
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
|
||||
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
|
||||
assertEquals(7, line.split(" ").size)
|
||||
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4"))
|
||||
assertTrue(!line.contains("Look4Sat-4.5.4"), "name and version must not be joined")
|
||||
}
|
||||
|
||||
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */
|
||||
@@ -84,11 +84,11 @@ class AprsLoginTest {
|
||||
* every send afterwards reported success against a server that had refused the login.
|
||||
*/
|
||||
@Test
|
||||
fun `an invalid login comment is a refusal, not chatter`() {
|
||||
fun `an invalid login comment is a refusal rather than chatter`() {
|
||||
val outcome = AprsLogin.parse(
|
||||
"# Invalid login: software name and version are not separated by a space"
|
||||
)
|
||||
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected)
|
||||
assertTrue(outcome is AprsLogin.Outcome.Rejected, "must be a refusal, got $outcome")
|
||||
assertEquals(
|
||||
"Invalid login: software name and version are not separated by a space",
|
||||
(outcome as AprsLogin.Outcome.Rejected).detail
|
||||
@@ -116,8 +116,8 @@ class AprsLoginTest {
|
||||
)
|
||||
for (line in refusals) {
|
||||
assertTrue(
|
||||
"must be a refusal: $line",
|
||||
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected
|
||||
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected,
|
||||
"must be a refusal: $line"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ class AprsLoginTest {
|
||||
"# filter myfilter active"
|
||||
)
|
||||
for (line in harmless) {
|
||||
assertNull("must be skipped so the caller keeps reading: $line", AprsLogin.parse(line))
|
||||
assertNull(AprsLogin.parse(line), "must be skipped so the caller keeps reading: $line")
|
||||
}
|
||||
}
|
||||
|
||||
+26
-31
@@ -1,10 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
import com.rtbishop.look4sat.core.domain.utility.formatString
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
|
||||
@@ -12,51 +11,49 @@ import java.util.Locale
|
||||
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
|
||||
* packets.
|
||||
*
|
||||
* Regression guard: every formatted field must stay ASCII regardless of the
|
||||
* default locale.
|
||||
* Regression guard: every formatted field must stay ASCII, and every expected
|
||||
* value below is a hardcoded literal. The formatter these fields are built from
|
||||
* (formatString) never consults a locale, so nothing here can vary with the
|
||||
* device - Locale.setDefault itself is JVM-only and does not exist on iOS.
|
||||
*/
|
||||
class AprsPacketLocaleTest {
|
||||
|
||||
private val original: Locale = Locale.getDefault()
|
||||
|
||||
@After
|
||||
fun restoreLocale() {
|
||||
Locale.setDefault(original)
|
||||
}
|
||||
|
||||
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
|
||||
|
||||
/** The primitive the fields above are built from: fixed digits, never a locale's digits. */
|
||||
@Test
|
||||
fun position_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
fun formatString_producesAsciiLiterals() {
|
||||
assertEquals("/A=000328", formatString("/A=%06d", 328))
|
||||
assertEquals("/090/019", formatString("/%03d/%03d", 90, 19))
|
||||
assertEquals("r/39.904/116.407/100", formatString("r/%.3f/%.3f/%d", 39.9042, 116.4074, 100))
|
||||
assertEquals("3954.25N", formatString("%02d%s%c", 39, "54.25", 'N'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun position_staysAscii() {
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
|
||||
assertEquals("3954.25N/11624.44E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun position_staysAsciiUnderBengaliLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
|
||||
|
||||
fun position_staysAsciiForSouthernCoordinates() {
|
||||
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
|
||||
|
||||
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
|
||||
assertEquals("3352.13S/15112.56E>", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
|
||||
|
||||
fun altitudeAndCourseSpeed_stayAscii() {
|
||||
val altitude = AprsPacket.formatAltitude(100.0)
|
||||
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
|
||||
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
|
||||
|
||||
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
|
||||
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
|
||||
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
|
||||
assertTrue(asciiPacket.matches(altitude), "not ASCII: $altitude")
|
||||
assertTrue(asciiPacket.matches(courseSpeed), "not ASCII: $courseSpeed")
|
||||
assertTrue(asciiPacket.matches(filter), "not ASCII: $filter")
|
||||
assertEquals("/A=000328", altitude)
|
||||
assertEquals("/090/019", courseSpeed)
|
||||
assertEquals("r/39.904/116.407/100", filter)
|
||||
@@ -79,13 +76,11 @@ class AprsPacketLocaleTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
|
||||
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
|
||||
|
||||
fun ambiguousPosition_staysAscii() {
|
||||
for (ambiguity in 1..4) {
|
||||
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
|
||||
.toUncompressedString()
|
||||
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
|
||||
assertTrue(asciiPacket.matches(encoded), "ambiguity=$ambiguity not ASCII: $encoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -1,9 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The rule these pin down: the app never invents a transmit passcode.
|
||||
@@ -80,8 +80,8 @@ class AprsPasscodeTest {
|
||||
|
||||
assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly)
|
||||
assertTrue(blank is AprsPasscode.Entry.ReceiveOnly)
|
||||
assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly)
|
||||
assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly)
|
||||
assertFalse(typo is AprsPasscode.Entry.ReceiveOnly, "a typo must not read as receive-only")
|
||||
assertFalse(garbage is AprsPasscode.Entry.ReceiveOnly, "garbage must not read as receive-only")
|
||||
|
||||
// All four are equally unable to transmit, which is why the boolean is not enough.
|
||||
for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) {
|
||||
+14
-14
@@ -1,10 +1,10 @@
|
||||
package com.rtbishop.look4sat.core.domain.aprs
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The list exists because two free-text fields accepted anything and used only the first character,
|
||||
@@ -28,14 +28,14 @@ class AprsSymbolsTest {
|
||||
fun `every curated symbol passes the transmit sanitiser`() {
|
||||
for (symbol in AprsSymbols.curated) {
|
||||
assertEquals(
|
||||
symbol.descriptionKey + " table must survive tableOf",
|
||||
symbol.table,
|
||||
AprsBeacon.tableOf(symbol.table.toString())
|
||||
AprsBeacon.tableOf(symbol.table.toString()),
|
||||
symbol.descriptionKey + " table must survive tableOf"
|
||||
)
|
||||
assertEquals(
|
||||
symbol.descriptionKey + " code must survive codeOf",
|
||||
symbol.code,
|
||||
AprsBeacon.codeOf(symbol.code.toString())
|
||||
AprsBeacon.codeOf(symbol.code.toString()),
|
||||
symbol.descriptionKey + " code must survive codeOf"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,15 +44,15 @@ class AprsSymbolsTest {
|
||||
@Test
|
||||
fun `no two curated symbols are the same pair`() {
|
||||
val pairs = AprsSymbols.curated.map { it.table to it.code }
|
||||
assertEquals("pairs must be unique", pairs.size, pairs.toSet().size)
|
||||
assertEquals(pairs.size, pairs.toSet().size, "pairs must be unique")
|
||||
}
|
||||
|
||||
/** Each needs its own description, or the list reads as duplicates. */
|
||||
@Test
|
||||
fun `every curated symbol has a distinct description key`() {
|
||||
val keys = AprsSymbols.curated.map { it.descriptionKey }
|
||||
assertEquals("description keys must be unique", keys.size, keys.toSet().size)
|
||||
assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") })
|
||||
assertEquals(keys.size, keys.toSet().size, "description keys must be unique")
|
||||
assertTrue(keys.all { it.startsWith("aprs_symbol_") }, "keys must be resource names")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -95,8 +95,8 @@ class AprsSymbolsTest {
|
||||
@Test
|
||||
fun `the list stays short`() {
|
||||
assertTrue(
|
||||
"a curated list of ${AprsSymbols.curated.size} defeats the point",
|
||||
AprsSymbols.curated.size in 8..20
|
||||
AprsSymbols.curated.size in 8..20,
|
||||
"a curated list of ${AprsSymbols.curated.size} defeats the point"
|
||||
)
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.sin
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
|
||||
* 200 Hz at 119 times the spectral mean, which reads as a real signal, and the whole band above
|
||||
* 1600 Hz folded down and lifted the noise floor across the display.
|
||||
*/
|
||||
class CwAntiAliasTest {
|
||||
|
||||
private val captureRate = 44100
|
||||
private val targetRate = CwDeepSpectrogram.SAMPLE_RATE
|
||||
private val nyquist = targetRate / 2.0
|
||||
|
||||
/** Magnitude at one frequency, Hann-windowed so neighbours do not smear in. */
|
||||
private fun magnitudeAt(signal: FloatArray, hz: Double, rate: Int): Double {
|
||||
val n = signal.size
|
||||
var real = 0.0
|
||||
var imag = 0.0
|
||||
val omega = 2.0 * PI * hz / rate
|
||||
for (i in 0 until n) {
|
||||
val window = 0.5 - 0.5 * kotlin.math.cos(2.0 * PI * i / (n - 1))
|
||||
val value = signal[i] * window
|
||||
real += value * kotlin.math.cos(omega * i)
|
||||
imag -= value * sin(omega * i)
|
||||
}
|
||||
return hypot(real, imag) / n
|
||||
}
|
||||
|
||||
private fun tone(hz: Double, rate: Int, seconds: Double = 0.4): FloatArray {
|
||||
val n = (rate * seconds).toInt()
|
||||
return FloatArray(n) { i -> sin(2.0 * PI * hz * i / rate).toFloat() }
|
||||
}
|
||||
|
||||
/** A tone the model needs must survive the filter. */
|
||||
@Test
|
||||
fun `tones inside the window pass through`() {
|
||||
for (hz in listOf(300.0, 700.0, 1000.0, 1200.0)) {
|
||||
val clean = tone(hz, captureRate)
|
||||
val filtered = CwAntiAlias.prepareForDecimation(clean, captureRate, targetRate)
|
||||
val before = magnitudeAt(clean, hz, captureRate)
|
||||
val after = magnitudeAt(filtered, hz, captureRate)
|
||||
assertTrue(
|
||||
after > before * 0.7,
|
||||
"$hz Hz lost too much: $before -> $after"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The defect. 3000 Hz used to fold to 200 Hz and look like a strong signal; the filter has to
|
||||
* remove it before the resampler can alias it.
|
||||
*/
|
||||
@Test
|
||||
fun `tones that would alias are suppressed`() {
|
||||
// Limits are the measured response, not aspirations. 1800 Hz sits just past the 1472 Hz
|
||||
// cut-off, and 127 taps cannot give a steeper transition than that - a longer filter would
|
||||
// be sharper and slower, and this runs on a phone during a pass. What matters is that the
|
||||
// wideband hiss well above the window, which is what smears across the whole display, is
|
||||
// gone: 2400 Hz and up measure below a thousandth.
|
||||
val cases = listOf(
|
||||
Triple(1800.0, 1400.0, 0.20),
|
||||
Triple(2400.0, 800.0, 0.01),
|
||||
Triple(3000.0, 200.0, 0.01),
|
||||
Triple(5000.0, 1400.0, 0.01)
|
||||
)
|
||||
for ((source, foldedTo, limit) in cases) {
|
||||
val raw = tone(source, captureRate)
|
||||
val filtered = CwAntiAlias.prepareForDecimation(raw, captureRate, targetRate)
|
||||
|
||||
val aliasedRaw = CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate)
|
||||
val aliasedFiltered = CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate)
|
||||
|
||||
val ghostBefore = magnitudeAt(aliasedRaw, foldedTo, targetRate)
|
||||
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
|
||||
|
||||
assertTrue(
|
||||
ghostAfter < ghostBefore * limit,
|
||||
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing above the target Nyquist means nothing to do. */
|
||||
@Test
|
||||
fun `no filtering when the rate is already low enough`() {
|
||||
val audio = tone(700.0, targetRate)
|
||||
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, targetRate, targetRate))
|
||||
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, 2000, targetRate))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty buffer is returned unchanged`() {
|
||||
val empty = FloatArray(0)
|
||||
assertSame(empty, CwAntiAlias.prepareForDecimation(empty, captureRate, targetRate))
|
||||
}
|
||||
|
||||
/** Unity DC gain: filtering must not move the level the model was trained on. */
|
||||
@Test
|
||||
fun `a steady level is preserved`() {
|
||||
val flat = FloatArray(4410) { 0.5f }
|
||||
val filtered = CwAntiAlias.prepareForDecimation(flat, captureRate, targetRate)
|
||||
// Skip the edges, where taps fall outside the buffer.
|
||||
val middle = filtered.copyOfRange(
|
||||
CwAntiAlias.GROUP_DELAY_SAMPLES + 1,
|
||||
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
|
||||
)
|
||||
for (v in middle) {
|
||||
assertTrue(kotlin.math.abs(v - 0.5f) < 0.02f, "level drifted to $v")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked filtering has to match whole-buffer filtering, or every chunk boundary becomes a
|
||||
* click - the same failure the tone shifter's streaming path exists to prevent.
|
||||
*/
|
||||
/**
|
||||
* Chunked filtering must equal whole-buffer filtering exactly, or every chunk boundary is a
|
||||
* click. A first attempt let the lookahead taps read zeros at the end of each chunk and
|
||||
* diverged by 0.134 across the last 44 samples of every one; holding output back by the group
|
||||
* delay makes the two identical.
|
||||
*/
|
||||
@Test
|
||||
fun `streaming matches whole-buffer filtering`() {
|
||||
val audio = tone(700.0, captureRate, seconds = 0.3)
|
||||
val whole = CwAntiAlias.prepareForDecimation(audio, captureRate, targetRate)
|
||||
|
||||
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||
val chunkSize = captureRate / 10
|
||||
val pieces = mutableListOf<Float>()
|
||||
var offset = 0
|
||||
while (offset < audio.size) {
|
||||
val end = minOf(offset + chunkSize, audio.size)
|
||||
pieces += streaming.process(audio.copyOfRange(offset, end)).toList()
|
||||
offset = end
|
||||
}
|
||||
val streamed = pieces.toFloatArray()
|
||||
|
||||
// Output is delayed by the group delay, so streamed[i] corresponds to whole[i + delay].
|
||||
var worst = 0f
|
||||
for (i in streamed.indices) {
|
||||
val j = i + CwAntiAlias.GROUP_DELAY_SAMPLES
|
||||
if (j >= whole.size) break
|
||||
val diff = kotlin.math.abs(streamed[i] - whole[j])
|
||||
if (diff > worst) worst = diff
|
||||
}
|
||||
assertTrue(worst < 1e-5f, "streaming diverges by $worst")
|
||||
}
|
||||
|
||||
/** Streaming must not lift the noise floor either. */
|
||||
@Test
|
||||
fun `streaming suppresses an aliasing tone too`() {
|
||||
val raw = tone(3000.0, captureRate, seconds = 0.3)
|
||||
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||
val chunkSize = captureRate / 10
|
||||
val pieces = mutableListOf<Float>()
|
||||
var offset = 0
|
||||
while (offset < raw.size) {
|
||||
val end = minOf(offset + chunkSize, raw.size)
|
||||
pieces += streaming.process(raw.copyOfRange(offset, end)).toList()
|
||||
offset = end
|
||||
}
|
||||
val filtered = pieces.toFloatArray()
|
||||
|
||||
val ghostBefore = magnitudeAt(
|
||||
CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate), 200.0, targetRate
|
||||
)
|
||||
val ghostAfter = magnitudeAt(
|
||||
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
|
||||
)
|
||||
assertTrue(ghostAfter < ghostBefore * 0.01, "ghost survived: $ghostBefore -> $ghostAfter")
|
||||
}
|
||||
|
||||
/** Reset has to clear history, or the next session starts with the last one's tail. */
|
||||
@Test
|
||||
fun `reset clears the history`() {
|
||||
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
|
||||
val loud = FloatArray(4410) { 0.9f }
|
||||
streaming.process(loud)
|
||||
streaming.reset()
|
||||
|
||||
val silence = FloatArray(4410)
|
||||
val after = streaming.process(silence)
|
||||
for (v in after) {
|
||||
assertTrue(kotlin.math.abs(v) < 0.01f, "history leaked into silence: $v")
|
||||
}
|
||||
// And a second silent chunk, now that the pipeline is primed.
|
||||
for (v in streaming.process(FloatArray(4410))) {
|
||||
assertTrue(kotlin.math.abs(v) < 0.01f, "history still leaking: $v")
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -17,8 +17,8 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Greedy CTC collapse, matching the reference implementation's
|
||||
@@ -45,7 +45,7 @@ class CwCtcDecoderTest {
|
||||
|
||||
@Test
|
||||
fun alphabetSizeMatchesModelMetadata() {
|
||||
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
|
||||
assertEquals(41, chars.size, "41 symbols + blank = 42 classes")
|
||||
}
|
||||
|
||||
@Test
|
||||
+19
-19
@@ -17,11 +17,11 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The rolling audio buffer feeding DeepCW.
|
||||
@@ -45,14 +45,14 @@ class CwDeepBufferTest {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f))
|
||||
buffer.append(floatArrayOf(4f, 5f))
|
||||
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
|
||||
assertContentEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotIsChronologicalAfterWrapAround() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
|
||||
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,18 +60,18 @@ class CwDeepBufferTest {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
|
||||
assertEquals(4, buffer.size)
|
||||
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
|
||||
assertContentEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redecodeIsSignalledOncePerInterval() {
|
||||
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
|
||||
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
|
||||
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
|
||||
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
|
||||
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
|
||||
assertFalse(buffer.append(FloatArray(3200)), "1.0s elapsed: interval not reached")
|
||||
assertTrue(buffer.append(FloatArray(1600)), "1.5s elapsed: first trigger")
|
||||
assertFalse(buffer.append(FloatArray(1600)), "2.0s: only 0.5s since trigger")
|
||||
assertFalse(buffer.append(FloatArray(1600)), "2.5s: only 1.0s since trigger")
|
||||
assertTrue(buffer.append(FloatArray(1600)), "3.0s: 1.5s since trigger, fires again")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,7 +89,7 @@ class CwDeepBufferTest {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
buffer.snapshot()[0] = 99f
|
||||
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
|
||||
assertEquals(1f, buffer.snapshot()[0], absoluteTolerance = 0f, "caller must not be able to mutate the buffer")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,7 +98,7 @@ class CwDeepBufferTest {
|
||||
buffer.append(FloatArray(3200))
|
||||
buffer.reset()
|
||||
assertEquals(0, buffer.size)
|
||||
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
|
||||
assertFalse(buffer.append(FloatArray(3200)), "counter restarted, 1.0s must not trigger")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,10 +123,10 @@ class CwDeepBufferTest {
|
||||
fun overflowCollectsEvictedSamplesInOrder() {
|
||||
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
|
||||
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
|
||||
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
|
||||
assertEquals(0, buffer.overflowCount, "nothing evicted before the window is full")
|
||||
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
|
||||
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
|
||||
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
|
||||
assertContentEquals(floatArrayOf(1f, 2f), buffer.drainOverflow(), "evicted samples, oldest first")
|
||||
assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), "live window still correct")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,7 +137,7 @@ class CwDeepBufferTest {
|
||||
assertEquals(1, buffer.overflowCount)
|
||||
buffer.drainOverflow()
|
||||
assertEquals(0, buffer.overflowCount)
|
||||
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
|
||||
assertContentEquals(FloatArray(0), buffer.drainOverflow())
|
||||
}
|
||||
|
||||
@Test
|
||||
+14
-13
@@ -17,13 +17,13 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sin
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies the DeepCW front-end against the upstream Python reference
|
||||
@@ -40,7 +40,7 @@ class CwDeepSpectrogramTest {
|
||||
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
|
||||
assertEquals(32, start)
|
||||
assertEquals(97, stop)
|
||||
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
|
||||
assertEquals(65, stop - start, "metadata declares 65 frequency bins")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,7 +59,7 @@ class CwDeepSpectrogramTest {
|
||||
val spec = CwDeepSpectrogram.compute(audio)
|
||||
val middle = spec[spec.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
|
||||
assertTrue(abs(peak - 24) <= 1, "peak at index $peak, expected near 24")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ class CwDeepSpectrogramTest {
|
||||
val middle = display[display.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
|
||||
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz)
|
||||
assertEquals(1500.0, peak * binHz, absoluteTolerance = binHz, "1500 Hz must land on its own bin")
|
||||
}
|
||||
|
||||
/** The model's own call must keep its exact shape, whatever the display asks for. */
|
||||
@@ -95,9 +95,10 @@ class CwDeepSpectrogramTest {
|
||||
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
|
||||
assertEquals(model.size, explicit.size)
|
||||
for (frame in model.indices) {
|
||||
assertArrayEquals(
|
||||
"explicit model range must equal the default",
|
||||
model[frame], explicit[frame], 0f
|
||||
assertContentEquals(
|
||||
model[frame],
|
||||
explicit[frame],
|
||||
"explicit model range must equal the default"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +109,7 @@ class CwDeepSpectrogramTest {
|
||||
val spec = CwDeepSpectrogram.compute(audio)
|
||||
for (frame in spec) {
|
||||
for (v in frame) {
|
||||
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
|
||||
assertTrue(v >= 0f, "log1p of a magnitude must be >= 0, got $v")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +125,7 @@ class CwDeepSpectrogramTest {
|
||||
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
|
||||
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(0.2f, out[1], 1e-6f)
|
||||
assertEquals(0.2f, out[1], absoluteTolerance = 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -136,7 +137,7 @@ class CwDeepSpectrogramTest {
|
||||
val spec = CwDeepSpectrogram.compute(at3200)
|
||||
val middle = spec[spec.size / 2]
|
||||
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
|
||||
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
|
||||
assertTrue(abs(peak - 24) <= 1, "resampled tone peak at $peak, expected near 24")
|
||||
}
|
||||
|
||||
@Test
|
||||
+33
-29
@@ -1,9 +1,9 @@
|
||||
package com.rtbishop.look4sat.core.domain.cw
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
|
||||
@@ -22,8 +22,10 @@ class CwDetectionPoolTest {
|
||||
private fun assertAscending(values: FloatArray) {
|
||||
for (i in 1 until values.size) {
|
||||
assertEquals(
|
||||
"sample $i breaks the ramp, so the ring wrap is wrong",
|
||||
values[i - 1] + 1f, values[i], 0f
|
||||
values[i - 1] + 1f,
|
||||
values[i],
|
||||
absoluteTolerance = 0f,
|
||||
"sample $i breaks the ramp, so the ring wrap is wrong"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -31,17 +33,17 @@ class CwDetectionPoolTest {
|
||||
@Test
|
||||
fun `reports readiness only once capacity is reached`() {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
assertFalse("an empty pool is not ready", pool.isReady)
|
||||
assertFalse(pool.isReady, "an empty pool is not ready")
|
||||
assertEquals(0, pool.size)
|
||||
|
||||
// Three 320-sample chunks are 960 samples: still short.
|
||||
repeat(3) { pool.add(ramp(it * 320, 320)) }
|
||||
assertEquals(960, pool.size)
|
||||
assertFalse("960 of $capacity samples is not ready", pool.isReady)
|
||||
assertFalse(pool.isReady, "960 of $capacity samples is not ready")
|
||||
|
||||
pool.add(ramp(960, 320))
|
||||
assertEquals(capacity, pool.size)
|
||||
assertTrue("a full pool must report ready", pool.isReady)
|
||||
assertTrue(pool.isReady, "a full pool must report ready")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -50,11 +52,11 @@ class CwDetectionPoolTest {
|
||||
pool.add(ramp(500, 320))
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals("only what was added may come back", 320, drained.size)
|
||||
assertEquals(500f, drained.first(), 0f)
|
||||
assertEquals(819f, drained.last(), 0f)
|
||||
assertEquals(320, drained.size, "only what was added may come back")
|
||||
assertEquals(500f, drained.first(), absoluteTolerance = 0f)
|
||||
assertEquals(819f, drained.last(), absoluteTolerance = 0f)
|
||||
assertAscending(drained)
|
||||
assertEquals("draining empties the pool", 0, pool.size)
|
||||
assertEquals(0, pool.size, "draining empties the pool")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,8 +67,8 @@ class CwDetectionPoolTest {
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f)
|
||||
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertEquals(3199f, drained.last(), absoluteTolerance = 0f, "the newest sample fed must be last")
|
||||
assertEquals((3200 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f, "the oldest retained sample must be first")
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@@ -77,8 +79,8 @@ class CwDetectionPoolTest {
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(4999f, drained.last(), 0f)
|
||||
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertEquals(4999f, drained.last(), absoluteTolerance = 0f)
|
||||
assertEquals((5000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@@ -90,8 +92,8 @@ class CwDetectionPoolTest {
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(1999f, drained.last(), 0f)
|
||||
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f)
|
||||
assertEquals(1999f, drained.last(), absoluteTolerance = 0f)
|
||||
assertEquals((2000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@@ -105,8 +107,8 @@ class CwDetectionPoolTest {
|
||||
pool.add(ramp(9000, 320))
|
||||
val drained = pool.drain()
|
||||
assertEquals(320, drained.size)
|
||||
assertEquals(9000f, drained.first(), 0f)
|
||||
assertEquals(9319f, drained.last(), 0f)
|
||||
assertEquals(9000f, drained.first(), absoluteTolerance = 0f)
|
||||
assertEquals(9319f, drained.last(), absoluteTolerance = 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@@ -120,8 +122,8 @@ class CwDetectionPoolTest {
|
||||
assertFalse(pool.isReady)
|
||||
pool.add(ramp(7000, 320))
|
||||
val drained = pool.drain()
|
||||
assertEquals("cleared samples must not reappear", 320, drained.size)
|
||||
assertEquals(7000f, drained.first(), 0f)
|
||||
assertEquals(320, drained.size, "cleared samples must not reappear")
|
||||
assertEquals(7000f, drained.first(), absoluteTolerance = 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,7 +131,7 @@ class CwDetectionPoolTest {
|
||||
val pool = CwDetectionPool(capacity)
|
||||
pool.add(ramp(0, 320))
|
||||
pool.add(FloatArray(0))
|
||||
assertEquals("an empty chunk must not change the pool", 320, pool.size)
|
||||
assertEquals(320, pool.size, "an empty chunk must not change the pool")
|
||||
assertAscending(pool.drain())
|
||||
}
|
||||
|
||||
@@ -140,8 +142,8 @@ class CwDetectionPoolTest {
|
||||
|
||||
val drained = pool.drain()
|
||||
assertEquals(capacity, drained.size)
|
||||
assertEquals(100f, drained.first(), 0f)
|
||||
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f)
|
||||
assertEquals(100f, drained.first(), absoluteTolerance = 0f)
|
||||
assertEquals((100 + capacity - 1).toFloat(), drained.last(), absoluteTolerance = 0f)
|
||||
assertAscending(drained)
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ class CwDetectionPoolTest {
|
||||
var phase = 0
|
||||
repeat(4) {
|
||||
pool.add(FloatArray(320) { i ->
|
||||
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
|
||||
kotlin.math.sin(2.0 * kotlin.math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
|
||||
})
|
||||
phase += 320
|
||||
}
|
||||
@@ -162,8 +164,10 @@ class CwDetectionPoolTest {
|
||||
|
||||
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
|
||||
assertEquals(
|
||||
"four pooled capture chunks must be enough to detect a 1500 Hz tone",
|
||||
1500.0, detected!!.toDouble(), 25.0
|
||||
1500.0,
|
||||
detected!!.toDouble(),
|
||||
absoluteTolerance = 25.0,
|
||||
"four pooled capture chunks must be enough to detect a 1500 Hz tone"
|
||||
)
|
||||
}
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user