Renamed base module into domain

This commit is contained in:
Arty Bishop committed 2025-12-27 15:01:45 +00:00
1 parent 9bb73f28b7
commit f8d5542af5
52 files changed
+187 -154

No files matched your search

+1
View File
@@ -0,0 +1 @@
/build
+16
View File
@@ -0,0 +1,16 @@
plugins {
id "kotlin"
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_test_version"
implementation "org.json:json:$json_version"
testImplementation "junit:junit:$junit_version"
testImplementation "io.mockk:mockk:$mockk_version"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_test_version"
}
@@ -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.data
import com.rtbishop.look4sat.domain.model.OrbitalData
import com.rtbishop.look4sat.domain.model.SatRadio
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
}
}
@@ -0,0 +1,106 @@
/*
* 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.data
import com.rtbishop.look4sat.domain.model.DatabaseState
import com.rtbishop.look4sat.domain.model.SatEntry
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.IDatabaseRepo
import com.rtbishop.look4sat.domain.predict.ISettingsRepo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.util.zip.ZipInputStream
class DatabaseRepo(
private val dispatcher: CoroutineDispatcher,
private val dataParser: DataParser,
private val dataSource: IDataSource,
private val localStorage: ILocalStorage,
private val settingsRepository: ISettingsRepo
) : IDatabaseRepo {
override suspend fun updateFromFile(uri: String) = withContext(dispatcher) {
val importedSatellites = dataSource.getFileStream(uri)?.let { importSatellites(it) }
importedSatellites?.let { localStorage.insertEntries(it) }
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateFromWeb() = withContext(dispatcher) {
val sourcesMap = settingsRepository.satelliteSourcesMap
val importedEntries = mutableListOf<SatEntry>()
val importedRadios = mutableListOf<SatRadio>()
// fetch
val jobsMap = sourcesMap.mapValues { async { dataSource.getNetworkStream(it.value) } }
val jobRadios = async { dataSource.getNetworkStream(settingsRepository.radioSourceUrl) }
// parse
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
entry.value?.let { stream ->
when (val type = entry.key) {
"AMSAT", "R4UAB" -> {
// parse tle stream
val satellites = importSatellites(stream)
val catnums = satellites.map { it.data.catnum }
settingsRepository.saveSatType(type, catnums)
importedEntries.addAll(satellites)
}
"McCants", "Classified" -> {
// unzip and parse tle stream
val unzipped = ZipInputStream(stream).apply { nextEntry }
val satellites = importSatellites(unzipped)
val catnums = satellites.map { it.data.catnum }
settingsRepository.saveSatType(type, catnums)
importedEntries.addAll(satellites)
}
else -> {
// parse csv stream
val parsed = dataParser.parseCSVStream(stream)
val satellites = parsed.map { data -> SatEntry(data) }
val catnums = satellites.map { it.data.catnum }
settingsRepository.saveSatType(type, catnums)
importedEntries.addAll(satellites)
}
}
}
}
jobRadios.await()?.let { importedRadios.addAll(dataParser.parseJSONStream(it)) }
// insert
localStorage.insertEntries(importedEntries)
localStorage.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun clearAllData() = withContext(dispatcher) {
localStorage.deleteEntries()
localStorage.deleteRadios()
setUpdateSuccessful(0L)
}
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
val satellitesTotal = localStorage.getEntriesTotal()
val radiosTotal = localStorage.getRadiosTotal()
settingsRepository.saveDatabaseState(DatabaseState(satellitesTotal, radiosTotal, timestamp))
}
private suspend fun importSatellites(stream: InputStream): List<SatEntry> {
return dataParser.parseTLEStream(stream).map { data -> SatEntry(data) }
}
}
@@ -0,0 +1,27 @@
/*
* 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.data
import java.io.InputStream
interface IDataSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
}
@@ -0,0 +1,44 @@
/*
* 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.data
import com.rtbishop.look4sat.domain.model.SatEntry
import com.rtbishop.look4sat.domain.model.SatItem
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.Satellite
interface ILocalStorage {
suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<Satellite>
suspend fun insertEntries(entries: List<SatEntry>)
suspend fun deleteEntries()
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun deleteRadios()
}
@@ -0,0 +1,12 @@
package com.rtbishop.look4sat.domain.data
import kotlinx.coroutines.flow.StateFlow
interface ISensorSource {
val orientation: StateFlow<Pair<Float, Float>>
fun enableSensor()
fun disableSensor()
}
@@ -0,0 +1,235 @@
/*
* 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.data
import com.rtbishop.look4sat.domain.model.GeoPos
import com.rtbishop.look4sat.domain.model.SatPass
import com.rtbishop.look4sat.domain.model.SatPos
import com.rtbishop.look4sat.domain.model.SatRadio
import com.rtbishop.look4sat.domain.predict.ISatelliteRepo
import com.rtbishop.look4sat.domain.predict.ISettingsRepo
import com.rtbishop.look4sat.domain.predict.Satellite
import com.rtbishop.look4sat.domain.utility.round
import com.rtbishop.look4sat.domain.utility.toDegrees
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
private val localStorage: ILocalStorage,
private val settingsRepo: ISettingsRepo
) : ISatelliteRepo {
private val _passes = MutableStateFlow<List<SatPass>>(emptyList())
override val passes: StateFlow<List<SatPass>> = _passes
private val _satellites = MutableStateFlow<List<Satellite>>(emptyList())
override val satellites: StateFlow<List<Satellite>> = _satellites
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.satelliteSelection.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation) = settingsRepo.passesSettings.value
calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation)
}
}
override suspend fun getPosition(sat: Satellite, pos: GeoPos, time: Long): SatPos {
return withContext(dispatcher) { sat.getPosition(pos, time) }
}
override suspend fun getTrack(
sat: Satellite, pos: GeoPos, start: Long, end: Long
): List<SatPos> {
return withContext(dispatcher) {
val positions = mutableListOf<SatPos>()
var currentTime = start
while (currentTime < end) {
positions.add(sat.getPosition(pos, currentTime))
currentTime += 15000
}
positions
}
}
override suspend fun processRadios(
sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
val copiedList = radios.map { it.copy() }
copiedList.forEach { transmitter ->
transmitter.downlink?.let { transmitter.downlink = satPos.getDownlinkFreq(it) }
transmitter.uplink?.let { transmitter.uplink = satPos.getUplinkFreq(it) }
}
copiedList.map { it.copy() }
}
}
override suspend fun processPasses(passList: List<SatPass>, time: Long): List<SatPass> {
return withContext(dispatcher) {
passList.forEach { pass ->
if (!pass.isDeepSpace) {
val timeStart = pass.aosTime
if (time > timeStart) {
val deltaNow = time.minus(timeStart).toFloat()
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
pass.progress = (deltaNow / deltaTotal).round(2)
}
}
}
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
}
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double) {
if (_satellites.value.isNotEmpty()) {
withContext(dispatcher) {
val allPasses = mutableListOf<SatPass>()
val stationPos = settingsRepo.stationPosition.value
_satellites.value.forEach { satellite ->
allPasses.addAll(satellite.getPasses(stationPos, time, hoursAhead))
}
_passes.update { allPasses.filter(time, hoursAhead, minElevation) }
}
} else {
_passes.update { emptyList() }
}
}
private fun Satellite.getPasses(pos: GeoPos, time: Long, hours: Int): List<SatPass> {
val passes = mutableListOf<SatPass>()
val endDate = time + hours * 60L * 60L * 1000L
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
var startDate = time
var shouldRewind = true
var lastAosDate: Long
var count = 0
if (this.willBeSeen(pos)) {
if (this.data.isDeepSpace) {
passes.add(getGeoPass(this, pos, time))
} else {
do {
if (count > 0) shouldRewind = false
val pass = getLeoPass(this, pos, startDate, shouldRewind)
lastAosDate = pass.aosTime
passes.add(pass)
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
count++
} while (lastAosDate < endDate)
}
}
return passes
}
private fun List<SatPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<SatPass> {
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
}
private fun getGeoPass(sat: Satellite, pos: GeoPos, time: Long): SatPass {
val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
val az = satPos.azimuth.toDegrees().round(1)
val elev = satPos.elevation.toDegrees().round(1)
val alt = satPos.altitude
return SatPass(aos, az, los, az, alt.toInt(), elev, sat)
}
private fun getLeoPass(sat: Satellite, pos: GeoPos, time: Long, rewind: Boolean): SatPass {
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
var calendarTimeMillis = time
var elevation: Double
var maxElevation = 0.0
var alt = 0.0 // var tcaAz = 0.0
// rewind 1/4 of an orbit
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
var satPos = sat.getPosition(pos, calendarTimeMillis)
if (satPos.elevation > 0.0) {
// move forward in 30 second intervals until the sat goes below the horizon
do {
calendarTimeMillis += 30 * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
} while (satPos.elevation > 0.0)
// move forward 3/4 of an orbit
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
}
// find the next time sat comes above the horizon
do {
calendarTimeMillis += 60L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
// refine to 3 seconds
calendarTimeMillis += -60L * 1000L
do {
calendarTimeMillis += 3L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
val aos = satPos.time
val aosAz = satPos.azimuth.toDegrees().round(1)
// find when sat goes below
do {
calendarTimeMillis += 30L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
// refine to 3 seconds
calendarTimeMillis += -30L * 1000L
do {
calendarTimeMillis += 3L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
val los = satPos.time // val tca = (aos + los) / 2
val losAz = satPos.azimuth.toDegrees().round(1)
val elev = maxElevation.toDegrees().round(1)
return SatPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
}
}
@@ -0,0 +1,80 @@
package com.rtbishop.look4sat.domain.data
import com.rtbishop.look4sat.domain.model.SatItem
import com.rtbishop.look4sat.domain.predict.ISelectionRepo
import com.rtbishop.look4sat.domain.predict.ISettingsRepo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepo(
private val dispatcher: CoroutineDispatcher,
private val localStorage: ILocalStorage,
private val settingsRepo: ISettingsRepo
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentType = MutableStateFlow("All")
private val currentQuery = MutableStateFlow("")
private val itemsWithType = currentType.flatMapLatest { type ->
currentItems.map { items -> items.filterByType(type) }
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithType.map { items -> items.filterByQuery(query) }
}
override fun getCurrentType() = currentType.value
override fun getTypesList() = settingsRepo.satelliteSourcesMap.keys.sorted()
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.satelliteSelection.value
currentItems.value = localStorage.getEntriesList().map { item ->
item.copy(isSelected = item.catnum in selectedIds)
}
return@withContext itemsWithQuery
}
override suspend fun setType(type: String) = withContext(dispatcher) {
currentType.value = type
}
override suspend fun setQuery(query: String) = withContext(dispatcher) {
currentQuery.value = query
}
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll)
}
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
currentItems.value = currentItems.value.map { item ->
if (item.catnum in ids) item.copy(isSelected = isTicked) else item
}
}
override suspend fun saveSelection() = withContext(dispatcher) {
val currentSelection = currentItems.value.filter { it.isSelected }.map { it.catnum }
settingsRepo.saveEntriesSelection(currentSelection)
}
private suspend fun List<SatItem>.filterByType(type: String) = withContext(dispatcher) {
if (type == "All") return@withContext this@filterByType
val catnums = settingsRepo.loadSatType(type)
if (catnums.isEmpty()) return@withContext this@filterByType
return@withContext this@filterByType.filter { item -> item.catnum in catnums }
}
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
if (query.isBlank()) return@withContext this@filterByQuery
return@withContext try {
this@filterByQuery.filter { it.catnum == query.toInt() }
} catch (e: Exception) {
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
}
}
}
@@ -0,0 +1,26 @@
/*
* 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.model
sealed class DataState<out T> {
data class Success<out T>(val data: T) : DataState<T>()
data class Error(val resId: Int?) : DataState<Nothing>()
data class Exception(val message: String?) : DataState<Nothing>()
object Handled : DataState<Nothing>()
object Loading : DataState<Nothing>()
}
@@ -0,0 +1,26 @@
/*
* 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.model
data class GeoPos(
val latitude: Double,
val longitude: Double,
val altitude: Double = 0.0,
val qthLocator: String = "null",
val timestamp: Long = 0L
)
@@ -0,0 +1,49 @@
/*
* 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.model
import com.rtbishop.look4sat.domain.predict.DEG2RAD
import com.rtbishop.look4sat.domain.predict.DeepSpaceSatellite
import com.rtbishop.look4sat.domain.predict.MIN_PER_DAY
import com.rtbishop.look4sat.domain.predict.NearEarthSatellite
import com.rtbishop.look4sat.domain.predict.Satellite
import com.rtbishop.look4sat.domain.predict.TWO_PI
data class OrbitalData(
val name: String,
val epoch: Double,
val meanmo: Double,
val eccn: Double,
val incl: Double,
val raan: Double,
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double,
val xincl: Double = incl * DEG2RAD,
val xnodeo: Double = raan * DEG2RAD,
val omegao: Double = argper * DEG2RAD,
val xmo: Double = meanan * DEG2RAD,
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY,
val orbitalPeriod: Double = MIN_PER_DAY / meanmo,
// Space objects are classified as NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
val isDeepSpace: Boolean = orbitalPeriod >= 225.0
) {
fun getSatellite(): Satellite =
if (isDeepSpace) DeepSpaceSatellite(this) else NearEarthSatellite(this)
}
@@ -0,0 +1,20 @@
/*
* 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.model
data class SatEntry(val data: OrbitalData, var comment: String? = null)
@@ -0,0 +1,25 @@
/*
* 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.model
data class SatItem(
val catnum: Int,
val name: String,
// val modes: List<String>,
val isSelected: Boolean
)
@@ -0,0 +1,35 @@
/*
* 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.model
import com.rtbishop.look4sat.domain.predict.Satellite
data class SatPass(
val aosTime: Long = 0L,
val aosAzimuth: Double = 90.0,
val losTime: Long = 0L,
val losAzimuth: Double = 270.0,
val altitude: Int = 1000,
val maxElevation: Double = 75.0,
val satellite: Satellite,
var progress: Float = 0.0f
) {
val catNum: Int = satellite.data.catnum
val name: String = satellite.data.name
val isDeepSpace: Boolean = satellite.data.isDeepSpace
}
@@ -0,0 +1,77 @@
/*
* 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.model
import com.rtbishop.look4sat.domain.predict.DEG2RAD
import com.rtbishop.look4sat.domain.predict.EARTH_RADIUS
import com.rtbishop.look4sat.domain.predict.RAD2DEG
import com.rtbishop.look4sat.domain.predict.SPEED_OF_LIGHT
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
data class SatPos(
var azimuth: Double = 0.0,
var elevation: Double = 0.0,
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var altitude: Double = 0.0,
var distance: Double = 0.0,
var distanceRate: Double = 0.0,
var theta: Double = 0.0,
var time: Long = 0L,
var phase: Double = 0.0,
var eclipseDepth: Double = 0.0,
var eclipsed: Boolean = false,
var aboveHorizon: Boolean = false
) {
fun getDownlinkFreq(freq: Long): Long {
return (freq.toDouble() * (SPEED_OF_LIGHT - distanceRate * 1000.0) / SPEED_OF_LIGHT).toLong()
}
fun getUplinkFreq(freq: Long): Long {
return (freq.toDouble() * (SPEED_OF_LIGHT + distanceRate * 1000.0) / SPEED_OF_LIGHT).toLong()
}
fun getOrbitalVelocity(): Double {
val earthG = 6.674 * 10.0.pow(-11)
val earthM = 5.98 * 10.0.pow(24)
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
return sqrt(earthG * earthM / radius) / 1000
}
fun getRangeCircle(): List<GeoPos> {
val rangeCirclePoints = mutableListOf<GeoPos>()
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
val lon = (longitude + atan2(
sin(rads) * sin(beta) * cos(latitude),
cos(beta) - sin(latitude) * sin(lat)
))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
}
return rangeCirclePoints
}
}
@@ -0,0 +1,30 @@
/*
* 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.model
data class SatRadio(
val uuid: String,
val info: String,
val isAlive: Boolean,
var downlink: Long?,
var uplink: Long?,
val mode: String?,
val isInverted: Boolean,
val catnum: Int?,
var comment: String? = null
)
@@ -0,0 +1,12 @@
package com.rtbishop.look4sat.domain.model
data class DatabaseState(val entriesTotal: Int, val radiosTotal: Int, val timestamp: Long)
data class PassesSettings(val hoursAhead: Int, val minElevation: Double)
data class OtherSettings(
val utcState: Boolean,
val updateState: Boolean,
val sweepState: Boolean,
val sensorState: Boolean
)
@@ -0,0 +1,851 @@
/*
* 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.predict
import com.rtbishop.look4sat.domain.model.OrbitalData
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
class DeepSpaceSatellite(data: OrbitalData) : Satellite(data) {
private val c1: Double
private val c4: Double
private val x1mth2: Double
private val x3thm1: Double
private val xlcof: Double
private val xnodcf: Double
private val t2cof: Double
private val aycof: Double
private val x7thm1: Double
private val deep: DeepSpaceCalculator
private val dsv = DeepSpaceValueObject()
init {
// Recover original mean motion (xnodp) and semimajor axis (aodp) from input elements
val a1 = (XKE / data.xno).pow(TWO_THIRDS)
dsv.cosio = cos(data.xincl)
dsv.theta2 = dsv.cosio * dsv.cosio
x3thm1 = 3.0 * dsv.theta2 - 1
dsv.eosq = data.eccn * data.eccn
dsv.betao2 = 1.0 - dsv.eosq
dsv.betao = sqrt(dsv.betao2)
val del1 = 1.5 * CK2 * x3thm1 / (a1 * a1 * dsv.betao * dsv.betao2)
val ao = a1 * (1.0 - del1 * (0.5 * TWO_THIRDS + del1 * (1.0 + 134.0 / 81.0 * del1)))
val delo = 1.5 * CK2 * x3thm1 / (ao * ao * dsv.betao * dsv.betao2)
dsv.xnodp = data.xno / (1.0 + delo)
dsv.aodp = ao / (1.0 - delo)
// For perigee below 156 km, the values of S and QOMS2T are altered
setPerigee((dsv.aodp * (1.0 - data.eccn) - 1.0) * EARTH_RADIUS)
val pinvsq = invert(dsv.aodp * dsv.aodp * dsv.betao2 * dsv.betao2)
dsv.sing = sin(data.omegao)
dsv.cosg = cos(data.omegao)
val tsi = invert(dsv.aodp - s4)
val eta = dsv.aodp * data.eccn * tsi
val etasq = eta * eta
val eeta = data.eccn * eta
val psisq = abs(1.0 - etasq)
val coef = qoms24 * tsi.pow(4.0)
val coef1 = coef / psisq.pow(3.5)
val c2 = coef1 * dsv.xnodp * (dsv.aodp * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq))
+ 0.75 * CK2 * tsi / psisq * x3thm1 * (8.0 + 3.0 * etasq * (8.0 + etasq)))
c1 = data.bstar * c2
dsv.sinio = sin(data.xincl)
val a3ovk2 = -J3_HARMONIC / CK2
x1mth2 = 1.0 - dsv.theta2
c4 =
2 * dsv.xnodp * coef1 * dsv.aodp * dsv.betao2 * (eta * (2.0 + 0.5 * etasq) + data.eccn
* (0.5 + 2 * etasq) - 2 * CK2 * tsi / (dsv.aodp * psisq)
* (-3 * x3thm1 * (1.0 - 2 * eeta + etasq * (1.5 - 0.5 * eeta)) + (0.75 * x1mth2
* (2.0 * etasq - eeta * (1.0 + etasq)) * cos(2.0 * data.omegao))))
val theta4 = dsv.theta2 * dsv.theta2
val temp1 = 3.0 * CK2 * pinvsq * dsv.xnodp
val temp2 = temp1 * CK2 * pinvsq
val temp3 = 1.25 * CK4 * pinvsq * pinvsq * dsv.xnodp
dsv.xmdot =
dsv.xnodp + 0.5 * temp1 * dsv.betao * x3thm1 + 0.0625 * temp2 * dsv.betao * (13 - 78 * dsv.theta2 + 137 * theta4)
val x1m5th = 1.0 - 5 * dsv.theta2
dsv.omgdot =
-0.5 * temp1 * x1m5th + 0.0625 * temp2 * (7.0 - 114 * dsv.theta2 + 395 * theta4) + temp3 * (3.0 - 36 * dsv.theta2 + 49 * theta4)
val xhdot1 = -temp1 * dsv.cosio
dsv.xnodot =
xhdot1 + (0.5 * temp2 * (4.0 - 19 * dsv.theta2) + 2 * temp3 * (3.0 - 7 * dsv.theta2)) * dsv.cosio
xnodcf = 3.5 * dsv.betao2 * xhdot1 * c1
t2cof = 1.5 * c1
xlcof = 0.125 * a3ovk2 * dsv.sinio * (3.0 + 5 * dsv.cosio) / (1.0 + dsv.cosio)
aycof = 0.25 * a3ovk2 * dsv.sinio
x7thm1 = 7.0 * dsv.theta2 - 1
deep = DeepSpaceCalculator(dsv)
}
internal fun calculateSDP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em = dsv.em - tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
}
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
val ecose = temp[5] + temp[6]
val esine = temp[3] - temp[4]
val elsq = axn * axn + ayn * ayn
temp[0] = 1.0 - elsq
val pl = a * temp[0]
temp[9] = a * (1.0 - ecose)
temp[1] = invert(temp[9])
temp[10] = XKE * sqrt(a) * esine * temp[1]
temp[11] = XKE * sqrt(pl) * temp[1]
temp[2] = a * temp[1]
val betal = sqrt(temp[0])
temp[3] = invert(1.0 + betal)
val cosu = temp[2] * (temp[8] - axn + ayn * esine * temp[3])
val sinu = temp[2] * (temp[7] - ayn - axn * esine * temp[3])
val u = atan2(sinu, cosu)
val sin2u = 2.0 * sinu * cosu
val cos2u = 2.0 * cosu * cosu - 1
temp[0] = invert(pl)
temp[1] = CK2 * temp[0]
temp[2] = temp[1] * temp[0]
// Update for short periodics
val rk = temp[9] * (1.0 - 1.5 * temp[2] * betal * x3thm1) + 0.5 * temp[1] * x1mth2 * cos2u
val uk = u - 0.25 * temp[2] * x7thm1 * sin2u
val xnodek = dsv.xnode + 1.5 * temp[2] * dsv.cosio * sin2u
val xinck = dsv.xinc + 1.5 * temp[2] * dsv.cosio * dsv.sinio * cos2u
val rdotk = temp[10] - dsv.xn * temp[1] * x1mth2 * sin2u
val rfdotk = temp[11] + dsv.xn * temp[1] * (x1mth2 * cos2u + 1.5 * x3thm1)
super.calculatePosAndVel(rk, uk, xnodek, xinck, rdotk, rfdotk)
}
inner class DeepSpaceValueObject {
var eosq = 0.0
var sinio = 0.0
var cosio = 0.0
var betao = 0.0
var aodp = 0.0
var theta2 = 0.0
var sing = 0.0
var cosg = 0.0
var betao2 = 0.0
var xmdot = 0.0
var omgdot = 0.0
var xnodot = 0.0
var xnodp = 0.0
// Used by dpsec and dpper parts of Deep()
var xll = 0.0
var omgadf = 0.0
var xnode = 0.0
var em = 0.0
var xinc = 0.0
var xn = 0.0
var t = 0.0
// Used by thetg and Deep()
var ds50 = 0.0
}
inner class DeepSpaceCalculator(private val dsv: DeepSpaceValueObject) {
private val zSinis = 3.9785416E-1
private val zSings = -9.8088458E-1
private val zNs = 1.19459E-5
private val c1ss = 2.9864797E-6
private val zEs = 1.675E-2
private val zNl = 1.5835218E-4
private val c1l = 4.7968065E-7
private val zEl = 5.490E-2
private val root22 = 1.7891679E-6
private val root32 = 3.7393792E-7
private val root44 = 7.3636953E-9
private val root52 = 1.1428639E-7
private val root54 = 2.1765803E-9
private val tHdt = 4.3752691E-3
private val q22 = 1.7891679E-6
private val q31 = 2.1460748E-6
private val q33 = 2.2123015E-7
private val g22 = 5.7686396
private val g32 = 9.5240898E-1
private val g44 = 1.8014998
private val g52 = 1.0508330
private val g54 = 4.4108898
private val thgr: Double
private val xnq: Double
private val xqncl: Double
private val omegaq: Double
private var zmol = 0.0
private var zmos = 0.0
// Many fields below cannot be final because they are iteratively refined
private var savtsn = 0.0
private var ee2 = 0.0
private var e3 = 0.0
private var xi2 = 0.0
private var xl2 = 0.0
private var xl3 = 0.0
private var xl4 = 0.0
private var xgh2 = 0.0
private var xgh3 = 0.0
private var xgh4 = 0.0
private var xh2 = 0.0
private var xh3 = 0.0
private var sse = 0.0
private var ssi = 0.0
private var ssg = 0.0
private var xi3 = 0.0
private var se2 = 0.0
private var si2 = 0.0
private var sl2 = 0.0
private var sgh2 = 0.0
private var sh2 = 0.0
private var se3 = 0.0
private var si3 = 0.0
private var sl3 = 0.0
private var sgh3 = 0.0
private var sh3 = 0.0
private var sl4 = 0.0
private var sgh4 = 0.0
private var ssl = 0.0
private var ssh = 0.0
private var d3210 = 0.0
private var d3222 = 0.0
private var d4410 = 0.0
private var d4422 = 0.0
private var d5220 = 0.0
private var d5232 = 0.0
private var d5421 = 0.0
private var d5433 = 0.0
private var del1 = 0.0
private var del2 = 0.0
private var del3 = 0.0
private var fasx2 = 0.0
private var fasx4 = 0.0
private var fasx6 = 0.0
private var xlamo = 0.0
private val xfact: Double
private var xni: Double
private var atime: Double
private val stepp: Double
private val stepn: Double
private val step2: Double
private var preep = 0.0
private var pl = 0.0
private var sghs = 0.0
private var xli: Double
private var d2201 = 0.0
private var d2211 = 0.0
private var sghl = 0.0
private var sh1 = 0.0
private var pinc = 0.0
private var pe = 0.0
private var shs = 0.0
private var zsingl = 0.0
private var zcosgl = 0.0
private var zsinhl = 0.0
private var zcoshl = 0.0
private var zsinil = 0.0
private var zcosil = 0.0
private var a1 = 0.0
private var a2 = 0.0
private var a3 = 0.0
private var a4 = 0.0
private var a5 = 0.0
private var a6 = 0.0
private var a7 = 0.0
private var a8 = 0.0
private var a9 = 0.0
private var a10 = 0.0
private var ainv2 = 0.0
private var alfdp = 0.0
private val aqnv: Double
private var sgh = 0.0
private var sini2 = 0.0
private var sinis = 0.0
private var sinok = 0.0
private var sh = 0.0
private var si = 0.0
private var sil = 0.0
private val day: Double
private var betdp = 0.0
private var dalf = 0.0
private var bfact = 0.0
private var c = 0.0
private var cc = 0.0
private var cosis = 0.0
private var cosok = 0.0
private val cosq: Double
private var ctem = 0.0
private var f322 = 0.0
private var zx = 0.0
private var zy = 0.0
private var dbet = 0.0
private var dls = 0.0
private var eoc = 0.0
private val eq: Double
private var f2 = 0.0
private var f220 = 0.0
private var f221 = 0.0
private var f3 = 0.0
private var f311 = 0.0
private var f321 = 0.0
private var xnoh = 0.0
private var f330 = 0.0
private var f441 = 0.0
private var f442 = 0.0
private var f522 = 0.0
private var f523 = 0.0
private var f542 = 0.0
private var f543 = 0.0
private var g200 = 0.0
private var g201 = 0.0
private var g211 = 0.0
private var pgh = 0.0
private var ph = 0.0
private var s1 = 0.0
private var s2 = 0.0
private var s3 = 0.0
private var s4 = 0.0
private var s5 = 0.0
private var s6 = 0.0
private var s7 = 0.0
private var se = 0.0
private var sel = 0.0
private var ses = 0.0
private var xls = 0.0
private var g300 = 0.0
private var g310 = 0.0
private var g322 = 0.0
private var g410 = 0.0
private var g422 = 0.0
private var g520 = 0.0
private var g521 = 0.0
private var g532 = 0.0
private var g533 = 0.0
private var gam = 0.0
private val sinq: Double
private var sinzf = 0.0
private var sis = 0.0
private var sl = 0.0
private var sll = 0.0
private var sls = 0.0
private var stem = 0.0
private var temp = 0.0
private var temp1 = 0.0
private var x1 = 0.0
private var x2 = 0.0
private var x2li = 0.0
private var x2omi = 0.0
private var x3 = 0.0
private var x4 = 0.0
private var x5 = 0.0
private var x6 = 0.0
private var x7 = 0.0
private var x8 = 0.0
private var xl = 0.0
private var xldot = 0.0
private val xmao: Double
private var xnddt = 0.0
private var xndot = 0.0
private var xno2 = 0.0
private var xnodce = 0.0
private var xnoi = 0.0
private var xomi = 0.0
private val xpidot: Double
private var z1 = 0.0
private var z11 = 0.0
private var z12 = 0.0
private var z13 = 0.0
private var z2 = 0.0
private var z21 = 0.0
private var z22 = 0.0
private var z23 = 0.0
private var z3 = 0.0
private var z31 = 0.0
private var z32 = 0.0
private var z33 = 0.0
private var ze = 0.0
private var zf = 0.0
private var zm = 0.0
private var zn = 0.0
private var zsing = 0.0
private var zsinh = 0.0
private var zsini = 0.0
private var zcosg = 0.0
private var zcosh = 0.0
private var zcosi = 0.0
private var delt = 0.0
private var ft = 0.0
private var resonance: Boolean
private var synchronous: Boolean
private var doLoop = false
private var epochRestart = false
init {
thgr = thetaG(data.epoch)
eq = data.eccn
xnq = dsv.xnodp
aqnv = invert(dsv.aodp)
xqncl = data.xincl
xmao = data.xmo
xpidot = dsv.omgdot + dsv.xnodot
sinq = sin(data.xnodeo)
cosq = cos(data.xnodeo)
omegaq = data.omegao
// Initialize lunar solar terms, days since 1900 Jan 0.5
day = dsv.ds50 + 18261.5
if (abs(day - preep) > 1.0E-6) {
preep = day
xnodce = 4.5236020 - 9.2422029E-4 * day
stem = sin(xnodce)
ctem = cos(xnodce)
zcosil = 0.91375164 - 0.03568096 * ctem
zsinil = sqrt(1.0 - zcosil * zcosil)
zsinhl = 0.089683511 * stem / zsinil
zcoshl = sqrt(1.0 - zsinhl * zsinhl)
c = 4.7199672 + 0.22997150 * day
gam = 5.8351514 + 0.0019443680 * day
zmol = mod2PI(c - gam)
zx = 0.39785416 * stem / zsinil
zy = zcoshl * ctem + 0.91744867 * zsinhl * stem
zx = atan2(zx, zy)
zx = gam + zx - xnodce
zcosgl = cos(zx)
zsingl = sin(zx)
zmos = mod2PI(6.2565837 + 0.017201977 * day)
} else {
zmol = 0.0
zmos = 0.0
}
doSolarTerms()
// Geopotential resonance initialization for 12 hour orbits
resonance = false
synchronous = false
if (!(xnq < 0.0052359877 && xnq > 0.0034906585)) {
if (xnq < 0.00826 || xnq > 0.00924)
if (eq < 0.5)
// calculateResonance
resonance = true
eoc = eq * dsv.eosq
g201 = -0.306 - (eq - 0.64) * 0.440
if (eq <= 0.65) {
g211 = 3.616 - 13.247 * eq + 16.290 * dsv.eosq
g310 = -19.302 + 117.390 * eq - 228.419 * dsv.eosq + 156.591 * eoc
g322 = -18.9068 + 109.7927 * eq - 214.6334 * dsv.eosq + 146.5816 * eoc
g410 = -41.122 + 242.694 * eq - 471.094 * dsv.eosq + 313.953 * eoc
g422 = -146.407 + 841.880 * eq - 1629.014 * dsv.eosq + 1083.435 * eoc
g520 = -532.114 + 3017.977 * eq - 5740 * dsv.eosq + 3708.276 * eoc
} else {
g211 = -72.099 + 331.819 * eq - 508.738 * dsv.eosq + 266.724 * eoc
g310 = -346.844 + 1582.851 * eq - 2415.925 * dsv.eosq + 1246.113 * eoc
g322 = -342.585 + 1554.908 * eq - 2366.899 * dsv.eosq + 1215.972 * eoc
g410 = -1052.797 + 4758.686 * eq - 7193.992 * dsv.eosq + 3651.957 * eoc
g422 = -3581.69 + 16178.11 * eq - 24462.77 * dsv.eosq + 12422.52 * eoc
g520 =
if (eq <= 0.715) 1464.74 - 4664.75 * eq + 3763.64 * dsv.eosq
else -5149.66 + 29936.92 * eq - 54087.36 * dsv.eosq + 31324.56 * eoc
}
if (eq < 0.7) {
g533 = -919.2277 + 4988.61 * eq - 9064.77 * dsv.eosq + 5542.21 * eoc
g521 = -822.71072 + 4568.6173 * eq - 8491.4146 * dsv.eosq + 5337.524 * eoc
g532 = -853.666 + 4690.25 * eq - 8624.77 * dsv.eosq + 5341.4 * eoc
} else {
g533 = -37995.78 + 161616.52 * eq - 229838.2 * dsv.eosq + 109377.94 * eoc
g521 = -51752.104 + 218913.95 * eq - 309468.16 * dsv.eosq + 146349.42 * eoc
g532 = -40023.88 + 170470.89 * eq - 242699.48 * dsv.eosq + 115605.82 * eoc
}
sini2 = dsv.sinio * dsv.sinio
f220 = 0.75 * (1.0 + 2 * dsv.cosio + dsv.theta2)
f221 = 1.5 * sini2
f321 = 1.875 * dsv.sinio * (1.0 - 2 * dsv.cosio - 3.0 * dsv.theta2)
f322 = -1.875 * dsv.sinio * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2)
f441 = 35 * sini2 * f220
f442 = 39.3750 * sini2 * sini2
f522 =
9.84375 * dsv.sinio * (sini2 * (1.0 - 2 * dsv.cosio - 5 * dsv.theta2) + 0.33333333 * (-2 + 4 * dsv.cosio + 6 * dsv.theta2))
f523 =
dsv.sinio * (4.92187512 * sini2 * (-2 - 4 * dsv.cosio + 10 * dsv.theta2) + 6.56250012 * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2))
f542 =
29.53125 * dsv.sinio * (2.0 - 8 * dsv.cosio + dsv.theta2 * (-12 + 8 * dsv.cosio + 10 * dsv.theta2))
f543 =
29.53125 * dsv.sinio * (-2 - 8 * dsv.cosio + dsv.theta2 * (12 + 8 * dsv.cosio - 10 * dsv.theta2))
xno2 = xnq * xnq
ainv2 = aqnv * aqnv
temp1 = 3.0 * xno2 * ainv2
temp = temp1 * root22
d2201 = temp * f220 * g201
d2211 = temp * f221 * g211
temp1 *= aqnv
temp = temp1 * root32
d3210 = temp * f321 * g310
d3222 = temp * f322 * g322
temp1 *= aqnv
temp = 2.0 * temp1 * root44
d4410 = temp * f441 * g410
d4422 = temp * f442 * g422
temp1 *= aqnv
temp = temp1 * root52
d5220 = temp * f522 * g520
d5232 = temp * f523 * g532
temp = 2.0 * temp1 * root54
d5421 = temp * f542 * g521
d5433 = temp * f543 * g533
xlamo = xmao + data.xnodeo + data.xnodeo - thgr - thgr
bfact = dsv.xmdot + dsv.xnodot + dsv.xnodot - tHdt - tHdt
bfact += ssl + ssh + ssh
} else {
// Init synchronous resonance terms
resonance = true
synchronous = true
g200 = 1.0 + dsv.eosq * (-2.5 + 0.8125 * dsv.eosq)
g310 = 1.0 + 2 * dsv.eosq
g300 = 1.0 + dsv.eosq * (-6 + 6.60937 * dsv.eosq)
f220 = 0.75 * (1.0 + dsv.cosio) * (1.0 + dsv.cosio)
f311 =
0.9375 * dsv.sinio * dsv.sinio * (1.0 + 3.0 * dsv.cosio) - 0.75 * (1.0 + dsv.cosio)
f330 = 1.0 + dsv.cosio
f330 *= 1.875 * f330 * f330
del1 = 3.0 * xnq * xnq * aqnv * aqnv
del2 = 2.0 * del1 * f220 * g200 * q22
del3 = 3.0 * del1 * f330 * g300 * q33 * aqnv
del1 *= f311 * g310 * q31 * aqnv
fasx2 = 0.13130908
fasx4 = 2.8843198
fasx6 = 0.37448087
xlamo = xmao + data.xnodeo + data.omegao - thgr
bfact = dsv.xmdot + xpidot - tHdt
bfact += ssl + ssg + ssh
}
xfact = bfact - xnq
// Init integrator
xli = xlamo
xni = xnq
atime = 0.0
stepp = 720.0
stepn = -720.0
step2 = 259200.0
}
// Entrance for lunar-solar periodics
fun dpper() {
sinis = sin(dsv.xinc)
cosis = cos(dsv.xinc)
if (abs(savtsn - dsv.t) >= 30) {
savtsn = dsv.t
zm = zmos + zNs * dsv.t
zf = zm + 2 * zEs * sin(zm)
sinzf = sin(zf)
f2 = 0.5 * sinzf * sinzf - 0.25
f3 = -0.5 * sinzf * cos(zf)
ses = se2 * f2 + se3 * f3
sis = si2 * f2 + si3 * f3
sls = sl2 * f2 + sl3 * f3 + sl4 * sinzf
sghs = sgh2 * f2 + sgh3 * f3 + sgh4 * sinzf
shs = sh2 * f2 + sh3 * f3
zm = zmol + zNl * dsv.t
zf = zm + 2 * zEl * sin(zm)
sinzf = sin(zf)
f2 = 0.5 * sinzf * sinzf - 0.25
f3 = -0.5 * sinzf * cos(zf)
sel = ee2 * f2 + e3 * f3
sil = xi2 * f2 + xi3 * f3
sll = xl2 * f2 + xl3 * f3 + xl4 * sinzf
sghl = xgh2 * f2 + xgh3 * f3 + xgh4 * sinzf
sh1 = xh2 * f2 + xh3 * f3
pe = ses + sel
pinc = sis + sil
pl = sls + sll
}
pgh = sghs + sghl
ph = shs + sh1
dsv.xinc = dsv.xinc + pinc
dsv.em = dsv.em + pe
if (xqncl >= 0.2) {
/* Apply periodics directly */
ph /= dsv.sinio
pgh -= dsv.cosio * ph
dsv.omgadf = dsv.omgadf + pgh
dsv.xnode = dsv.xnode + ph
dsv.xll = dsv.xll + pl
} else {
applyPeriodics()
// This is a patch to Lyddane modification suggested by Rob Matson
if (abs(xnoh - dsv.xnode) > PI) {
if (dsv.xnode < xnoh) dsv.xnode += TWO_PI else dsv.xnode -= TWO_PI
}
dsv.xll = dsv.xll + pl
dsv.omgadf = xls - dsv.xll - cos(dsv.xinc) * dsv.xnode
}
}
// Entrance for deep space secular effects
fun dpsec(params: OrbitalData) {
dsv.xll = dsv.xll + ssl * dsv.t
dsv.omgadf = dsv.omgadf + ssg * dsv.t
dsv.xnode = dsv.xnode + ssh * dsv.t
dsv.em = params.eccn + sse * dsv.t
dsv.xinc = params.xincl + ssi * dsv.t
if (dsv.xinc < 0) {
dsv.xinc = -dsv.xinc
dsv.xnode = dsv.xnode + PI
dsv.omgadf = dsv.omgadf - PI
}
if (!resonance) return
do processEpochRestartLoop() while (doLoop && epochRestart)
dsv.xn = xni + xndot * ft + xnddt * ft * ft * 0.5
xl = xli + xldot * ft + xndot * ft * ft * 0.5
temp = -dsv.xnode + thgr + dsv.t * tHdt
if (synchronous) dsv.xll = xl - dsv.omgadf + temp else dsv.xll = xl + temp + temp
}
private fun doSolarTerms() {
savtsn = 1E20
zcosg = 1.945905E-1
zsing = zSings
zcosi = 9.1744867E-1
zsini = zSinis
zcosh = cosq
zsinh = sinq
cc = c1ss
zn = zNs
ze = zEs
xnoi = invert(xnq)
calculateSolarTerms()
calculateLunarTerms()
calculateSolarTerms() // Solar terms done again after Lunar terms are done
sse += se
ssi += si
ssl += sl
ssg = ssg + sgh - dsv.cosio / dsv.sinio * sh
ssh += sh / dsv.sinio
}
private fun calculateLunarTerms() {
sse = se
ssi = si
ssl = sl
ssh = sh / dsv.sinio
ssg = sgh - dsv.cosio * ssh
se2 = ee2
si2 = xi2
sl2 = xl2
sgh2 = xgh2
sh2 = xh2
se3 = e3
si3 = xi3
sl3 = xl3
sgh3 = xgh3
sh3 = xh3
sl4 = xl4
sgh4 = xgh4
zcosg = zcosgl
zsing = zsingl
zcosi = zcosil
zsini = zsinil
zcosh = zcoshl * cosq + zsinhl * sinq
zsinh = sinq * zcoshl - cosq * zsinhl
zn = zNl
cc = c1l
ze = zEl
}
private fun calculateSolarTerms() {
a1 = zcosg * zcosh + zsing * zcosi * zsinh
a3 = -zsing * zcosh + zcosg * zcosi * zsinh
a7 = -zcosg * zsinh + zsing * zcosi * zcosh
a8 = zsing * zsini
a9 = zsing * zsinh + zcosg * zcosi * zcosh
a10 = zcosg * zsini
a2 = dsv.cosio * a7 + dsv.sinio * a8
a4 = dsv.cosio * a9 + dsv.sinio * a10
a5 = -dsv.sinio * a7 + dsv.cosio * a8
a6 = -dsv.sinio * a9 + dsv.cosio * a10
x1 = a1 * dsv.cosg + a2 * dsv.sing
x2 = a3 * dsv.cosg + a4 * dsv.sing
x3 = -a1 * dsv.sing + a2 * dsv.cosg
x4 = -a3 * dsv.sing + a4 * dsv.cosg
x5 = a5 * dsv.sing
x6 = a6 * dsv.sing
x7 = a5 * dsv.cosg
x8 = a6 * dsv.cosg
z31 = 12 * x1 * x1 - 3.0 * x3 * x3
z32 = 24 * x1 * x2 - 6 * x3 * x4
z33 = 12 * x2 * x2 - 3.0 * x4 * x4
z1 = 3.0 * (a1 * a1 + a2 * a2) + z31 * dsv.eosq
z2 = 6.0 * (a1 * a3 + a2 * a4) + z32 * dsv.eosq
z3 = 3.0 * (a3 * a3 + a4 * a4) + z33 * dsv.eosq
z11 = -6 * a1 * a5 + dsv.eosq * (-24 * x1 * x7 - 6 * x3 * x5)
z12 =
-6 * (a1 * a6 + a3 * a5) + dsv.eosq * (-24 * (x2 * x7 + x1 * x8) - 6 * (x3 * x6 + x4 * x5))
z13 = -6 * a3 * a6 + dsv.eosq * (-24 * x2 * x8 - 6 * x4 * x6)
z21 = 6.0 * a2 * a5 + dsv.eosq * (24 * x1 * x5 - 6 * x3 * x7)
z22 =
6.0 * (a4 * a5 + a2 * a6) + dsv.eosq * (24 * (x2 * x5 + x1 * x6) - 6 * (x4 * x7 + x3 * x8))
z23 = 6.0 * a4 * a6 + dsv.eosq * (24 * x2 * x6 - 6 * x4 * x8)
z1 += z1 + dsv.betao2 * z31
z2 += z2 + dsv.betao2 * z32
z3 += z3 + dsv.betao2 * z33
s3 = cc * xnoi
s2 = -0.5 * s3 / dsv.betao
s4 = s3 * dsv.betao
s1 = -15 * eq * s4
s5 = x1 * x3 + x2 * x4
s6 = x2 * x3 + x1 * x4
s7 = x2 * x4 - x1 * x3
se = s1 * zn * s5
si = s2 * zn * (z11 + z13)
sl = -zn * s3 * (z1 + z3 - 14 - 6 * dsv.eosq)
sgh = s4 * zn * (z31 + z33 - 6)
sh = -zn * s2 * (z21 + z23)
if (xqncl < 5.2359877E-2) sh = 0.0
ee2 = 2.0 * s1 * s6
e3 = 2.0 * s1 * s7
xi2 = 2.0 * s2 * z12
xi3 = 2.0 * s2 * (z13 - z11)
xl2 = -2 * s3 * z2
xl3 = -2 * s3 * (z3 - z1)
xl4 = -2 * s3 * (-21 - 9 * dsv.eosq) * ze
xgh2 = 2.0 * s4 * z32
xgh3 = 2.0 * s4 * (z33 - z31)
xgh4 = -18 * s4 * ze
xh2 = -2 * s2 * z22
xh3 = -2 * s2 * (z23 - z21)
}
private fun processEpochRestartLoop() {
if (atime == 0.0 || dsv.t >= 0 && atime < 0 || dsv.t < 0 && atime >= 0) {
calculateDelta()
atime = 0.0
xni = xnq
xli = xlamo
} else if (abs(dsv.t) >= abs(atime)) calculateDelta()
processNotEpochRestartLoop()
}
private fun calculateDelta() {
delt = if (dsv.t < 0) stepn else stepp
}
private fun processNotEpochRestartLoop() {
do {
if (abs(dsv.t - atime) >= stepp) {
doLoop = true
epochRestart = false
} else {
ft = dsv.t - atime
doLoop = false
}
if (abs(dsv.t) < abs(atime)) {
delt = if (dsv.t >= 0) stepn else stepp
doLoop = doLoop or epochRestart
}
if (synchronous) {
xndot = del1 * sin(xli - fasx2) + del2 * sin(2.0 * (xli - fasx4))
+del3 * sin(3.0 * (xli - fasx6))
xnddt = del1 * cos(xli - fasx2) + 2 * del2 * cos(2.0 * (xli - fasx4))
+3.0 * del3 * cos(3.0 * (xli - fasx6))
} else {
xomi = omegaq + dsv.omgdot * atime
x2omi = xomi + xomi
x2li = xli + xli
xndot =
d2201 * sin(x2omi + xli - g22) + d2211 * sin(xli - g22) + (d3210
* sin(xomi + xli - g32)) + d3222 * sin(-xomi + xli - g32) + (d4410
* sin(x2omi + x2li - g44)) + d4422 * sin(x2li - g44) + (d5220
* sin(xomi + xli - g52)) + d5232 * sin(-xomi + xli - g52) + (d5421
* sin(xomi + x2li - g54)) + d5433 * sin(-xomi + x2li - g54)
xnddt =
d2201 * cos(x2omi + xli - g22) + d2211 * cos(xli - g22) + (d3210
* cos(xomi + xli - g32)) + d3222 * cos(-xomi + xli - g32) + (d5220
* cos(xomi + xli - g52)) + d5232 * cos(-xomi + xli - g52) + (2
* (d4410 * cos(x2omi + x2li - g44) + d4422 * cos(x2li - g44) + (d5421
* cos(xomi + x2li - g54)) + d5433 * cos(-xomi + x2li - g54)))
}
xldot = xni + xfact
xnddt *= xldot
if (doLoop) {
xli += xldot * delt + xndot * step2
xni += xndot * delt + xnddt * step2
atime += delt
}
} while (doLoop && !epochRestart)
}
// Apply periodics with Lyddane modification
private fun applyPeriodics() {
sinok = sin(dsv.xnode)
cosok = cos(dsv.xnode)
alfdp = sinis * sinok
betdp = sinis * cosok
dalf = ph * cosok + pinc * cosis * sinok
dbet = -ph * sinok + pinc * cosis * cosok
alfdp += dalf
betdp += dbet
dsv.xnode = mod2PI(dsv.xnode)
xls = dsv.xll + dsv.omgadf + cosis * dsv.xnode
dls = pl + pgh - pinc * dsv.xnode * sinis
xls += dls
xnoh = dsv.xnode
dsv.xnode = atan2(alfdp, betdp)
}
// Calculates the Greenwich Mean Sidereal Time for an epoch, valid 1957 through 2056
private fun thetaG(epoch: Double): Double {
var year = floor(epoch * 1E-3)
var dayOfYear = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
val dayFloor = floor(dayOfYear)
val dayFraction = dayOfYear - dayFloor
dayOfYear = dayFloor
val jd = julianDateOfYear(year) + dayOfYear
dsv.ds50 = jd - 2433281.5 + dayFraction
return mod2PI(6.3003880987 * dsv.ds50 + 1.72944494)
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.predict
interface IDatabaseRepo {
suspend fun updateFromFile(uri: String)
suspend fun updateFromWeb()
suspend fun clearAllData()
}
@@ -0,0 +1,30 @@
package com.rtbishop.look4sat.domain.predict
import com.rtbishop.look4sat.domain.model.GeoPos
import com.rtbishop.look4sat.domain.model.SatPass
import com.rtbishop.look4sat.domain.model.SatPos
import com.rtbishop.look4sat.domain.model.SatRadio
import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo {
val passes: StateFlow<List<SatPass>>
val satellites: StateFlow<List<Satellite>>
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun initRepository()
suspend fun getPosition(sat: Satellite, pos: GeoPos, time: Long): SatPos
suspend fun getTrack(sat: Satellite, pos: GeoPos, start: Long, end: Long): List<SatPos>
suspend fun processRadios(
sat: Satellite, pos: GeoPos, radios: List<SatRadio>, time: Long
): List<SatRadio>
suspend fun processPasses(passList: List<SatPass>, time: Long): List<SatPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int = 8, minElevation: Double = 16.0)
}
@@ -0,0 +1,23 @@
package com.rtbishop.look4sat.domain.predict
import com.rtbishop.look4sat.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentType(): String
fun getTypesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setType(type: String)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
suspend fun saveSelection()
}
@@ -0,0 +1,148 @@
/*
* 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.predict
import com.rtbishop.look4sat.domain.model.DatabaseState
import com.rtbishop.look4sat.domain.model.GeoPos
import com.rtbishop.look4sat.domain.model.OtherSettings
import com.rtbishop.look4sat.domain.model.PassesSettings
import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo {
val radioSourceUrl: String get() = "https://db.satnogs.org/api/transmitters/?format=json"
val satelliteSourcesMap: Map<String, String>
get() = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv"
)
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setGpsPosition(): Boolean
fun setGeoPosition(latitude: Double, longitude: Double, altitude: Double = 0.0): Boolean
fun setQthPosition(locator: String): Boolean
//endregion
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun saveDatabaseState(state: DatabaseState)
fun saveSatType(type: String, catnums: List<Int>)
fun loadSatType(type: String): List<Int>
//endregion
//region # Entries selection settings
val satelliteSelection: StateFlow<List<Int>>
fun saveEntriesSelection(catnums: List<Int>)
//endregion
//region # Passes filter settings
val passesSettings: StateFlow<PassesSettings>
fun savePassesSettings(settings: PassesSettings)
fun saveModesSelection(modes: List<String>)
fun loadModesSelection(): List<String>
//endregion
//region # Other settings
val otherSettings: StateFlow<OtherSettings>
fun toggleUtc(value: Boolean)
fun toggleUpdate(value: Boolean)
fun toggleSweep(value: Boolean)
fun toggleSensor(value: Boolean)
//endregion
//region # Undefined settings
fun getRotatorEnabled(): Boolean
fun setRotatorEnabled(value: Boolean)
fun getRotatorServer(): String
fun setRotatorServer(value: String)
fun getRotatorPort(): String
fun setRotatorPort(value: String)
fun getBTEnabled(): Boolean
fun setBTEnabled(value: Boolean)
fun getBTDeviceAddr(): String
fun setBTDeviceAddr(value: String)
fun getBTDeviceName(): String
fun setBTDeviceName(value: String)
fun getBTFormat(): String
fun setBTFormat(value: String)
//endregion
}
@@ -0,0 +1,227 @@
/*
* 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.predict
import com.rtbishop.look4sat.domain.model.OrbitalData
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
class NearEarthSatellite(data: OrbitalData) : Satellite(data) {
private val aodp: Double
private val aycof: Double
private val c1: Double
private val c4: Double
private val c5: Double
private val cosio: Double
private var d2 = 0.0
private var d3 = 0.0
private var d4 = 0.0
private val delmo: Double
private val omgcof: Double
private val eta: Double
private val omgdot: Double
private val sinio: Double
private val xnodp: Double
private val sinmo: Double
private val t2cof: Double
private var t3cof = 0.0
private var t4cof = 0.0
private var t5cof = 0.0
private val x1mth2: Double
private val x3thm1: Double
private val x7thm1: Double
private val xmcof: Double
private val xmdot: Double
private val xnodcf: Double
private val xnodot: Double
private val xlcof: Double
private val sgp4Simple: Boolean
init {
// Recover original mean motion (xnodp) and semimajor axis (aodp) from input elements
val a1 = (XKE / data.xno).pow(TWO_THIRDS)
cosio = cos(data.xincl)
val theta2 = sqr(cosio)
x3thm1 = 3.0 * theta2 - 1.0
val eo = data.eccn
val eosq = sqr(eo)
val betao2 = 1.0 - eosq
val betao = sqrt(betao2)
val del1 = 1.5 * CK2 * x3thm1 / (sqr(a1) * betao * betao2)
val ao = a1 * (1.0 - del1 * (0.5 * TWO_THIRDS + del1 * (1.0 + 134.0 / 81.0 * del1)))
val delo = 1.5 * CK2 * x3thm1 / (sqr(ao) * betao * betao2)
xnodp = data.xno / (1.0 + delo)
aodp = ao / (1.0 - delo)
// For perigee less than 220 kilometers, the "simple" flag is set
sgp4Simple = aodp * (1.0 - eo) < 220 / EARTH_RADIUS + 1.0
// For perigees below 156 km, the values of S and QOMS2T are altered
setPerigee((aodp * (1.0 - eo) - 1.0) * EARTH_RADIUS)
val pinvsq = invert(sqr(aodp) * sqr(betao2))
val tsi = invert(aodp - s4)
eta = aodp * eo * tsi
val etasq = eta * eta
val eeta = eo * eta
val psisq = abs(1.0 - etasq)
val coef = qoms24 * tsi.pow(4.0)
val coef1 = coef / psisq.pow(3.5)
val bstar = data.bstar
val c2 = coef1 * xnodp * (aodp * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq)) + 0.75
* CK2 * tsi / psisq * x3thm1 * (8.0 + 3.0 * etasq * (8.0 + etasq)))
c1 = bstar * c2
sinio = sin(data.xincl)
val a3ovk2 = -J3_HARMONIC / CK2
val c3 = coef * tsi * a3ovk2 * xnodp * sinio / eo
x1mth2 = 1.0 - theta2
val omegao = data.omegao
c4 = 2 * xnodp * coef1 * aodp * betao2 * (eta * (2.0 + 0.5 * etasq) + eo * (0.5 + 2 * etasq)
- 2 * CK2 * tsi / (aodp * psisq) * (-3 * x3thm1 * (1.0 - 2 * eeta + etasq
* (1.5 - 0.5 * eeta)) + 0.75 * x1mth2 * (2.0 * etasq - eeta * (1.0 + etasq))
* cos(2.0 * omegao)))
c5 = 2.0 * coef1 * aodp * betao2 * (1.0 + 2.75 * (etasq + eeta) + eeta * etasq)
val theta4 = sqr(theta2)
val temp1 = 3.0 * CK2 * pinvsq * xnodp
val temp2 = temp1 * CK2 * pinvsq
val temp3 = 1.25 * CK4 * pinvsq * pinvsq * xnodp
xmdot =
xnodp + 0.5 * temp1 * betao * x3thm1 + (0.0625 * temp2 * betao * (13.0 - 78.0 * theta2 + 137.0 * theta4))
val x1m5th = 1.0 - 5.0 * theta2
omgdot =
-0.5 * temp1 * x1m5th + 0.0625 * temp2 * (7.0 - 114.0 * theta2 + 395.0 * theta4) + temp3 * (3.0 - 36.0 * theta2 + 49.0 * theta4)
val xhdot1 = -temp1 * cosio
xnodot =
xhdot1 + (0.5 * temp2 * (4.0 - 19.0 * theta2) + 2.0 * temp3 * (3.0 - 7.0 * theta2)) * cosio
omgcof = bstar * c3 * cos(omegao)
xmcof = -TWO_THIRDS * coef * bstar / eeta
xnodcf = 3.5 * betao2 * xhdot1 * c1
t2cof = 1.5 * c1
xlcof = 0.125 * a3ovk2 * sinio * (3.0 + 5 * cosio) / (1.0 + cosio)
aycof = 0.25 * a3ovk2 * sinio
val xmo = data.xmo
delmo = (1.0 + eta * cos(xmo)).pow(3.0)
sinmo = sin(xmo)
x7thm1 = 7.0 * theta2 - 1
if (!sgp4Simple) {
val c1sq = sqr(c1)
d2 = 4.0 * aodp * tsi * c1sq
val temp = d2 * tsi * c1 / 3.0
d3 = (17 * aodp + s4) * temp
d4 = 0.5 * temp * aodp * tsi * (221 * aodp + 31 * s4) * c1
t3cof = d2 + 2 * c1sq
t4cof = 0.25 * (3.0 * d3 + c1 * (12 * d2 + 10 * c1sq))
t5cof = 0.2 * (3.0 * d4 + 12 * c1 * d3 + 6 * d2 * d2 + 15 * c1sq * (2.0 * d2 + c1sq))
} else {
d2 = 0.0
d3 = 0.0
d4 = 0.0
t3cof = 0.0
t4cof = 0.0
t5cof = 0.0
}
}
internal fun calculateSGP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = sqr(tSince)
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
val a = aodp * tempa.pow(2.0)
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
}
}
private fun calculatePosAndVel(
temp: DoubleArray, xnode: Double, a: Double,
xn: Double, axn: Double, ayn: Double
) {
val ecose = temp[5] + temp[6]
val esine = temp[3] - temp[4]
val elsq = sqr(axn) + sqr(ayn)
temp[0] = 1.0 - elsq
val pl = a * temp[0]
val r = a * (1.0 - ecose)
temp[1] = invert(r)
val rdot = XKE * sqrt(a) * esine * temp[1]
val rfdot = XKE * sqrt(pl) * temp[1]
temp[2] = a * temp[1]
val betal = sqrt(temp[0])
temp[3] = invert(1.0 + betal)
val cosu = temp[2] * (temp[8] - axn + ayn * esine * temp[3])
val sinu = temp[2] * (temp[7] - ayn - axn * esine * temp[3])
val u = atan2(sinu, cosu)
val sin2u = 2.0 * sinu * cosu
val cos2u = 2.0 * cosu * cosu - 1
temp[0] = invert(pl)
temp[1] = CK2 * temp[0]
temp[2] = temp[1] * temp[0]
// Update for short periodics
val rk = r * (1.0 - 1.5 * temp[2] * betal * x3thm1) + 0.5 * temp[1] * x1mth2 * cos2u
val uk = u - 0.25 * temp[2] * x7thm1 * sin2u
val xnodek = xnode + 1.5 * temp[2] * cosio * sin2u
val xinck = data.xincl + 1.5 * temp[2] * cosio * sinio * cos2u
val rdotk = rdot - xn * temp[1] * x1mth2 * sin2u
val rfdotk = rfdot + xn * temp[1] * (x1mth2 * cos2u + 1.5 * x3thm1)
super.calculatePosAndVel(rk, uk, xnodek, xinck, rdotk, rfdotk)
}
}
@@ -0,0 +1,435 @@
/*
* 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.predict
import com.rtbishop.look4sat.domain.model.GeoPos
import com.rtbishop.look4sat.domain.model.OrbitalData
import com.rtbishop.look4sat.domain.model.SatPos
import kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
const val ASTRONOMICAL_UNIT = 1.49597870691E8
const val DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137
const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6
const val MIN_PER_DAY = 1.44E3
const val SEC_PER_DAY = 8.6400E4
const val SOLAR_RADIUS = 6.96000E5
const val SPEED_OF_LIGHT = 2.99792458E8
const val PI = 3.141592653589793
const val PI_2 = PI / 2.0
const val TWO_PI = PI * 2.0
const val TWO_THIRDS = 2.0 / 3.0
const val CK2 = 5.413079E-4
const val CK4 = 6.209887E-7
const val XKE = 7.43669161E-2
abstract class Satellite(val data: OrbitalData) {
private val position = Vector4()
private val velocity = Vector4()
private var satPos = SatPos()
private var eclipseDepth = 0.0
private var gsPosTheta = 0.0
private var julUTC = 0.0
private var perigee = 0.0
var qoms24 = 0.0
var s4 = 0.0
internal fun willBeSeen(pos: GeoPos): Boolean {
return if (data.meanmo < 1e-8) false
else {
val sma = 331.25 * exp(ln(MIN_PER_DAY / data.meanmo) * (2.0 / 3.0))
val apogee = sma * (1.0 + data.eccn) - EARTH_RADIUS
var lin = data.incl
if (lin >= 90.0) lin = 180.0 - lin
acos(EARTH_RADIUS / (apogee + EARTH_RADIUS)) + lin * DEG2RAD > abs(pos.latitude * DEG2RAD)
}
}
internal fun getPosition(pos: GeoPos, time: Long): SatPos {
satPos = SatPos()
// Date/time at which the position and velocity were calculated
julUTC = calcCurrentDaynum(time) + 2444238.5
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
val julEpoch = juliandDateOfEpoch(data.epoch)
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
// Scale position and velocity vectors to km and km/sec
convertSatState(position, velocity)
// Calculate velocity of satellite
magnitude(velocity)
val squintVector = Vector4()
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
satPos.time = time
satPos.eclipsed = isEclipsed()
satPos.eclipseDepth = eclipseDepth
return satPos
}
private fun calcCurrentDaynum(now: Long): Double {
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
}
private fun juliandDateOfEpoch(epoch: Double): Double {
var year = floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
return julianDateOfYear(year) + day
}
internal fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
var i = floor(aYear / 100).toLong()
val a = i
i = a / 4
val b = 2 - a + i
i = floor(365.25 * aYear).toLong()
i += (30.6001 * 14).toLong()
return i + 1720994.5 + b
}
private fun calculateSDP4orSGP4(tsince: Double) {
if (data.isDeepSpace) (this as DeepSpaceSatellite).calculateSDP4(tsince)
else (this as NearEarthSatellite).calculateSGP4(tsince)
}
// Converts the sat position and velocity vectors to km and km/sec
private fun convertSatState(pos: Vector4, vel: Vector4) {
scaleVector(EARTH_RADIUS, pos)
scaleVector(EARTH_RADIUS * MIN_PER_DAY / SEC_PER_DAY, vel)
}
// Calculates the topocentric coordinates of the object with ECI pos and vel at time
private fun calculateObs(
julianUTC: Double,
positionVector: Vector4,
velocityVector: Vector4,
gsPos: GeoPos,
squintVector: Vector4
) {
val obsPos = Vector4()
val obsVel = Vector4()
val range = Vector4()
val rgvel = Vector4()
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
range.setXYZ(
positionVector.x - obsPos.x,
positionVector.y - obsPos.y,
positionVector.z - obsPos.z
)
// Save these values globally for calculating squint angles later
squintVector.setXYZ(range.x, range.y, range.z)
rgvel.setXYZ(
velocityVector.x - obsVel.x,
velocityVector.y - obsVel.y,
velocityVector.z - obsVel.z
)
magnitude(range)
val sinLat = sin(DEG2RAD * gsPos.latitude)
val cosLat = cos(DEG2RAD * gsPos.latitude)
val sinTheta = sin(gsPosTheta)
val cosTheta = cos(gsPosTheta)
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
val topE = -sinTheta * range.x + cosTheta * range.y
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
satPos.azimuth = azim
satPos.elevation = asin(topZ / range.w)
satPos.distance = range.w
satPos.distanceRate = dot(range, rgvel) / range.w
var elevation = satPos.elevation / TWO_PI * 360.0
if (elevation > 90) elevation = 180 - elevation
satPos.aboveHorizon = elevation - 0 > EPSILON
}
// Returns the ECI position and velocity of the observer
private fun calculateUserPosVel(
time: Double,
gsPos: GeoPos,
obsPos: Vector4,
obsVel: Vector4
) {
val mFactor = 7.292115E-5
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
val sq = sqr(1.0 - FLAT_FACT) * c
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
obsPos.setXYZ(
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
)
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
magnitude(obsPos)
magnitude(obsVel)
}
// Calculate the geodetic position of an object given its ECI pos and time
private fun calculateLatLonAlt(time: Double) {
satPos.theta = atan2(position.y, position.x)
satPos.longitude = mod2PI(satPos.theta - thetaGJD(time))
val r = sqrt(sqr(position.x) + sqr(position.y))
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
satPos.latitude = atan2(position.z, r)
var phi: Double
var c: Double
var i = 0
var converged: Boolean
do {
phi = satPos.latitude
c = invert(sqrt(1.0 - e2 * sqr(sin(phi))))
satPos.latitude = atan2(position.z + EARTH_RADIUS * c * e2 * sin(phi), r)
converged = abs(satPos.latitude - phi) < EPSILON
} while (i++ < 10 && !converged)
satPos.altitude = r / cos(satPos.latitude) - EARTH_RADIUS * c
var temp = satPos.latitude
if (temp > PI_2) {
temp -= TWO_PI
satPos.latitude = temp
}
}
internal fun calculatePosAndVel(
rk: Double, uk: Double, xnodek: Double,
xinck: Double, rdotk: Double, rfdotk: Double
) {
// Orientation vectors
val sinuk = sin(uk)
val cosuk = cos(uk)
val sinik = sin(xinck)
val cosik = cos(xinck)
val sinnok = sin(xnodek)
val cosnok = cos(xnodek)
val xmx = -sinnok * cosik
val xmy = cosnok * cosik
val ux = xmx * sinuk + cosnok * cosuk
val uy = xmy * sinuk + sinnok * cosuk
val uz = sinik * sinuk
val vx = xmx * cosuk - cosnok * sinuk
val vy = xmy * cosuk - sinnok * sinuk
val vz = sinik * cosuk
// Position and velocity
position.setXYZ(ux, uy, uz)
position.multiply(rk)
velocity.x = rdotk * ux + rfdotk * vx
velocity.y = rdotk * uy + rfdotk * vy
velocity.z = rdotk * uz + rfdotk * vz
}
internal class Vector4(
var w: Double = 0.0,
var x: Double = 0.0,
var y: Double = 0.0,
var z: Double = 0.0
) {
fun multiply(multiplier: Double) {
x *= multiplier
y *= multiplier
z *= multiplier
}
fun setXYZ(xValue: Double, yValue: Double, zValue: Double) {
x = xValue
y = yValue
z = zValue
}
}
internal fun sqr(arg: Double): Double {
return arg * arg
}
internal fun invert(value: Double): Double {
return 1.0 / value
}
// Calculates the modulus of 2 * PI
internal fun mod2PI(value: Double): Double {
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
var converged = false
var i = 0
do {
temp[7] = sin(temp[2])
temp[8] = cos(temp[2])
temp[3] = axn * temp[7]
temp[4] = ayn * temp[8]
temp[5] = axn * temp[8]
temp[6] = ayn * temp[7]
val epw = (capu - temp[4] + temp[3] - temp[2]) / (1.0 - temp[5] - temp[6]) + temp[2]
if (abs(epw - temp[2]) <= EPSILON) converged = true else temp[2] = epw
} while (i++ < 10 && !converged)
}
internal fun calculatePhase(xlt: Double, xnode: Double, omgadf: Double) {
var phaseValue = xlt - xnode - omgadf + TWO_PI
if (phaseValue < 0.0) phaseValue += TWO_PI
satPos.phase = mod2PI(phaseValue)
}
// Sets perigee and checks and adjusts the calculation if the perigee is less tan 156KM
internal fun setPerigee(perigee: Double) {
this.perigee = perigee
checkPerigee()
}
// Checks and adjusts the calculation if the perigee is less tan 156KM
private fun checkPerigee() {
s4 = 1.012229
qoms24 = 1.880279E-09
if (perigee < 156.0) {
s4 = if (perigee <= 98.0) 20.0 else perigee - 78.0
qoms24 = ((120 - s4) / EARTH_RADIUS).pow(4.0)
s4 = s4 / EARTH_RADIUS + 1.0
}
}
// Checks if the satellite is in sunlight
private fun isEclipsed(): Boolean {
val sunVector = calculateSunVector()
val sdEarth = asin(EARTH_RADIUS / position.w)
val rho = subtract(sunVector, position)
val sdSun = asin(SOLAR_RADIUS / rho.w)
val earth = scalarNegMultiply(position)
val delta = angle(sunVector, earth)
eclipseDepth = sdEarth - sdSun - delta
return if (sdEarth < sdSun) false else eclipseDepth >= 0
}
private fun calculateSunVector(): Vector4 {
val mjd = julUTC - 2415020.0
val year = 1900 + mjd / 365.25
val solTime = (mjd + deltaEt(year) / SEC_PER_DAY) / 36525.0
val mTemp = modulus(35999.04975 * solTime, 360.0)
val m = radians(
modulus(358.47583 + mTemp - (0.000150 + 0.0000033 * solTime) * sqr(solTime), 360.0)
)
val lTemp = modulus(36000.76892 * solTime, 360.0)
val l = radians(
modulus(279.69668 + lTemp + 0.0003025 * sqr(solTime), 360.0)
)
val e = 0.01675104 - (0.0000418 + 0.000000126 * solTime) * solTime
val c = radians(
((1.919460 - (0.004789 + 0.000014 * solTime) * solTime) * sin(m))
+ ((0.020094 - 0.000100 * solTime) * sin(2 * m)) + 0.000293 * sin(3 * m)
)
val o = radians(modulus(259.18 - 1934.142 * solTime, 360.0))
val lsa = modulus(l + c - radians(0.00569 - 0.00479 * sin(o)), TWO_PI)
val nu = modulus(m + c, TWO_PI)
var r = (1.0000002 * (1.0 - sqr(e)) / (1.0 + e * cos(nu)))
val eps = radians(
23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * solTime) * solTime)
* solTime + 0.00256 * cos(o)
)
r *= ASTRONOMICAL_UNIT
return Vector4(r, r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps))
}
private fun subtract(v1: Vector4, v2: Vector4): Vector4 {
val v3 = Vector4()
v3.x = v1.x - v2.x
v3.y = v1.y - v2.y
v3.z = v1.z - v2.z
magnitude(v3)
return v3
}
private fun scalarNegMultiply(vector: Vector4): Vector4 {
val neg = -1.0
return Vector4(vector.w * abs(neg), vector.x * neg, vector.y * neg, vector.z * neg)
}
private fun angle(v1: Vector4, v2: Vector4): Double {
magnitude(v1)
magnitude(v2)
return acos(dot(v1, v2) / (v1.w * v2.w))
}
private fun deltaEt(year: Double): Double {
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
}
private fun radians(degrees: Double): Double {
return degrees * DEG2RAD
}
// Calculates the dot product of two vectors
private fun dot(v1: Vector4, v2: Vector4): Double {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
}
// Returns fractional part of double argument
private fun fraction(arg: Double): Double {
return arg - floor(arg)
}
// Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
var returnValue = arg1
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
// Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) {
v.multiply(k)
magnitude(v)
}
private fun thetaGJD(theJD: Double): Double {
val earthRotPerSidDay = 1.00273790934
val ut = fraction(theJD + 0.5)
val aJD = theJD - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
return TWO_PI * gmst / SEC_PER_DAY
}
}
@@ -0,0 +1,74 @@
/*
* 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.predict.DEG2RAD
import com.rtbishop.look4sat.domain.predict.RAD2DEG
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
return String.format(format, hours, minutes, seconds)
}
fun Float.round(decimals: Int): Float {
var multiplier = 1.0f
repeat(decimals) { multiplier *= 10 }
return kotlin.math.round(this * multiplier) / multiplier
}
fun Double.round(decimals: Int): Double {
var multiplier = 1.0
repeat(decimals) { multiplier *= 10 }
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())
// val result = StringBuilder(bytes.size * 2)
// bytes.forEach {
// val i = it.toInt()
// result.append(hexChars[i shr 4 and 0x0f])
// result.append(hexChars[i and 0x0f])
// }
// return result.toString()
//}
//fun String.isValidEmail(): Boolean {
// val expression = "^[\\w.-]+@([\\w\\-]+\\.)+[A-Z]{2,8}$"
// val pattern = Pattern.compile(expression, Pattern.CASE_INSENSITIVE)
// 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.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,61 @@
/*
* 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.GeoPos
object QthConverter {
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val tempLon = if (longitude > 180.0) longitude - 180 else longitude
val upper = "ABCDEFGHIJKLMNOPQRSTUVWX"
val lower = "abcdefghijklmnopqrstuvwx"
val newLongitude = tempLon + 180
val newLatitude = latitude + 90
val lonFirst = upper[(newLongitude / 20).toInt()]
val latFirst = upper[(newLatitude / 10).toInt()]
val lonSecond = ((newLongitude / 2) % 10).toInt().toString()
val latSecond = (newLatitude % 10).toInt().toString()
val lonThird = lower[((newLongitude % 2) * 12).toInt()]
val latThird = lower[((newLatitude % 1) * 24).toInt()]
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
}
}
@@ -0,0 +1,115 @@
/*
* 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.data.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
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
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
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
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)
assert(parsedList[1].epoch == 24069.23963816)
}
@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)
assert(parsedList[1].epoch == 24069.23963816)
}
@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())
}
@Test
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
val answers = listOf(false, true, false, true, false, true, false, true)
val results = years.map { dataParser.isLeapYear(it) }
assert(results == answers)
}
}
@@ -0,0 +1,53 @@
/*
* 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.QthConverter
import org.junit.Test
class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = QthConverter.qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = QthConverter.qthToPosition("JN58TD")
assert(result?.latitude == 48.1458 && result.longitude == 11.6250)
result = QthConverter.qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(QthConverter.qthToPosition("ZZ00zz") == null)
assert(QthConverter.qthToPosition("JN58") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
assert(QthConverter.positionToQth(51.4878, -0.2146) == "IO91vl")
assert(QthConverter.positionToQth(48.1466, 11.6083) == "JN58td")
assert(QthConverter.positionToQth(-34.91, -56.2116) == "GF15vc")
}
@Test
fun `Given invalid POS returns null`() {
assert(QthConverter.positionToQth(91.0542, -170.1142) == null)
assert(QthConverter.positionToQth(89.0542, -240.1142) == null)
}
}