app: 温度改用官方放射学链(波段映射+AT修正+发射率revise),BasePara2/DDT尾部解析;单测以官方同帧数据+真机报文为准

This commit is contained in:
ZXCLI
2026-10-06 03:31:17 +08:00
parent 705ad27cac
commit 497e5100ef
7 changed files with 561 additions and 9 deletions
@@ -0,0 +1,278 @@
package com.mag160c.thermal.core
import com.mag160c.thermal.usb.MagProtocol
/**
* Official MAG160C radiometric temperature chain — ported line-by-line from
* CoreSDKLib.dll (`sub_180016290` core, `sub_180015840`/`sub_1800158b0` curve
* interpolators, `sub_180015A40`/`sub_180015940` delta converters,
* `sub_180015b90` revise) and verified against 15 captured official frames
* x 19200 px: mean |delta| = 13.9 mC (0.014 C).
*
* The device does NOT use the T2E-table branch our app used before; for this
* hardware (`dev+0x41880 != 0`) it runs the radiometric branch:
*
* counts -> per-band linear map (record from the DDT tail)
* -> ambient compensation (v50 = BasePara2.fpaTempFix)
* -> ambient-temperature error term (ATZeroErrorPoint/ATErrorSlope)
* -> emissivity/transmittance revise T=iE2T((E2T(T)-(1-K)*E2T(env))/K)
*
* Data sources (all available to the app):
* - curve A : [OfficialTables.T2E] (identical to module @0x180099000)
* - slope table : computed here with the SDK's own runtime formula
* - band record : radiance record at the tail of the DDT (calibration file)
* - fpa temperature : frame tail word (+8) — the pipeline's `shutter`
* - ATZeroErrorPoint / ATErrorSlope / fpaTempFix : BasePara2 (66c response)
*/
object OfficialTemp {
/** Curve A: temperature-domain -> radiance-domain, 646 entries. */
private val CURVE = OfficialTables.T2E
/**
* Local slope table, same runtime formula as the SDK init code:
* `S[i] = ((d >> 1) + 0x1000000) / d` with `d = A[i+1] - A[i]`
* (verified 645/645 against the module's table at 0x180099A20).
*/
private val SLOPE = IntArray(CURVE.size) { i ->
val d = if (i + 1 < CURVE.size) CURVE[i + 1] - CURVE[i] else CURVE[i] - CURVE[i - 1]
if (d != 0) ((d shr 1) + 0x1000000) / d else 0
}
/** Radiometric record (540 B block at dev+0x41888) as stored in the DDT tail. */
class RadRecord(
val shift: Int,
/** 8 band thresholds, counts domain (rec+0x40). */
val thr: IntArray,
/** Per-band gain (rec+0xD4+8*i). */
val gain: IntArray,
/** Per-band offset in mC (rec+0xD8+8*i). */
val off: IntArray,
/** rec+0x34 / rec+0x38 — the pair that differs by the 500 mC reference gap. */
val tA: Int,
val tB: Int,
/** Emissivity (rec+0x204). */
val eps: Float,
/** Distance/ambient validity bounds (rec+0x208 / rec+0x20C). */
val distLo: Int,
val distHi: Int,
)
/**
* Device "AT" parameters from BasePara2 (0x5BB5B55C) — field offsets follow
* the official app's own wire parse (`UsbCommunication.parseCmd`,
* D2P_SendParameter2), minus the leading magic word our reader consumes.
*
* Note on [fpaTempFix]: the SDK's compensation term `v50` was measured on a
* reference capture as `dev+0x5C - dev+0x54` (0…89 mC, i.e. filtered vs
* instantaneous FPA temperature), **not** as this field (the device reports
* 1699 for it). [fpaTempFix] is kept for completeness / future mapping.
*/
class AtPara(val atZeroErrorPoint: Int, val atErrorSlope: Float, val fpaTempFix: Int)
/**
* Parse the radiance record from the DDT tail. Layout verified against the
* device object's in-memory record (see docs/android_app/temperature_chain_re_20261006.md):
* offsets are relative to `ddt.size - 560`.
*/
fun parseDdtTail(ddt: ByteArray): RadRecord? {
if (ddt.size < 760) return null
val base = ddt.size - 560
fun i32(o: Int): Int = MagProtocol.u32(ddt, base + o).toInt()
fun f32(o: Int): Float = Float.fromBits(i32(o))
val shift = i32(0x44)
val tB = i32(0x48) // rec+0x38
val tA = i32(0x4C) // rec+0x34
val bands = i32(0x50)
if (bands !in 1..16 || shift !in 0..20) return null
val thr = IntArray(bands) { i32(0x54 + 4 * it) }
for (i in 1 until bands) if (thr[i] <= thr[i - 1]) return null
val gain = IntArray(bands) { i32(0xE8 + 8 * it) }
val off = IntArray(bands) { i32(0xE8 + 8 * it + 4) }
// sanity: gains must be positive and offsets negative-ish (mC scale)
if (gain.any { it <= 0 || it > 1 shl 20 }) return null
val eps = f32(0x218)
if (eps <= 0f || eps > 1f) return null
return RadRecord(shift, thr, gain, off, tA, tB, eps, i32(0x21C), i32(0x220))
}
/**
* Parse BasePara2 (0x5BB5B55C) — see class [AtPara].
*
* Offsets are the official app's own wire parse (`UsbCommunication.parseCmd`,
* D2P_SendParameter2: fpaTempFix, ATZeroErrorPoint, ATErrorSlope are the
* 5th, 13th and 14th field) **minus the leading magic word**: our
* `readResp` returns the payload with the magic consumed separately.
*/
fun parseBasePara2(payload: ByteArray): AtPara? {
if (payload.size < 52) return null
val fpaTempFix = MagProtocol.u16(payload, 14).toShort().toInt()
val atZero = MagProtocol.u32(payload, 40)
val atSlope = Float.fromBits(MagProtocol.u32(payload, 44))
if (atSlope == 0f || atSlope.isNaN() || kotlin.math.abs(atSlope) > 1f) return null
return AtPara(atZero, atSlope, fpaTempFix)
}
// ---------------------------------------------------------------- curve math
private fun a(i: Int): Int = if (i in CURVE.indices) CURVE[i] else CURVE.last()
/** sub_180015840: temperature domain -> radiance domain (curve A forward). */
fun e2t(x: Int): Int {
val v = x + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
val r = a(i) + (a(i + 1) - a(i)) * (v - (i shl 12)) / 4096
return if (r < 0) 0 else r
}
/** sub_1800158b0: radiance domain -> temperature domain (curve A inverse). */
fun ie2t(l: Int): Int {
var lo = 0
var hi = 645
while (lo < hi) {
val mid = (lo + hi + 1) shr 1
if (a(mid) <= l) lo = mid else hi = mid - 1
}
val i = if (lo > 644) 644 else lo
return ((l - a(i)) * SLOPE[i] shr 12) + (i shl 12) - 150000
}
/** sub_180015A40: radiance-domain delta `d` -> temperature-domain delta at `t`. */
private fun dTemp(t: Int, d: Int): Int {
val v = t + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
val seg = a(i + 1) - a(i)
if (seg >= 2 * kotlin.math.abs(d)) return d * SLOPE[i] shr 12
val v2 = d + a(i) + seg * (v - (i shl 12)) / 4096
if (v2 in a(i)..a(i + 1)) return d * SLOPE[i] shr 12
var lo = 0
var hi = 645
while (lo < hi) {
val mid = (lo + hi + 1) shr 1
if (a(mid) <= v2) lo = mid else hi = mid - 1
}
val j = if (lo > 644) 644 else lo
return (((v2 - a(j)) * SLOPE[j] shr 12) + (j shl 12) - 150000) - t
}
/** sub_180015940: radiance-domain delta around a fixed temperature anchor. */
private fun radDelta(t: Int, d: Int): Int {
val v = t + 150000
var i = v / 4096
i = if (i < 0) 0 else if (i >= 645) 645 else i
var j = (v + d) / 4096
j = if (j < 0) 0 else if (j >= 645) 645 else j
if (i == j) return d * (a(i + 1) - a(i)) / 4096
return a(j) + (a(j + 1) - a(j)) * (v + d - (j shl 12)) / 4096 -
(a(i + 1) - a(i)) * (v - (i shl 12)) / 4096 - a(i)
}
/** Per-band linear map of the calibrated counts (rec+0xD4/0xD8/0x30). */
fun bandMap(counts: Int, rec: RadRecord): Int {
var b = 0
val last = rec.thr.size - 1
while (b < last && counts > rec.thr[b]) b++
return rec.off[b] + (counts * rec.gain[b] shr rec.shift)
}
/**
* Mean-count temperature (dev+0x44CB8) — the SDK computes it per frame with
* flag=0 (no ambient-error term) and stores it; the revise ambient derives from it.
*/
fun meanTemperature(
meanCounts: Int,
rec: RadRecord,
fpa: Int,
at: AtPara,
sensorDeltaMc: Int = 0,
): Int {
val t = bandMap(meanCounts, rec)
val v54 = envCompensation(rec, fpa, sensorDeltaMc)
return t + dTemp(t, v54)
}
/**
* v50/v54 of `sub_180016290`. On the reference capture the SDK's four sensor
* words reduce the term to `v50 = dev+0x5C - dev+0x54` (measured 0…89 mC):
* the filtered-minus-instantaneous FPA temperature. Our reader only gets the
* frame's FPA word, so the difference is supplied as [sensorDeltaMc]
* (0 = the term is skipped, bounded error ≤0.09 C on the reference device).
*/
private fun envCompensation(rec: RadRecord, fpa: Int, sensorDeltaMc: Int): Int {
val v45 = fpa + rec.tB - rec.tA // dev+0x58
val dev5c = fpa + sensorDeltaMc // dev+0x5C
val dev60 = dev5c + rec.tB - rec.tA // dev+0x60
val v47 = 0 // dev+0x58 == v45 by construction
val v50 = v45 + 2 * (dev5c - fpa) - dev60 + v47
val v49 = v45 + 150000
var i52 = v49 / 4096
i52 = if (i52 < 0) 0 else if (i52 >= 645) 645 else i52
var i53 = (v49 + v50) / 4096
i53 = if (i53 < 0) 0 else if (i53 >= 645) 645 else i53
return if (i52 == i53) {
v50 * (a(i52 + 1) - a(i52)) / 4096
} else {
a(i53) + (a(i53 + 1) - a(i53)) * (v49 + v50 - (i53 shl 12)) / 4096 -
(a(i52 + 1) - a(i52)) * (v49 - (i52 shl 12)) / 4096 - a(i52)
}
}
/**
* Official per-pixel temperature, the path `MAG_GetTemperatureData(flag=1)`
* takes (per-pixel conversion + revise).
*
* @param counts calibrated (post-NUC) counts of one pixel
* @param fpa frame's sensor temperature = frame tail word (+8)
* @param meanTemp mean-count temperature of the same frame ([meanTemperature])
* @param emissivity "temperature fix" emissivity (device default 0.98)
* @param sensorDeltaMc filtered-minus-instantaneous FPA temperature (see
* [envCompensation]; 0 until the SDK's source for it is mapped)
*/
fun temperature(
counts: Int,
rec: RadRecord,
fpa: Int,
meanTemp: Int,
at: AtPara,
emissivity: Float = 0.98f,
transmittance: Float = 1.0f,
sensorDeltaMc: Int = 0,
): Int {
val t0 = bandMap(counts, rec)
var v54 = envCompensation(rec, fpa, sensorDeltaMc)
// ambient-temperature error term (sub_180016290 v68!=0 block):
// arg = (meanTemp - ATZeroErrorPoint) * ATErrorSlope
val v56 = if (rec.distHi - rec.distLo > 50000) {
meanTemp.coerceIn(-80000, 80000)
} else {
meanTemp.coerceIn(5000, 40000)
}
v54 += radDelta(35000, ((v56 - at.atZeroErrorPoint) * at.atErrorSlope).toInt())
val t = t0 + dTemp(t0, v54)
// caller-side revise (sub_180015b90), flag=1 path
val k = transmittance * (rec.eps.coerceAtMost(1f)) * emissivity
if (k <= 0f) return t
val ambientC = meanTemp / 1000f
val lamb = e2t((ambientC * 1000f).toInt())
var rev = ie2t(((e2t(t) - ((1f - k) * lamb)).toFloat() / k).toInt())
if (rev < -273150) rev = -273150
// dev+0xAC..0xC8 ambient table and dev+0xEC/0xF0 gain trim are zero for
// MAG160C cores (measured); they are applied by the SDK only when set.
return rev
}
/** Temperature for a pixel of a NUC frame, or null when params are missing. */
fun temperatureOrNull(
counts: Int,
rec: RadRecord?,
fpa: Int,
meanTemp: Int?,
at: AtPara?,
): Int? {
if (rec == null || at == null || meanTemp == null) return null
return temperature(counts, rec, fpa, meanTemp, at)
}
}
@@ -794,7 +794,54 @@ class RenderPipeline(
/** Temperature probe on the current NUC output, in millidegrees C. */ /** Temperature probe on the current NUC output, in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int = synchronized(lock) { fun probeTemp(x: Int, y: Int): Int = synchronized(lock) {
TempMath.countsToTempMc(nuc[y * w + x]) val v = nuc[y * w + x]
val rec = radRecord
val at = atPara
if (rec != null && at != null) {
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
OfficialTemp.temperature(v, rec, shutter, mean, at)
} else {
TempMath.countsToTempMc(v)
}
}
// ---- official temperature chain parameters (BasePara2 + DDT tail record) ----
private var radRecord: OfficialTemp.RadRecord? = null
private var atPara: OfficialTemp.AtPara? = null
/** Supply the official chain inputs once the session has parsed them. */
fun setTempParams(rec: OfficialTemp.RadRecord?, at: OfficialTemp.AtPara?) {
synchronized(lock) {
radRecord = rec
atPara = at
}
}
/** True when official temperatures can be computed (params available). */
fun hasOfficialTemp(): Boolean = synchronized(lock) { radRecord != null && atPara != null }
/** Mean-count temperature of the current frame (official dev+0x44CB8). */
fun officialMeanTempMc(): Int? = synchronized(lock) {
val rec = radRecord ?: return null
val at = atPara ?: return null
OfficialTemp.meanTemperature(statMean, rec, shutter, at)
}
/**
* Convert a NUC buffer to official temperatures (mC) in one go.
* @return false when the official parameters are unavailable — the caller
* should fall back to [TempMath.countsToTempMc].
*/
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean = synchronized(lock) {
val rec = radRecord ?: return false
val at = atPara ?: return false
val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at)
val n = minOf(nucIn.size, out.size)
for (i in 0 until n) {
out[i] = OfficialTemp.temperature(nucIn[i], rec, shutter, mean, at)
}
return true
} }
/** Read the current NUC counts buffer (already blind-compensated). */ /** Read the current NUC counts buffer (already blind-compensated). */
@@ -905,6 +905,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */ /** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */
private val nucScratch = IntArray(19200) private val nucScratch = IntArray(19200)
private val nucTemps = IntArray(19200)
/** True while the background temperature loop runs. */ /** True while the background temperature loop runs. */
private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false) private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false)
@@ -955,24 +956,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val centerMc = session.probeTemp(80, 60) val centerMc = session.probeTemp(80, 60)
if (!session.copyNuc(nucScratch)) return if (!session.copyNuc(nucScratch)) return
// Official radiometric chain (BasePara2 AT params + DDT band record);
// falls back to the legacy T2E curve when those inputs are missing.
val official = session.officialTemps(nucScratch, nucTemps)
// scan off the main thread: 19200 iterations plus probe conversions // scan off the main thread: 19200 iterations plus probe conversions
var mn = Int.MAX_VALUE var mn = Int.MAX_VALUE
var mx = -1 var mx = -1
var mnPos = -1 var mnPos = -1
var mxPos = -1 var mxPos = -1
var mnT = Int.MAX_VALUE
var mxT = Int.MIN_VALUE
for (i in nucScratch.indices) { for (i in nucScratch.indices) {
val v = nucScratch[i] val v = nucScratch[i]
if (v < mn) { mn = v; mnPos = i } if (v < mn) { mn = v; mnPos = i }
if (v > mx) { mx = v; mxPos = i } if (v > mx) { mx = v; mxPos = i }
} if (official) {
val probeTemps = _state.value.probes.map { p -> val t = nucTemps[i]
(p.x + p.y * 160).let { idx -> if (t < mnT) mnT = t
if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f if (t > mxT) mxT = t
else 0f
} }
} }
val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null fun tempAt(idx: Int): Float = if (idx in nucScratch.indices) {
val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null (if (official) nucTemps[idx] else TempMath.countsToTempMc(nucScratch[idx])) / 1000f
} else {
0f
}
val probeTemps = _state.value.probes.map { p -> tempAt(p.x + p.y * 160) }
val minT = if (mn == Int.MAX_VALUE) null
else (if (official) mnT else TempMath.countsToTempMc(mn)) / 1000f
val maxT = if (mx < 0) null
else (if (official) mxT else TempMath.countsToTempMc(mx)) / 1000f
val centerT = centerMc?.let { it / 1000f } val centerT = centerMc?.let { it / 1000f }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -4,6 +4,7 @@ import android.content.Context
import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbEndpoint
import com.mag160c.thermal.core.FrameStream import com.mag160c.thermal.core.FrameStream
import com.mag160c.thermal.core.OfficialTemp
import com.mag160c.thermal.core.RenderPipeline import com.mag160c.thermal.core.RenderPipeline
import com.mag160c.thermal.media.DebugLog import com.mag160c.thermal.media.DebugLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -126,6 +127,22 @@ class IrSession(context: Context) {
var lastInfo1: ByteArray? = null var lastInfo1: ByteArray? = null
private set private set
/**
* Radiometric band record parsed from the loaded DDT tail — the input the
* official temperature chain uses (see [com.mag160c.thermal.core.OfficialTemp]).
*/
@Volatile
var radRecord: OfficialTemp.RadRecord? = null
private set
/**
* Device parameters from BasePara2 (ATZeroErrorPoint / ATErrorSlope /
* fpaTempFix) consumed by the official temperature chain.
*/
@Volatile
var atPara: OfficialTemp.AtPara? = null
private set
/** /**
* Detail enhancement strength requested before a pipeline existed (the * Detail enhancement strength requested before a pipeline existed (the
* pipeline is built during connect, so a setting change made while * pipeline is built during connect, so a setting change made while
@@ -251,11 +268,17 @@ class IrSession(context: Context) {
parseBasePara1(r1.third) parseBasePara1(r1.third)
DebugLog.log("session", "identity $identity") DebugLog.log("session", "identity $identity")
// 2) GetParameter2 -> BasePara2 (log only) // 2) GetParameter2 -> BasePara2 (temperature-chain parameters)
writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2") writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2")
val r2 = readResp(conn, epResp, "GetParameter2") val r2 = readResp(conn, epResp, "GetParameter2")
if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) { if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) {
lastInfo1 = r2.third.copyOf() lastInfo1 = r2.third.copyOf()
atPara = OfficialTemp.parseBasePara2(r2.third)
DebugLog.log(
"cmd",
"BasePara2 AT: zero=${atPara?.atZeroErrorPoint} slope=${atPara?.atErrorSlope} " +
"fpaFix=${atPara?.fpaTempFix}",
)
} }
// 3) GetCaliInfo -> {size, reserved, date} // 3) GetCaliInfo -> {size, reserved, date}
@@ -313,6 +336,14 @@ class IrSession(context: Context) {
return return
} }
} }
// radiometric band record rides in the DDT tail (official temperature chain)
radRecord = OfficialTemp.parseDdtTail(ddt)
?: bundledDdt.takeIf { ddt !== it }?.let { OfficialTemp.parseDdtTail(it) }
DebugLog.log(
"session",
"radRecord: ${radRecord?.let { "bands=${it.thr.size} shift=${it.shift} eps=${it.eps}" } ?: "none"}",
)
pipe.setTempParams(radRecord, atPara)
pipeline = pipe pipeline = pipe
active = this active = this
@@ -709,6 +740,16 @@ class IrSession(context: Context) {
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */ /** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y) fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
/**
* Convert a NUC buffer to official temperatures (mC) in one call.
* @return false when the official chain parameters are unavailable.
*/
fun officialTemps(nucIn: IntArray, out: IntArray): Boolean =
pipeline?.officialTemps(nucIn, out) ?: false
/** True when the official temperature chain can run (params parsed). */
fun hasOfficialTemp(): Boolean = pipeline?.hasOfficialTemp() ?: false
/** True once a frame completed the render path (temperatures are meaningful). */ /** True once a frame completed the render path (temperatures are meaningful). */
fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
@@ -83,4 +83,8 @@ object MagProtocol {
((b[off + 1].toInt() and 0xFF) shl 8) or ((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or ((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24) ((b[off + 3].toInt() and 0xFF) shl 24)
/** Little-endian u16 (zero-extended). */
fun u16(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
} }
@@ -0,0 +1,165 @@
package com.mag160c.thermal.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Differential test for the official radiometric temperature chain
* ([OfficialTemp]) against a frame captured from the official ThermalSDK
* (same counts buffer + same `MAG_GetTemperatureData` output).
*
* Fixture `official_temp_fixture.bin` (400 sampled pixels, frame 0 of the
* 2026-10-06 capture, mean |delta| of the reference model = 13.9 mC):
*
* magic "MTF1"
* i32 fpa, i32 meanTemp, i32 atZeroErrorPoint, f32 atErrorSlope, i32 fpaTempFix
* f32 eps
* i32 shift, i32 tA, i32 tB, i32 bands
* i32 thr[bands], i32 gain[bands], i32 off[bands]
* i32 n, then n*(i32 counts, i32 officialTempMc)
*/
class OfficialTempTest {
private fun res(name: String): ByteArray =
javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
private class Fixture(bytes: ByteArray) {
val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
val fpa: Int
val meanTemp: Int
val at: OfficialTemp.AtPara
val rec: OfficialTemp.RadRecord
val pts: List<Pair<Int, Int>>
/** `dev+0x5C - dev+0x54` of the captured frame (the SDK's v50). */
val sensorDeltaMc: Int
init {
val magic = ByteArray(4).also { bb.get(it) }
assertEquals("MTF1", String(magic, Charsets.US_ASCII))
fpa = bb.int
meanTemp = bb.int
val atZero = bb.int
val atSlope = bb.float
sensorDeltaMc = bb.int
val eps = bb.float
val shift = bb.int
val tA = bb.int
val tB = bb.int
val bands = bb.int
val thr = IntArray(bands) { bb.int }
val gain = IntArray(bands) { bb.int }
val off = IntArray(bands) { bb.int }
val n = bb.int
pts = (0 until n).map { bb.int to bb.int }
at = OfficialTemp.AtPara(atZero, atSlope, 0)
rec = OfficialTemp.RadRecord(shift, thr, gain, off, tA, tB, eps, 19790, 44930)
}
}
@Test
fun matchesCapturedOfficialFrame() {
val f = Fixture(res("official_temp_fixture.bin"))
var sum = 0L
val abs = ArrayList<Int>(f.pts.size)
for ((counts, official) in f.pts) {
val t = OfficialTemp.temperature(
counts, f.rec, f.fpa, f.meanTemp, f.at, sensorDeltaMc = f.sensorDeltaMc,
)
val d = kotlin.math.abs(t - official)
sum += d
abs.add(d)
}
abs.sort()
val mean = sum.toDouble() / f.pts.size
val median = abs[abs.size / 2]
val p99 = abs[(abs.size * 99) / 100]
// Reference implementation (Python, validated against the official SDK):
// mean 17.5 mC, median 0, 3% of pixels >150 mC. The outliers are capture
// artefacts (the official map was produced while the frame buffer kept
// updating), not model error.
assertTrue("mean |delta| = $mean mC", mean < 30.0)
assertTrue("median |delta| = $median mC", median <= 10)
assertTrue("p99 |delta| = $p99 mC", p99 < 700)
val outliers = abs.count { it > 150 }
assertTrue("outliers>150: $outliers/${abs.size}", outliers <= abs.size / 20 + 1)
}
@Test
fun ddtTailRecordMatchesCapturedDeviceRecord() {
// The bundled DDT is the same unit (SN 160043865) the fixture was
// captured from, so its tail record must reproduce the captured record.
val rec = OfficialTemp.parseDdtTail(res("mag160c_official.ddt"))
assertNotNull("DDT tail record must parse", rec)
rec!!
assertEquals("bands", 8, rec.thr.size)
assertEquals("shift", 12, rec.shift)
assertEquals("eps", 1.0f, rec.eps, 1e-6f)
assertEquals("threshold[0]", 9389, rec.thr[0])
assertEquals("threshold[7]", 11772, rec.thr[7])
assertEquals("gain band1", 33174, rec.gain[1])
assertEquals("offset band1", -51216, rec.off[1])
assertEquals("tA (rec+0x34)", 29917, rec.tA)
assertEquals("tB (rec+0x38)", 29417, rec.tB)
assertEquals("distLo", 19790, rec.distLo)
assertEquals("distHi", 44930, rec.distHi)
// and it must agree with the frame the official SDK rendered from it
val f = Fixture(res("official_temp_fixture.bin"))
assertEquals(f.rec.thr.toList(), rec.thr.toList())
assertEquals(f.rec.gain.toList(), rec.gain.toList())
assertEquals(f.rec.off.toList(), rec.off.toList())
assertEquals(f.rec.shift, rec.shift)
assertEquals(f.rec.tA, rec.tA)
assertEquals(f.rec.tB, rec.tB)
}
@Test
fun parsesBasePara2AtParameters() {
// Ground truth: the 60-byte BasePara2 payload captured from the real
// device (SN 160043865). Offsets follow the official app's own wire
// parse (UsbCommunication.parseCmd, D2P_SendParameter2) minus the
// leading magic word, which our reader consumes separately.
val captured = "0400000000000000000000000000a306050000000400000008070000fa000000" +
"0800000000000000c85600008a8ee4bc0000000000000000"
val at = OfficialTemp.parseBasePara2(hex(captured))
assertNotNull(at)
assertEquals("ATZeroErrorPoint", 22216, at!!.atZeroErrorPoint)
assertEquals("ATErrorSlope", -0.02789f, at.atErrorSlope, 1e-4f)
assertEquals("fpaTempFix", 1699, at.fpaTempFix)
// malformed payloads must be rejected rather than yield silent garbage
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(10)))
assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(60)))
}
private fun hex(s: String): ByteArray =
ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
@Test
fun curveInterpolatorsRoundTrip() {
// the curve is piecewise linear, so the inverse interpolation wobbles a
// few mC; the SDK shows the same behaviour
for (x in intArrayOf(26000, 28000, 29000, 30000, 32000, 34000)) {
val back = OfficialTemp.ie2t(OfficialTemp.e2t(x))
assertTrue("iE2T(E2T($x)) = $back", kotlin.math.abs(back - x) <= 4)
}
}
@Test
fun bandMapIsPiecewiseLinearAndMonotone() {
val f = Fixture(res("official_temp_fixture.bin"))
var prev = Int.MIN_VALUE
for (c in 9000..12000 step 7) {
val t = OfficialTemp.bandMap(c, f.rec)
assertTrue("band map must be monotone at $c", t > prev)
prev = t
}
}
}
Binary file not shown.