mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
feat(map): award filters with boundary overlay in grid mode
- Grid-mode top bar replaced with award filter chips (All/DXCC/VUCC/ WAPC/WAJA/WAZ/WAS) showing live progress (count/target) - New AwardBoundaryOverlay renders real administrative boundaries (provinces/prefectures/CQ zones/states/countries) with the VUCC visual language: yellow boundary lines, green fill for worked regions, labels - Boundary assets packaged from public GeoJSON (DataV China, Japan land, HB9HIL CQ zones, Natural Earth states/countries) with ADIF match keys: WAPC province codes, WAJA ADIF prefecture numbers, US state abbrs, ARRL DXCC entity codes - GridQso extended with dxcc/country/cqz/state parsed from LoTW report (qso_qsldetail); AwardCalculator derives six-award progress; STATE is interpreted per DXCC entity (ADIF 'STATE depends on DXCC') - Grid-mode toggle pill floated at map top-right (Grid mode / Satellite mode) - New tests: AwardCalculatorTest (9 cases), LoTW award-field parsing (3 cases)
This commit is contained in:
1 parent
cce9209b3d
commit
4a33d1b19a
17 files changed
+891
-15
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
/**
|
||||
* Award progress computed from the confirmed-QSO store.
|
||||
*
|
||||
* @param type which award
|
||||
* @param workedKeys matching keys for the boundary assets: each is compared
|
||||
* against the region "code" field of the award's GeoJSON
|
||||
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
||||
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
||||
* @param count distinct worked units (grids / entities / provinces / ...)
|
||||
* @param target units required for the award
|
||||
*/
|
||||
data class AwardProgress(
|
||||
val type: AwardType,
|
||||
val workedKeys: Set<String>,
|
||||
val count: Int,
|
||||
val target: Int
|
||||
)
|
||||
|
||||
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
||||
|
||||
object AwardTargets {
|
||||
const val DXCC = 100
|
||||
const val VUCC = 100
|
||||
const val WAPC = 34
|
||||
const val WAJA = 47
|
||||
const val WAZ = 40
|
||||
const val WAS = 50
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives six-award progress from the confirmed QSO store.
|
||||
*
|
||||
* Award rules (ADIF / award-program semantics):
|
||||
* - VUCC: distinct 4-char gridsquares.
|
||||
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
||||
* to a callsign-prefix guess when LoTW omitted it).
|
||||
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
||||
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
||||
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
||||
* - WAZ: distinct CQ zones.
|
||||
* - WAS: distinct US states (STATE abbreviations).
|
||||
*
|
||||
* The ADIF STATE field's meaning depends on the DXCC entity
|
||||
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
||||
*/
|
||||
object AwardCalculator {
|
||||
|
||||
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
||||
val all = qsosByGrid.values.flatten()
|
||||
return listOf(
|
||||
AwardProgress(
|
||||
AwardType.VUCC,
|
||||
workedKeys = qsosByGrid.keys,
|
||||
count = qsosByGrid.size,
|
||||
target = AwardTargets.VUCC
|
||||
),
|
||||
calculateDxcc(all),
|
||||
calculateWapc(all),
|
||||
calculateWaja(all),
|
||||
calculateWaz(all),
|
||||
calculateWas(all)
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||
if (code != null) keys.add(code.toString())
|
||||
}
|
||||
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||
}
|
||||
|
||||
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
when (entityOf(q)) {
|
||||
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||
Entity.MACAO -> keys.add(MO_CODE)
|
||||
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||
}
|
||||
|
||||
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.JAPAN) continue
|
||||
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||
}
|
||||
|
||||
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||
}
|
||||
|
||||
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||
val keys = mutableSetOf<String>()
|
||||
for (q in all) {
|
||||
if (entityOf(q) != Entity.USA) continue
|
||||
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||
}
|
||||
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||
}
|
||||
|
||||
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
||||
|
||||
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
||||
318 -> Entity.CHINA
|
||||
321 -> Entity.HONG_KONG
|
||||
330 -> Entity.MACAO
|
||||
386 -> Entity.TAIWAN
|
||||
339 -> Entity.JAPAN
|
||||
291 -> Entity.USA
|
||||
else -> prefixEntity(q.call)
|
||||
}
|
||||
|
||||
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
||||
private fun prefixEntity(call: String): Entity {
|
||||
val c = call.uppercase().substringBefore('/')
|
||||
return when {
|
||||
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
||||
c.startsWith("VR") -> Entity.HONG_KONG
|
||||
c.startsWith("XX9") -> Entity.MACAO
|
||||
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
||||
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
||||
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
||||
else -> Entity.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
||||
private fun prefixEntityCode(call: String): Int? {
|
||||
return when (prefixEntity(call)) {
|
||||
Entity.CHINA -> 318
|
||||
Entity.HONG_KONG -> 321
|
||||
Entity.MACAO -> 330
|
||||
Entity.TAIWAN -> 386
|
||||
Entity.JAPAN -> 339
|
||||
Entity.USA -> 291
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
||||
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
||||
private val JP_PREFIXES =
|
||||
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
||||
private val US_PREFIXES =
|
||||
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
||||
|
||||
private val CN_STATES = setOf(
|
||||
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
||||
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
||||
)
|
||||
|
||||
private val US_STATES = setOf(
|
||||
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
||||
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
||||
"VA", "WA", "WV", "WI", "WY"
|
||||
)
|
||||
|
||||
private const val HK_CODE = "HK"
|
||||
private const val MO_CODE = "MO"
|
||||
private const val TW_CODE = "TW"
|
||||
}
|
||||
@@ -29,6 +29,14 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||
* LoTW did not include it
|
||||
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||
* <DXCC>), or null when LoTW omitted it
|
||||
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
||||
* @param state Primary administrative subdivision of the opposite station
|
||||
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
||||
* China -> province code ("GD"), Japan -> prefecture number
|
||||
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
||||
*/
|
||||
data class GridQso(
|
||||
val call: String,
|
||||
@@ -36,7 +44,11 @@ data class GridQso(
|
||||
val satName: String,
|
||||
val mode: String,
|
||||
val bandUp: String,
|
||||
val bandDown: String
|
||||
val bandDown: String,
|
||||
val dxcc: Int? = null,
|
||||
val country: String? = null,
|
||||
val cqz: Int? = null,
|
||||
val state: String? = null
|
||||
) {
|
||||
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||
val bandLabel: String
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AwardCalculatorTest {
|
||||
|
||||
private fun qso(
|
||||
call: String,
|
||||
dxcc: Int? = null,
|
||||
cqz: Int? = null,
|
||||
state: String? = null,
|
||||
grid: String = "OL62"
|
||||
) = GridQso(
|
||||
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
|
||||
)
|
||||
|
||||
private fun byGrid(vararg qsos: GridQso) =
|
||||
qsos.groupBy { "OL62" } // all share one grid for award-math tests
|
||||
|
||||
@Test
|
||||
fun `vucc counts distinct grid keys`() {
|
||||
val qsos = mapOf(
|
||||
"OL62" to listOf(qso("BG7XYZ")),
|
||||
"PM95" to listOf(qso("JH0ABC")),
|
||||
"OL62" to listOf(qso("BA7AAA"))
|
||||
)
|
||||
// map literal dedupes keys: OL62/PM95 -> 2 grids
|
||||
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc counts distinct entities from dxcc field`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318),
|
||||
qso("JH0ABC", dxcc = 339),
|
||||
qso("YD1XYZ", dxcc = 327),
|
||||
qso("BG7ABC", dxcc = 318)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dxcc falls back to prefix when dxcc missing`() {
|
||||
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"))
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc counts mainland provinces plus hk mo tw`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
|
||||
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
|
||||
qso("VR2X", dxcc = 321), // Hong Kong
|
||||
qso("XX9A", dxcc = 330), // Macao
|
||||
qso("BM4X", dxcc = 386) // Taiwan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(5, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wapc ignores non-china states and missing states`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
|
||||
qso("BG7XYZ", dxcc = 318, state = null), // missing province
|
||||
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||
assertEquals(0, progress.count)
|
||||
assertTrue(progress.workedKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waja counts japanese prefectures only`() {
|
||||
val all = listOf(
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
|
||||
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
|
||||
qso("JH0ABC", dxcc = 339, state = "10"), // dup
|
||||
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
|
||||
qso("JE2XYZ", dxcc = 339, state = null) // missing
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `waz counts distinct cq zones in range`() {
|
||||
val all = listOf(
|
||||
qso("BG7XYZ", cqz = 24),
|
||||
qso("JH0ABC", cqz = 25),
|
||||
qso("YD1XYZ", cqz = 28),
|
||||
qso("BG7AAA", cqz = 24),
|
||||
qso("BG7BBB", cqz = null),
|
||||
qso("BG7CCC", cqz = 0)
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
|
||||
assertEquals(3, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `was counts us states only`() {
|
||||
val all = listOf(
|
||||
qso("K1ABC", dxcc = 291, state = "CA"),
|
||||
qso("W2XYZ", dxcc = 291, state = "NY"),
|
||||
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
|
||||
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
|
||||
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
|
||||
)
|
||||
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
|
||||
assertEquals(2, progress.count)
|
||||
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all six awards present with correct targets`() {
|
||||
val progress = AwardCalculator.calculate(emptyMap())
|
||||
assertEquals(6, progress.size)
|
||||
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
|
||||
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
|
||||
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
|
||||
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
|
||||
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user