mirror of
https://github.com/rt-bishop/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added GeoConverter.kt and various minor fixes
This commit is contained in:
1 parent
1719f8cc2e
commit
c4ecdb7a78
9 files changed
+108
-41
No files matched your search
@@ -8,6 +8,7 @@ kotlin {
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
implementation("org.json:json:20230618")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.5")
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2022 Arty Bishop (bishop.arty@gmail.com)
|
||||
*
|
||||
* 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.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.domain.model.SatRadio
|
||||
import com.rtbishop.look4sat.domain.predict.OrbitalData
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.InputStream
|
||||
import kotlin.math.pow
|
||||
|
||||
class DataParser(private val dispatcher: CoroutineDispatcher) {
|
||||
|
||||
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
val parsedItems = mutableListOf<OrbitalData>()
|
||||
stream.bufferedReader().useLines { lines ->
|
||||
lines.forEachIndexed { index, line ->
|
||||
if (index != 0) {
|
||||
val values = line.split(",")
|
||||
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return@withContext parsedItems
|
||||
}
|
||||
|
||||
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
|
||||
val tleStrings = mutableListOf(String(), String(), String())
|
||||
val parsedItems = mutableListOf<OrbitalData>()
|
||||
var lineIndex = 0
|
||||
stream.bufferedReader().forEachLine { line ->
|
||||
tleStrings[lineIndex] = line
|
||||
if (lineIndex < 2) {
|
||||
lineIndex++
|
||||
} else {
|
||||
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
|
||||
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
|
||||
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
|
||||
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
|
||||
lineIndex = 0
|
||||
}
|
||||
}
|
||||
return@withContext parsedItems
|
||||
}
|
||||
|
||||
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
|
||||
val parsedItems = mutableListOf<SatRadio>()
|
||||
try {
|
||||
val jsonArray = JSONArray(stream.bufferedReader().readText())
|
||||
for (index in 0 until jsonArray.length()) {
|
||||
val jsonObject = jsonArray.getJSONObject(index)
|
||||
parseJSON(jsonObject)?.let { parsedItems.add(it) }
|
||||
}
|
||||
return@withContext parsedItems
|
||||
} catch (exception: Exception) {
|
||||
return@withContext parsedItems
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCSV(values: List<String>): OrbitalData? {
|
||||
try {
|
||||
val name = values[0]
|
||||
val year = values[2].substring(0, 4)
|
||||
val month = values[2].substring(5, 7)
|
||||
val dayOfMonth = values[2].substring(8, 10)
|
||||
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
|
||||
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
|
||||
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
|
||||
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
|
||||
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
|
||||
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
|
||||
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
|
||||
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
|
||||
val meanmo = values[3].toDouble()
|
||||
val eccn = values[4].toDouble()
|
||||
val incl = values[5].toDouble()
|
||||
val raan = values[6].toDouble()
|
||||
val argper = values[7].toDouble()
|
||||
val meanan = values[8].toDouble()
|
||||
val catnum = values[11].toInt()
|
||||
val bstar = values[14].toDouble()
|
||||
return OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
||||
} catch (exception: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTLE(tle: List<String>): OrbitalData? {
|
||||
if (tle[1].substring(0, 1) != "1" && tle[2].substring(0, 1) != "2") {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
val name: String = tle[0].trim()
|
||||
val epoch: Double = tle[1].substring(18, 32).toDouble()
|
||||
val meanmo: Double = tle[2].substring(52, 63).toDouble()
|
||||
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
|
||||
val incl: Double = tle[2].substring(8, 16).toDouble()
|
||||
val raan: Double = tle[2].substring(17, 25).toDouble()
|
||||
val argper: Double = tle[2].substring(34, 42).toDouble()
|
||||
val meanan: Double = tle[2].substring(43, 51).toDouble()
|
||||
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
|
||||
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(
|
||||
tle[1].substring(60, 61).toDouble()
|
||||
)
|
||||
return OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
|
||||
} catch (exception: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJSON(json: JSONObject): SatRadio? {
|
||||
try {
|
||||
val uuid = json.getString("uuid")
|
||||
val info = json.getString("description")
|
||||
val isAlive = json.getBoolean("alive")
|
||||
val downlink = if (json.isNull("downlink_low")) null
|
||||
else json.getLong("downlink_low")
|
||||
val uplink = if (json.isNull("uplink_low")) null
|
||||
else json.getLong("uplink_low")
|
||||
val mode = if (json.isNull("mode")) null
|
||||
else json.getString("mode")
|
||||
val isInverted = json.getBoolean("invert")
|
||||
val catnum = if (json.isNull("norad_cat_id")) null
|
||||
else json.getInt("norad_cat_id")
|
||||
return SatRadio(uuid, info, isAlive, downlink, uplink, mode, isInverted, catnum)
|
||||
} catch (exception: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||
if (month == 1) return dayOfMonth
|
||||
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||
var dayOfYear = dayOfMonth
|
||||
// If leap year increment Feb days
|
||||
if (((year / 4 == 0) && (year / 100 != 0)) || (year / 400 == 0)) daysArray[1]++
|
||||
for (i in 0 until month - 1) dayOfYear += daysArray[i]
|
||||
return dayOfYear
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@
|
||||
*/
|
||||
package com.rtbishop.look4sat.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.domain.predict.DEG2RAD
|
||||
import com.rtbishop.look4sat.domain.predict.RAD2DEG
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun Long.toTimerString(): String {
|
||||
@@ -41,10 +39,6 @@ fun Double.round(decimals: Int): Double {
|
||||
return kotlin.math.round(this * multiplier) / multiplier
|
||||
}
|
||||
|
||||
fun Double.toDegrees(): Double = this * RAD2DEG
|
||||
|
||||
fun Double.toRadians(): Double = this * DEG2RAD
|
||||
|
||||
//fun String.getHash(type: String = "SHA-256"): String {
|
||||
// val hexChars = "0123456789ABCDEF"
|
||||
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
|
||||
@@ -63,12 +57,12 @@ fun Double.toRadians(): Double = this * DEG2RAD
|
||||
// return pattern.matcher(this).matches()
|
||||
//}
|
||||
|
||||
fun String.isValidIPv4(): Boolean {
|
||||
val ip4 = "^((\\d|[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])(\\.(?!\$)|\$)){4}\$"
|
||||
return this.matches(ip4.toRegex())
|
||||
}
|
||||
//fun String.isValidIPv4(): Boolean {
|
||||
// val ip4 = "^((\\d|[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])(\\.(?!\$)|\$)){4}\$"
|
||||
// return this.matches(ip4.toRegex())
|
||||
//}
|
||||
|
||||
fun String.isValidPort(): Boolean {
|
||||
val port = "([1-9]|[1-9]\\d{1,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])"
|
||||
return this.matches(port.toRegex()) && this.toInt() in 1024..65535
|
||||
}
|
||||
//fun String.isValidPort(): Boolean {
|
||||
// val port = "([1-9]|[1-9]\\d{1,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])"
|
||||
// return this.matches(port.toRegex()) && this.toInt() in 1024..65535
|
||||
//}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.rtbishop.look4sat.domain.utility
|
||||
|
||||
import com.rtbishop.look4sat.domain.predict.DEG2RAD
|
||||
import com.rtbishop.look4sat.domain.predict.PI
|
||||
import com.rtbishop.look4sat.domain.predict.RAD2DEG
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val MIN_LATITUDE = -85.05112877980658
|
||||
private const val MAX_LATITUDE = 85.05112877980658
|
||||
private const val MIN_LONGITUDE = -180.0
|
||||
private const val MAX_LONGITUDE = 180.0
|
||||
|
||||
fun Double.toDegrees(): Double = this * RAD2DEG
|
||||
|
||||
fun Double.toRadians(): Double = this * DEG2RAD
|
||||
|
||||
fun Double.latToY01(): Double {
|
||||
val sinus = sin(clipLat(this) * PI / MAX_LONGITUDE)
|
||||
return 0.5 - ln((1 + sinus) / (1 - sinus)) / (4 * PI)
|
||||
}
|
||||
|
||||
fun Double.lonToX01(): Double {
|
||||
return (clipLon(this) - MIN_LONGITUDE) / (MAX_LONGITUDE - MIN_LONGITUDE)
|
||||
}
|
||||
|
||||
//fun Double.y01ToLat(): Double {
|
||||
// return 90 - 360 * atan(exp((this - 0.5) * 2 * PI)) / PI
|
||||
//}
|
||||
|
||||
//fun Double.x01ToLon(): Double {
|
||||
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
|
||||
//}
|
||||
|
||||
private fun clipLat(latitude: Double): Double {
|
||||
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
|
||||
}
|
||||
|
||||
private fun clipLon(longitude: Double): Double {
|
||||
var result = longitude
|
||||
while (result < MIN_LONGITUDE) result += 360.0
|
||||
while (result > MAX_LONGITUDE) result -= 360.0
|
||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||
}
|
||||
|
||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||
return min(max(currentValue, minValue), maxValue)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2022 Arty Bishop (bishop.arty@gmail.com)
|
||||
*
|
||||
* 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.domain
|
||||
|
||||
import com.rtbishop.look4sat.domain.utility.DataParser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class DataParserTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val dataParser = DataParser(testDispatcher)
|
||||
private val validCSVStream = """
|
||||
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
|
||||
FLTSATCOM 8 (USA 46),1989-077A,2022-01-07T11:37:38.074080,1.00273350,.0001114,12.9044,1.3272,91.5769,260.4200,0,U,20253,999,24434,0,-.85E-6,0
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidCSVStream = """
|
||||
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 = """
|
||||
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
|
||||
FLTSATCOM 8 (USA 46)
|
||||
1 20253U 89077A 22007.48446845 -.00000085 00000+0 00000+0 0 9999
|
||||
2 20253 12.9044 1.3272 0001114 91.5769 260.4200 1.00273350244345
|
||||
""".trimIndent().byteInputStream()
|
||||
private val invalidTLEStream = """
|
||||
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 = """
|
||||
[{"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 = """
|
||||
[{"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()
|
||||
|
||||
@Test
|
||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseCSVStream(invalidCSVStream)
|
||||
assert(parsedList.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(parsedList[0].epoch == 21320.51955234)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseTLEStream(invalidTLEStream)
|
||||
assert(parsedList.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
|
||||
val csvResult = dataParser.parseCSVStream(validCSVStream)
|
||||
val tleResult = dataParser.parseTLEStream(validTLEStream)
|
||||
assert(csvResult == tleResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseJSONStream(validJSONStream)
|
||||
assert(parsedList[0].downlink == 136658500L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||
val parsedList = dataParser.parseJSONStream(invalidJSONStream)
|
||||
assert(parsedList.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user