mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-05 04:07:29 +00:00
Tweaked data source management, migrated DB and Settings
This commit is contained in:
1 parent
d4a2fae0cd
commit
6f3e3351c9
34 files changed
+1290
-437
No files matched your search
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
|
||||
* missing optional field should not cost the whole transceiver. */
|
||||
@Serializable
|
||||
data class SatRadio(
|
||||
@SerialName("uuid") val uuid: String,
|
||||
@SerialName("description") val info: String,
|
||||
@SerialName("alive") val isAlive: Boolean,
|
||||
@SerialName("downlink_low") val downlinkLow: Long?,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long?,
|
||||
@SerialName("mode") val downlinkMode: String?,
|
||||
@SerialName("uplink_low") val uplinkLow: Long?,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||
@SerialName("invert") val isInverted: Boolean,
|
||||
@SerialName("norad_cat_id") val catnum: Int?
|
||||
@SerialName("description") val info: String = "",
|
||||
@SerialName("alive") val isAlive: Boolean = true,
|
||||
@SerialName("downlink_low") val downlinkLow: Long? = null,
|
||||
@SerialName("downlink_high") val downlinkHigh: Long? = null,
|
||||
@SerialName("mode") val downlinkMode: String? = null,
|
||||
@SerialName("uplink_low") val uplinkLow: Long? = null,
|
||||
@SerialName("uplink_high") val uplinkHigh: Long? = null,
|
||||
@SerialName("uplink_mode") val uplinkMode: String? = null,
|
||||
@SerialName("invert") val isInverted: Boolean = false,
|
||||
@SerialName("norad_cat_id") val catnum: Int? = null
|
||||
)
|
||||
+29
-17
@@ -37,30 +37,42 @@ data class OrbitalData(
|
||||
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
|
||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||
|
||||
/** Absolute age of this element set, used to pick the freshest data among several sources. */
|
||||
val epochDaynum: Double = epochToDaynum(epoch)
|
||||
|
||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||
|
||||
/** Check if satellite has likely decayed by the given time. */
|
||||
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||
if (ndot == 0.0) return false
|
||||
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||
val epochDaynum = epochToDaynum(epoch)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
|
||||
}
|
||||
|
||||
private fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
companion object {
|
||||
/** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
|
||||
fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
/**
|
||||
* Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
|
||||
* comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
|
||||
* freshness checks must always go through this conversion.
|
||||
*/
|
||||
fun epochToDaynum(epoch: Double): Double {
|
||||
var year = kotlin.math.floor(epoch * 1E-3)
|
||||
val day = (epoch * 1E-3 - year) * 1000.0
|
||||
year = if (year < 57) year + 2000 else year + 1900
|
||||
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||
val jan1Jd = julianDateOfYear(year)
|
||||
return jan1Jd + day - 2444238.5
|
||||
}
|
||||
|
||||
private fun julianDateOfYear(theYear: Double): Double {
|
||||
val aYear = theYear - 1
|
||||
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||
val b = 2 - a + a / 4
|
||||
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
interface ISettingsRepo {
|
||||
|
||||
val appVersionName: String
|
||||
val appVersionCode: Long
|
||||
|
||||
//region # Satellites selection settings
|
||||
val selectedIds: StateFlow<List<Int>>
|
||||
|
||||
@@ -26,11 +26,18 @@ interface ILocalSource {
|
||||
suspend fun getEntriesTotal(): Int
|
||||
suspend fun getEntriesList(): List<SatItem>
|
||||
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
|
||||
suspend fun getEntriesEpochs(): Map<Int, Double>
|
||||
suspend fun getEntriesNames(): Map<Int, String>
|
||||
suspend fun renameEntries(names: Map<Int, String>)
|
||||
suspend fun deleteEntriesWithIds(ids: List<Int>)
|
||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||
suspend fun deleteEntries()
|
||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||
suspend fun getRadiosTotal(): Int
|
||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||
suspend fun insertRadios(radios: List<SatRadio>)
|
||||
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
|
||||
suspend fun deleteRadios()
|
||||
|
||||
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
|
||||
suspend fun deleteManagedRadios()
|
||||
}
|
||||
@@ -18,17 +18,19 @@
|
||||
package com.rtbishop.look4sat.core.domain.source
|
||||
|
||||
object Sources {
|
||||
// Ordered from the most specific to the most generic source: the first source that knows a
|
||||
// satellite gets to name it, with the full catalog acting as the fallback.
|
||||
val satelliteDataUrls = listOf(
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"db.satnogs.org/api/tle/?format=3le",
|
||||
"amsat.org/tle/current/nasabare.txt",
|
||||
"mmccants.org/tles/classfd.zip",
|
||||
"live.ariss.org/iss.txt",
|
||||
"r4uab.ru/satonline.txt",
|
||||
"live.ariss.org/iss.txt"
|
||||
"mmccants.org/tles/classfd.zip",
|
||||
"amsat.org/tle/current/nasabare.txt",
|
||||
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||
"db.satnogs.org/api/tle/?format=3le"
|
||||
)
|
||||
val transceiversDataUrls = listOf(
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||
"r4uab.ru/transmitters.json"
|
||||
"r4uab.ru/transmitters.json",
|
||||
"db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||
)
|
||||
val satelliteModes = listOf(
|
||||
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
|
||||
|
||||
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
private val celestrakCSVColumns = mapOf(
|
||||
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
|
||||
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
|
||||
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
|
||||
)
|
||||
|
||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
|
||||
val iterator = lines.iterator()
|
||||
if (!iterator.hasNext()) return@useLines emptyList()
|
||||
val columns = parseCSVColumns(iterator.next())
|
||||
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,35 +67,63 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
|
||||
val name = values[0]
|
||||
val timestamp = values[2]
|
||||
val year = timestamp.substring(0, 4)
|
||||
val month = timestamp.substring(5, 7).toInt()
|
||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
|
||||
val day = dayInt.toString().padStart(3, '0')
|
||||
val hour = timestamp.substring(11, 13).toInt() * 3600000
|
||||
val min = timestamp.substring(14, 16).toInt() * 60000
|
||||
val sec = timestamp.substring(17, 19).toInt() * 1000
|
||||
val ms = timestamp.substring(20, 26).toInt() / 1000.0
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
|
||||
val epoch = "${year.substring(2)}$day$frac".toDouble()
|
||||
private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
|
||||
fun value(column: String) = values[columns.getValue(column)].trim()
|
||||
fun optionalValue(column: String) =
|
||||
columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
OrbitalData(
|
||||
name = name,
|
||||
epoch = epoch,
|
||||
meanmo = values[3].toDouble(),
|
||||
eccn = values[4].toDouble(),
|
||||
incl = values[5].toDouble(),
|
||||
raan = values[6].toDouble(),
|
||||
argper = values[7].toDouble(),
|
||||
meanan = values[8].toDouble(),
|
||||
catnum = values[11].toInt(),
|
||||
bstar = values[14].toDouble(),
|
||||
ndot = values[15].toDouble()
|
||||
name = value("OBJECT_NAME"),
|
||||
epoch = parseTimestamp(value("EPOCH")),
|
||||
meanmo = value("MEAN_MOTION").toDouble(),
|
||||
eccn = value("ECCENTRICITY").toDouble(),
|
||||
incl = value("INCLINATION").toDouble(),
|
||||
raan = value("RA_OF_ASC_NODE").toDouble(),
|
||||
argper = value("ARG_OF_PERICENTER").toDouble(),
|
||||
meanan = value("MEAN_ANOMALY").toDouble(),
|
||||
catnum = parseCatnum(value("NORAD_CAT_ID")),
|
||||
bstar = optionalValue("BSTAR"),
|
||||
ndot = optionalValue("MEAN_MOTION_DOT")
|
||||
)
|
||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||
|
||||
/**
|
||||
* OMM columns are located by name, as providers agree on the names but not on the order.
|
||||
* Falls back to the Celestrak layout when the header is missing or unrecognized.
|
||||
*/
|
||||
private fun parseCSVColumns(header: String): Map<String, Int> {
|
||||
val columns = header.split(",").withIndex().associate { (index, name) ->
|
||||
name.trim().trim('"').uppercase() to index
|
||||
}
|
||||
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
|
||||
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
|
||||
*/
|
||||
private fun parseTimestamp(timestamp: String): Double {
|
||||
val year = timestamp.substring(0, 4).toInt()
|
||||
val month = timestamp.substring(5, 7).toInt()
|
||||
val dayOfMonth = timestamp.substring(8, 10).toInt()
|
||||
val hours = timestamp.substring(11, 13).toInt()
|
||||
val minutes = timestamp.substring(14, 16).toInt()
|
||||
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
|
||||
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
|
||||
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
|
||||
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
|
||||
*/
|
||||
private fun parseCatnum(value: String): Int {
|
||||
val catnum = value.trim()
|
||||
if (catnum.first().isDigit()) return catnum.toInt()
|
||||
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
|
||||
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
|
||||
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
|
||||
}
|
||||
|
||||
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
|
||||
val line1 = tle[1]
|
||||
val line2 = tle[2]
|
||||
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
raan = line2.substring(17, 25).toDouble(),
|
||||
argper = line2.substring(34, 42).toDouble(),
|
||||
meanan = line2.substring(43, 51).toDouble(),
|
||||
catnum = line1.substring(2, 7).trim().toInt(),
|
||||
catnum = parseCatnum(line1.substring(2, 7)),
|
||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||
ndot = line1.substring(33, 43).trim().toDouble()
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class DataParserTest {
|
||||
@@ -61,8 +62,49 @@ class DataParserTest {
|
||||
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)
|
||||
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
|
||||
assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
|
||||
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||
assert(sat.name == "ISS (ZARYA)")
|
||||
assert(sat.catnum == 25544)
|
||||
assert(sat.meanmo == 15.48582035)
|
||||
assert(sat.bstar == 0.31985E-4)
|
||||
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
|
||||
val csvStream = """
|
||||
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:09Z,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,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||
""".trimIndent().byteInputStream()
|
||||
val parsedList = dataParser.parseCSVStream(csvStream)
|
||||
assert(parsedList.size == 2)
|
||||
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
|
||||
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
|
||||
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
|
||||
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
|
||||
val tleStream = """
|
||||
NAVSTAR 43
|
||||
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||
""".trimIndent().byteInputStream()
|
||||
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
|
||||
assert(sat.catnum == 270111)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user