mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 03:15:37 +00:00
Added colored elevation and decay check to satellite passes
This commit is contained in:
1 parent
18c8ac1822
commit
f7f5a73c4c
8 files changed
+213
-28
No files matched your search
@@ -146,17 +146,18 @@ class SatelliteRepo(
|
|||||||
val passes = mutableListOf<OrbitalPass>()
|
val passes = mutableListOf<OrbitalPass>()
|
||||||
val endDate = time + hours * 60L * 60L * 1000L
|
val endDate = time + hours * 60L * 60L * 1000L
|
||||||
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
|
||||||
|
val decayed = this.data.hasDecayed(time)
|
||||||
var startDate = time
|
var startDate = time
|
||||||
var shouldRewind = true
|
var shouldRewind = true
|
||||||
var lastAosDate: Long
|
var lastAosDate: Long
|
||||||
var count = 0
|
var count = 0
|
||||||
if (this.willBeSeen(pos)) {
|
if (this.willBeSeen(pos)) {
|
||||||
if (this.data.isDeepSpace) {
|
if (this.data.isDeepSpace) {
|
||||||
passes.add(getGeoPass(this, pos, time))
|
passes.add(getGeoPass(this, pos, time, decayed))
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
if (count > 0) shouldRewind = false
|
if (count > 0) shouldRewind = false
|
||||||
val pass = getLeoPass(this, pos, startDate, shouldRewind)
|
val pass = getLeoPass(this, pos, startDate, shouldRewind, decayed)
|
||||||
lastAosDate = pass.aosTime
|
lastAosDate = pass.aosTime
|
||||||
passes.add(pass)
|
passes.add(pass)
|
||||||
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
|
||||||
@@ -167,17 +168,17 @@ class SatelliteRepo(
|
|||||||
return passes
|
return passes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
|
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, decayed: Boolean): OrbitalPass {
|
||||||
val satPos = sat.getPosition(pos, time)
|
val satPos = sat.getPosition(pos, time)
|
||||||
val aos = time - 24 * 60L * 60L * 1000L
|
val aos = time - 24 * 60L * 60L * 1000L
|
||||||
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
|
||||||
val az = satPos.azimuth.toDegrees().round(1)
|
val az = satPos.azimuth.toDegrees().round(1)
|
||||||
val elev = satPos.elevation.toDegrees().round(1)
|
val elev = satPos.elevation.toDegrees().round(1)
|
||||||
val alt = satPos.altitude
|
val alt = satPos.altitude
|
||||||
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
|
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean, decayed: Boolean): OrbitalPass {
|
||||||
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
|
||||||
var calendarTimeMillis = time
|
var calendarTimeMillis = time
|
||||||
var elevation: Double
|
var elevation: Double
|
||||||
@@ -241,6 +242,6 @@ class SatelliteRepo(
|
|||||||
val alt = tcaPos.altitude
|
val alt = tcaPos.altitude
|
||||||
|
|
||||||
val elev = maxElevation.toDegrees().round(1)
|
val elev = maxElevation.toDegrees().round(1)
|
||||||
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
|
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat, hasDecayed = decayed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,8 @@ data class OrbitalData(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
) {
|
) {
|
||||||
val xincl: Double = incl * DEG2RAD
|
val xincl: Double = incl * DEG2RAD
|
||||||
val xnodeo: Double = raan * DEG2RAD
|
val xnodeo: Double = raan * DEG2RAD
|
||||||
@@ -37,4 +38,29 @@ data class OrbitalData(
|
|||||||
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
|
||||||
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
|
||||||
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
|
||||||
|
|
||||||
|
/** Check if satellite has likely decayed by the given time. */
|
||||||
|
fun hasDecayed(currentTimeMillis: Long): Boolean {
|
||||||
|
if (ndot == 0.0) return false
|
||||||
|
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0
|
||||||
|
val epochDaynum = epochToDaynum(epoch)
|
||||||
|
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun epochToDaynum(epoch: Double): Double {
|
||||||
|
var year = kotlin.math.floor(epoch * 1E-3)
|
||||||
|
val day = (epoch * 1E-3 - year) * 1000.0
|
||||||
|
year = if (year < 57) year + 2000 else year + 1900
|
||||||
|
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
|
||||||
|
val jan1Jd = julianDateOfYear(year)
|
||||||
|
return jan1Jd + day - 2444238.5
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun julianDateOfYear(theYear: Double): Double {
|
||||||
|
val aYear = theYear - 1
|
||||||
|
val a = kotlin.math.floor(aYear / 100).toLong()
|
||||||
|
val b = 2 - a + a / 4
|
||||||
|
val i = kotlin.math.floor(365.25 * aYear).toLong()
|
||||||
|
return i + (30.6001 * 14).toLong() + 1720994.5 + b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,9 +25,10 @@ data class OrbitalPass(
|
|||||||
val altitude: Int = 1000,
|
val altitude: Int = 1000,
|
||||||
val maxElevation: Double = 75.0,
|
val maxElevation: Double = 75.0,
|
||||||
val orbitalObject: OrbitalObject,
|
val orbitalObject: OrbitalObject,
|
||||||
val progress: Float = 0.0f
|
val progress: Float = 0.0f,
|
||||||
|
val hasDecayed: Boolean = false
|
||||||
) {
|
) {
|
||||||
val catNum: Int = orbitalObject.data.catnum
|
val catNum: Int = orbitalObject.data.catnum
|
||||||
val name: String = orbitalObject.data.name
|
val name: String = if (hasDecayed) "${orbitalObject.data.name} (decayed?)" else orbitalObject.data.name
|
||||||
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
argper = values[7].toDouble(),
|
argper = values[7].toDouble(),
|
||||||
meanan = values[8].toDouble(),
|
meanan = values[8].toDouble(),
|
||||||
catnum = values[11].toInt(),
|
catnum = values[11].toInt(),
|
||||||
bstar = values[14].toDouble()
|
bstar = values[14].toDouble(),
|
||||||
|
ndot = values[15].toDouble()
|
||||||
)
|
)
|
||||||
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
}.onFailure { println("CSV parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
@@ -99,13 +100,15 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
|
|||||||
argper = line2.substring(34, 42).toDouble(),
|
argper = line2.substring(34, 42).toDouble(),
|
||||||
meanan = line2.substring(43, 51).toDouble(),
|
meanan = line2.substring(43, 51).toDouble(),
|
||||||
catnum = line1.substring(2, 7).trim().toInt(),
|
catnum = line1.substring(2, 7).trim().toInt(),
|
||||||
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble())
|
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
|
||||||
|
ndot = line1.substring(33, 43).trim().toDouble()
|
||||||
)
|
)
|
||||||
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
}.onFailure { println("TLE parsing exception: $it") }.getOrNull()
|
||||||
|
|
||||||
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
|
||||||
val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
|
||||||
|
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
return daysInMonth.take(month - 1).sum() + dayOfMonth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,53 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
val parsedList = dataParser.parseCSVStream(validCSVStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList[0].epoch == 21320.51955234)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(parsedList[1].epoch == 24069.23963816)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.bstar == 0.31985E-4)
|
||||||
|
assert(sat.ndot == 0.1288E-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// ISS is healthy, should not be decayed even years later
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
|
||||||
|
// Simulate a satellite with high drag and old epoch that should have decayed
|
||||||
|
val csvStream = """
|
||||||
|
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
|
||||||
|
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseCSVStream(csvStream)[0]
|
||||||
|
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
|
||||||
|
assert(sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
|
||||||
@@ -71,10 +114,41 @@ class DataParserTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
|
||||||
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
val parsedList = dataParser.parseTLEStream(validTLEStream)
|
||||||
|
assert(parsedList.size == 2)
|
||||||
assert(parsedList[0].epoch == 21320.51955234)
|
assert(parsedList[0].epoch == 21320.51955234)
|
||||||
assert(parsedList[1].epoch == 24069.23963816)
|
assert(parsedList[1].epoch == 24069.23963816)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
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
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(sat.name == "ISS (ZARYA)")
|
||||||
|
assert(sat.catnum == 25544)
|
||||||
|
assert(sat.meanmo == 15.48582035)
|
||||||
|
assert(sat.eccn == 0.0004694)
|
||||||
|
assert(sat.incl == 51.6447)
|
||||||
|
assert(sat.raan == 309.4881)
|
||||||
|
assert(sat.argper == 203.6966)
|
||||||
|
assert(sat.meanan == 299.8876)
|
||||||
|
assert(sat.ndot == 0.00001288)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid TLE stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
|
||||||
|
val tleStream = """
|
||||||
|
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
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val sat = dataParser.parseTLEStream(tleStream)[0]
|
||||||
|
assert(!sat.hasDecayed(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
|
||||||
@@ -85,6 +159,43 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"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","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
|
||||||
|
assert(radio.info == "Upper side band (drifting)")
|
||||||
|
assert(radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 136658500L)
|
||||||
|
assert(radio.downlinkHigh == 136700000L)
|
||||||
|
assert(radio.downlinkMode == "USB")
|
||||||
|
assert(radio.uplinkLow == 145900000L)
|
||||||
|
assert(radio.uplinkHigh == 146000000L)
|
||||||
|
assert(radio.uplinkMode == "FM")
|
||||||
|
assert(radio.isInverted)
|
||||||
|
assert(radio.catnum == 965)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
|
||||||
|
val jsonStream = """
|
||||||
|
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
|
val radio = dataParser.parseJSONStream(jsonStream)[0]
|
||||||
|
assert(radio.uuid == "abc123")
|
||||||
|
assert(!radio.isAlive)
|
||||||
|
assert(radio.downlinkLow == 145800000L)
|
||||||
|
assert(radio.downlinkHigh == null)
|
||||||
|
assert(radio.downlinkMode == null)
|
||||||
|
assert(radio.uplinkLow == null)
|
||||||
|
assert(radio.uplinkHigh == null)
|
||||||
|
assert(radio.uplinkMode == null)
|
||||||
|
assert(!radio.isInverted)
|
||||||
|
assert(radio.catnum == 12345)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
|
||||||
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
|
||||||
@@ -95,11 +206,37 @@ class DataParserTest {
|
|||||||
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Test
|
@Test
|
||||||
// fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
|
fun `isLeapYear returns correct results`() {
|
||||||
// val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
|
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
|
||||||
// val answers = listOf(false, true, false, true, false, true, false, true)
|
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
|
||||||
// val results = years.map { dataParser.isLeapYear(it) }
|
val results = years.map { dataParser.isLeapYear(it) }
|
||||||
// assert(results == answers)
|
assert(results == expected)
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for January 1st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
|
||||||
|
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
|
||||||
|
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
|
||||||
|
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
|
||||||
|
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
|
||||||
|
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for December 31st`() {
|
||||||
|
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
|
||||||
|
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDayOfYear returns correct day for November 16th`() {
|
||||||
|
// Matches the CSV test data epoch: 2021-11-16 → day 320
|
||||||
|
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+13
-2
@@ -48,6 +48,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -151,16 +152,17 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_elevation),
|
painter = painterResource(R.drawable.ic_elevation),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = elevColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.maxElevation}°",
|
text = "${pass.maxElevation}°",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = elevColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -379,3 +381,12 @@ fun TopBar(
|
|||||||
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
TopBar { startAction(); topInfo(); bottomInfo(); endAction() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun elevationColor(elevation: Double): Color {
|
||||||
|
return when {
|
||||||
|
elevation < 15.0 -> Color(0xFFEF5350) // soft red for low elevation
|
||||||
|
elevation < 45.0 -> MaterialTheme.colorScheme.primary // accent yellow for normal
|
||||||
|
else -> Color(0xFF66BB6A) // soft green for high elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -54,6 +55,7 @@ import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
|||||||
import com.rtbishop.look4sat.core.presentation.MainTheme
|
import com.rtbishop.look4sat.core.presentation.MainTheme
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
import com.rtbishop.look4sat.core.presentation.SharedDialog
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
|
|
||||||
private val allModes = listOf(
|
private val allModes = listOf(
|
||||||
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
"AFSK", "AFSK S-Net", "AFSK SALSAT", "AHRPT", "AM", "APT", "BPSK", "BPSK PMT-A3",
|
||||||
@@ -96,7 +98,8 @@ internal fun PassesDialog(
|
|||||||
value = elevationValueNew.doubleValue,
|
value = elevationValueNew.doubleValue,
|
||||||
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
displayValue = "${elevationValueNew.doubleValue.toInt()}°",
|
||||||
valueResId = R.drawable.ic_elevation,
|
valueResId = R.drawable.ic_elevation,
|
||||||
valueRange = 0f..60f
|
valueRange = 0f..60f,
|
||||||
|
accentColor = elevationColor(elevationValueNew.doubleValue)
|
||||||
) { elevationValueNew.doubleValue = it.toDouble() }
|
) { elevationValueNew.doubleValue = it.toDouble() }
|
||||||
SliderRow(
|
SliderRow(
|
||||||
title = stringResource(R.string.pass_filter_hours),
|
title = stringResource(R.string.pass_filter_hours),
|
||||||
@@ -117,6 +120,7 @@ private fun SliderRow(
|
|||||||
valueResId: Int,
|
valueResId: Int,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
steps: Int = 0,
|
steps: Int = 0,
|
||||||
|
accentColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
onChange: (Float) -> Unit
|
onChange: (Float) -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -137,14 +141,14 @@ private fun SliderRow(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = valueResId),
|
painter = painterResource(id = valueResId),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = accentColor,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = displayValue,
|
text = displayValue,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = accentColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
Slider(value = value.toFloat(), onValueChange = onChange, valueRange = valueRange, steps = steps)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
|||||||
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
import com.rtbishop.look4sat.core.presentation.SwipeableItem
|
||||||
import com.rtbishop.look4sat.core.presentation.TimerRow
|
import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
|
import com.rtbishop.look4sat.core.presentation.elevationColor
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -331,16 +332,17 @@ private fun PassItem(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
val elevColor = elevationColor(pass.maxElevation)
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(id = R.drawable.ic_elevation),
|
painter = painterResource(id = R.drawable.ic_elevation),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = elevColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "${pass.maxElevation}°",
|
text = "${pass.maxElevation}°",
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = elevColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
Reference in new issue
Block a user