From 7cadded6caae2ac15f0292ebe281bd26be88ce0b Mon Sep 17 00:00:00 2001 From: QIU Date: Thu, 13 Aug 2026 16:20:07 +0000 Subject: [PATCH] fix(data): compute the OMM epoch day fraction numerically, not by string surgery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排查页面顺序问题时顺带审计发现: OMM/CSV 历元在子夜后约 86 秒内会被解析成 完全错误的值, 且不抛异常 —— 静默的错误数据。 ## 根因 DataParser.parseCSV 用字符串拼接构造历元: val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1) val epoch = "${year.substring(2)}$day$frac".toDouble() substring(1) 的意图是切掉 "0.123" 的前导 0。但 Double.toString() 在数值 小于 1e-3 时切换为科学计数法, 于是被切掉的是【有效数字】, 剩下的指数后缀 让整个字符串重新变成一个合法但语义完全错误的 double。 Kotlin 侧实测(单测失败信息): 00:00:01.000 期望 25001.000011574073 实得 0.2500115740740741 00:01:00.000 期望 25001.000694444443 实得 2.5001944444444444 与 JDK 侧独立验证逐位一致。00:01:26.4 之后 frac >= 0.001, 不再用科学计数法, 所以这个 bug 只在每天前 86.4 秒的历元上出现(约占 0.1%), Celestrak OMM 数据 里整分历元并不罕见。 ## 影响 runCatching 抓不到(没有异常), 该卫星的 juliandDateOfEpoch 会推出 year=2000 day≈0, tsince 偏差约 26 年 —— 方位/仰角/过境预报彻底失效, 不是精度下降。 且用户无从察觉。 ## 修复 改为数值相加, 不经过字符串: val dayFraction = (hour + min + sec + ms) / 86400000.0 val epoch = "${year.substring(2)}$day".toDouble() + dayFraction "25001".toDouble() + 0.0000115 = 25001.0000115, 无科学计数法风险。 ## 验证 DataParserTest 新增 5 个历元回归测试(子夜整点/子夜后 1 秒/子夜后 1 分钟/ 正午/当日最后一毫秒)。先确认前两个在旧实现下失败(failures=2), 修复后: - DataParserTest 24 个测试全绿(原有 19 个无回归) - :core:domain:test 全量 105 个测试 0 失败 0 错误 --- .../core/domain/utility/DataParser.kt | 9 +++- .../look4sat/core/domain/DataParserTest.kt | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt index f82d2348..6d17f1ef 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/utility/DataParser.kt @@ -70,8 +70,13 @@ class DataParser(private val dispatcher: CoroutineDispatcher) { val min = timestamp.substring(14, 16).toInt() * 60000 val sec = timestamp.substring(17, 19).toInt() * 1000 val ms = timestamp.substring(20, 26).toInt() / 1000.0 - val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1) - val epoch = "${year.substring(2)}$day$frac".toDouble() + // Add the day fraction numerically. Building it by string surgery breaks + // below 1e-3, where Double.toString() switches to scientific notation and + // dropping the first character removes a significant digit instead of the + // leading zero: 00:01:00 yielded "25001.944444444444445E-4" -> 2.50019, + // a silently valid epoch about 26 years off. + val dayFraction = (hour + min + sec + ms) / 86400000.0 + val epoch = "${year.substring(2)}$day".toDouble() + dayFraction OrbitalData( name = name, epoch = epoch, diff --git a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt index 5d7a5588..93a6f32f 100644 --- a/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt +++ b/core/domain/src/test/java/com/rtbishop/look4sat/core/domain/DataParserTest.kt @@ -21,6 +21,7 @@ import com.rtbishop.look4sat.core.domain.utility.DataParser import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Test @ExperimentalCoroutinesApi @@ -111,6 +112,47 @@ class DataParserTest { assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty()) } + private fun csvWithEpoch(epoch: String) = """ + 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,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 + """.trimIndent().byteInputStream() + + @Test + fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) { + // Regression: the day fraction used to be built by string surgery + // (Double.toString().substring(1)), but toString switches to scientific + // notation below 1e-3, so the leading significant digit was truncated. + // 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch + // roughly 26 years off, with no exception to reveal it. + val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0] + assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9) + } + + @Test + fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) { + val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0] + assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9) + } + + @Test + fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) { + val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0] + assertEquals(25001.0, sat.epoch, 1e-9) + } + + @Test + fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) { + val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0] + assertEquals(25001.5, sat.epoch, 1e-9) + } + + @Test + fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) { + val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0] + assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" } + assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" } + } + @Test fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) { val parsedList = dataParser.parseTLEStream(validTLEStream)