11package org.operatorfoundation.audiocoder
22
3+ import android.icu.util.Calendar
4+ import java.lang.Math.pow
5+
6+ /* *
7+ * Represents a successfully decoded WSPR message with all associated metadata.
8+ *
9+ * WSPR (Weak Signal Propagation Reporter) messages contain standardized information
10+ * about the transmitting station, propagation conditions, and signal characteristics.
11+ *
12+ * A typical WSPR message contains:
13+ * - Station identification (callsign)
14+ * - Location information (Maidenhead grid square)
15+ * - Transmit power level (in dBm)
16+ * - Reception quality metrics (SNR, frequency offset)
17+ * - Decode timing information
18+ *
19+ * Example usage:
20+ * val result = WSPRDecodeResult(
21+ * callsign = "W1AW",
22+ * gridSquare = "FN31",
23+ * powerLevelDbm = 30,
24+ * signalToNoiseRatioDb = -15.2f,
25+ * frequencyOffsetHz = 1456.3,
26+ * completeMessage = "W1AW FN31 30",
27+ * decodeTimestamp = System.currentTimeMillis()
28+ * )
29+ *
30+ * println("Station ${result.callsign} in ${result.gridSquare}")
31+ * println("Signal quality: ${result.signalQualityDescription}")
32+ */
333data class WSPRDecodeResult (
434 /* *
535 * Amateur radio callsign of the transmitting station.
636 *
7- * Examples: "W1AW", "JA1XYZ", "DL0ABC "
37+ * Examples: "Q0QQQ "
838 * Standard amateur radio callsign format as assigned by national authorities.
939 * May include portable/mobile indicators like "/P" or "/M".
1040 */
@@ -50,7 +80,7 @@ data class WSPRDecodeResult(
5080 * Complete decoded message as received.
5181 *
5282 * Format: "CALLSIGN GRID POWER"
53- * Example: "W1AW FN31 30"
83+ * Example: "Q0QQQ FN31 30"
5484 * Contains the raw decoded message for verification and logging.
5585 */
5686 val completeMessage : String ,
@@ -66,5 +96,168 @@ data class WSPRDecodeResult(
6696 val decodeTimestamp : Long
6797)
6898{
99+ /* *
100+ * Human-readable description of signal quality based on SNR.
101+ */
102+ val signalQualityDescription: String
103+ get() = when {
104+ signalToNoiseRatioDb >= 0 -> " Excellent (${signalToNoiseRatioDb.format(1 )} dB)"
105+ signalToNoiseRatioDb >= - 10 -> " Good (${signalToNoiseRatioDb.format(1 )} dB)"
106+ signalToNoiseRatioDb >= - 20 -> " Fair (${signalToNoiseRatioDb.format(1 )} dB)"
107+ signalToNoiseRatioDb >= - 30 -> " Weak (${signalToNoiseRatioDb.format(1 )} dB)"
108+ else -> " Very weak (${signalToNoiseRatioDb.format(1 )} dB)"
109+ }
110+
111+ /* *
112+ * Transmit power converted to watts.
113+ * Calculated from dBm using standard conversion formula: P(W) = 10^((P(dBm) - 30) / 10)
114+ */
115+ val transmitPowerWatts: Double
116+ get() = pow(10.0 , (powerLevelDbm - 30.0 ) / 10.0 )
117+
118+ /* *
119+ * Human-readable power level description.
120+ */
121+ val powerLevelDescription: String
122+ get() = when {
123+ transmitPowerWatts < 0.001 -> " QRP (${transmitPowerWatts.format(3 )} W)"
124+ transmitPowerWatts < 0.01 -> " Low power (${transmitPowerWatts.format(3 )} W)"
125+ transmitPowerWatts < 0.1 -> " Medium power (${transmitPowerWatts.format(2 )} W)"
126+ transmitPowerWatts < 1.0 -> " High power (${transmitPowerWatts.format(1 )} W)"
127+ else -> " Very high power (${transmitPowerWatts.format(0 )} W)"
128+ }
129+
130+ /* *
131+ * Formatted frequency display showing offset from WSPR center frequency.
132+ * Displays the actual received frequency for technical analysis.
133+ */
134+ val displayFrequency: String
135+ get() {
136+ val centerFrequencyHz = 1500.0 // WSPR center frequency
137+ val actualFrequencyHz = centerFrequencyHz + frequencyOffsetHz
138+ return " ${actualFrequencyHz.format(1 )} Hz (${frequencyOffsetHz.formatOffset()} Hz)"
139+ }
140+
141+ /* *
142+ * Formatted timestamp for display purposes.
143+ * Shows decode time in local timezone with standard format.
144+ */
145+ val formattedDecodeTime: String
146+ get() {
147+ val calendar = Calendar .getInstance()
148+ calendar.timeInMillis = decodeTimestamp
149+ return String .format(
150+ " %04d-%02d-%02d %02d:%02d:%02d" ,
151+ calendar.get(Calendar .YEAR ),
152+ calendar.get(Calendar .MONTH ) + 1 ,
153+ calendar.get(Calendar .DAY_OF_MONTH ),
154+ calendar.get(Calendar .HOUR_OF_DAY ),
155+ calendar.get(Calendar .MINUTE ),
156+ calendar.get(Calendar .SECOND )
157+ )
158+ }
159+
160+ /* *
161+ * Creates a summary string for logging or display.
162+ */
163+ fun createSummaryLine (): String {
164+ return " $callsign ($gridSquare ) ${powerLevelDbm} dBm SNR:${signalToNoiseRatioDb.format(1 )} dB"
165+ }
166+
167+ /* *
168+ * Creates detailed information for technical analysis.
169+ * Includes all available data for comprehensive record keeping.
170+ */
171+ fun createDetailedReport (): String {
172+ return buildString {
173+ appendLine(" WSPR Decode Result" )
174+ appendLine(" ================" )
175+ appendLine(" Callsign: $callsign " )
176+ appendLine(" Grid Square: $gridSquare " )
177+ appendLine(" Power: $powerLevelDbm dBm ($powerLevelDescription )" )
178+ appendLine(" Signal Quality: $signalQualityDescription " )
179+ appendLine(" Frequency: $displayFrequency " )
180+ appendLine(" Complete Message: '$completeMessage '" )
181+ appendLine(" Decoded: $formattedDecodeTime " )
182+ }
183+ }
184+
185+ /* *
186+ * Checks if this decode result represents the same transmission as another.
187+ * Useful for duplicate detection across multiple decode attempts.
188+ *
189+ * @param other Another decode result to compare
190+ * @param timeToleranceMs Acceptable time difference for considering results as duplicates
191+ * @return true if the results likely represent the same transmission
192+ */
193+ fun isSameTransmissionAs (other : WSPRDecodeResult , timeToleranceMs : Long = 5000L): Boolean
194+ {
195+ val timeDifference = kotlin.math.abs(decodeTimestamp - other.decodeTimestamp)
196+ val frequencyDifference = kotlin.math.abs(frequencyOffsetHz - other.frequencyOffsetHz)
197+
198+ return callsign == other.callsign &&
199+ gridSquare == other.gridSquare &&
200+ powerLevelDbm == other.powerLevelDbm &&
201+ timeDifference <= timeToleranceMs &&
202+ frequencyDifference < 5.0 // Within 5 Hz frequency tolerance
203+ }
69204
205+ companion object
206+ {
207+ /* * Placeholder for unknown or invalid callsigns */
208+ const val UNKNOWN_CALLSIGN = " UNKNOWN"
209+
210+ /* * Placeholder for unknown or invalid grid squares */
211+ const val UNKNOWN_GRID_SQUARE = " ????"
212+
213+ /* * Placeholder for empty or corrupted messages */
214+ const val EMPTY_MESSAGE = " "
215+
216+ /* *
217+ * Creates a decode result representing a failed or corrupted decode.
218+ * Used when the decoder detects a signal but cannot extract valid information.
219+ *
220+ * @param partialMessage Any partial message content that was recovered
221+ * @param snr Signal-to-noise ratio if available
222+ * @param frequency Frequency offset if available
223+ * @return Decode result marked as invalid/incomplete
224+ */
225+ fun createCorruptedDecode (
226+ partialMessage : String = EMPTY_MESSAGE ,
227+ snr : Float = Float .NaN ,
228+ frequency : Double = Double .NaN
229+ ): WSPRDecodeResult
230+ {
231+ return WSPRDecodeResult (
232+ callsign = UNKNOWN_CALLSIGN ,
233+ gridSquare = UNKNOWN_GRID_SQUARE ,
234+ powerLevelDbm = 0 ,
235+ signalToNoiseRatioDb = snr,
236+ frequencyOffsetHz = frequency,
237+ completeMessage = partialMessage,
238+ decodeTimestamp = System .currentTimeMillis()
239+ )
240+ }
241+ }
70242}
243+
244+ // ========== Extension Functions for Formatting ==========
245+
246+ /* *
247+ * Formats a floating-point number to the specified number of decimal places.
248+ */
249+ private fun Float.format (decimals : Int ): String = " %.${decimals} f" .format(this )
250+
251+ /* *
252+ * Formats a double-precision number to the specified number of decimal places.
253+ */
254+ private fun Double.format (decimals : Int ): String = " %.${decimals} f" .format(this )
255+
256+ /* *
257+ * Formats a frequency offset with appropriate sign and units.
258+ */
259+ private fun Double.formatOffset (): String
260+ {
261+ val sign = if (this >= 0 ) " +" else " "
262+ return " $sign${format(1 )} "
263+ }
0 commit comments