fix(data): compute the OMM epoch day fraction numerically, not by string surgery
排查页面顺序问题时顺带审计发现: 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 错误
This commit is contained in:
1 parent
6fc2f560b7
commit
7cadded6ca
2 files changed
+49
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user