Initial commit: VesiscanBasic Android app
Port of MedilightV2 iOS app to Android with Kotlin + Jetpack Compose. Includes BLE connectivity, NIRS/Piezo sensor modes, Rive animations, interactive measurement history charts, and foldable device support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package com.example.medilightv2android.managers
|
||||
|
||||
import com.example.medilightv2android.models.NirsConstants
|
||||
import com.example.medilightv2android.models.NirsMagResponse
|
||||
import com.example.medilightv2android.models.NirsOxyResult
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.pow
|
||||
|
||||
class NirsCalcEngine {
|
||||
|
||||
// Gain Calibration
|
||||
private var gainValues: IntArray = intArrayOf()
|
||||
val hasGain: Boolean get() = gainValues.size >= 24
|
||||
|
||||
fun setGainFromMag(response: NirsMagResponse) {
|
||||
gainValues = response.gainValues
|
||||
}
|
||||
|
||||
fun clearGain() {
|
||||
gainValues = intArrayOf()
|
||||
}
|
||||
|
||||
// AGC DAC -> Software Amplification
|
||||
private fun agcGain(dacValue: Int): Double {
|
||||
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
|
||||
}
|
||||
|
||||
// DeGain Calculation
|
||||
fun degain(rawLed: IntArray): DoubleArray {
|
||||
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
|
||||
|
||||
val result = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
val raw = rawLed[i].toDouble()
|
||||
val sag = gainValues[i]
|
||||
val pkg = i / 6
|
||||
val wl = i % 6
|
||||
|
||||
val softAmp = agcGain(sag)
|
||||
val hAmp = NirsConstants.hardwareAmp[pkg]
|
||||
val spResponse = NirsConstants.spectralResponse[wl]
|
||||
val dpf = NirsConstants.dpf[wl]
|
||||
|
||||
val denominator = softAmp * hAmp
|
||||
if (denominator > 0) {
|
||||
var dg = raw / denominator
|
||||
dg /= spResponse
|
||||
dg *= dpf
|
||||
result[i] = dg
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Butterworth IIR Filter
|
||||
private var filterState: Array<DoubleArray> = emptyArray()
|
||||
private var filterInitialized = false
|
||||
var isFilterEnabled = true
|
||||
|
||||
fun resetFilter() {
|
||||
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
|
||||
filterInitialized = false
|
||||
}
|
||||
|
||||
private fun applyFilter(x: Double, state: DoubleArray): Double {
|
||||
val b = NirsConstants.filterB
|
||||
val a = NirsConstants.filterA
|
||||
|
||||
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
|
||||
|
||||
state[1] = state[0]
|
||||
state[0] = x
|
||||
state[3] = state[2]
|
||||
state[2] = y
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
|
||||
if (degainValues.size < 24) return degainValues
|
||||
|
||||
if (filterState.isEmpty()) resetFilter()
|
||||
|
||||
if (!filterInitialized) {
|
||||
for (i in 0 until 24) {
|
||||
val v = degainValues[i]
|
||||
filterState[i] = doubleArrayOf(v, v, v, v)
|
||||
}
|
||||
filterInitialized = true
|
||||
}
|
||||
|
||||
val filtered = DoubleArray(24)
|
||||
for (i in 0 until 24) {
|
||||
filtered[i] = applyFilter(degainValues[i], filterState[i])
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Oxygenation Calculation
|
||||
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
|
||||
if (degainValues.size < 24) return NirsOxyResult.zero
|
||||
|
||||
val c = NirsConstants
|
||||
val muA = DoubleArray(c.numWavelengths)
|
||||
|
||||
for (i in 0 until c.numWavelengths) {
|
||||
val i2cm = degainValues[i]
|
||||
val i5cm = degainValues[18 + i]
|
||||
|
||||
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
|
||||
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
|
||||
|
||||
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
|
||||
|
||||
if (mix > 0) {
|
||||
val slope = -ln(mix) / c.srsPathLength
|
||||
muA[i] = -slope
|
||||
}
|
||||
}
|
||||
|
||||
val solution = solveNormalEq6x3(muA)
|
||||
|
||||
var hbO2 = max(0.0, solution.first) * c.oxyScale
|
||||
var hb = max(0.0, solution.second) * c.oxyScale
|
||||
|
||||
val tHb = hbO2 + hb
|
||||
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
|
||||
|
||||
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
|
||||
}
|
||||
|
||||
// Least Squares Solver (6x3)
|
||||
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
|
||||
val eps = arrayOf(
|
||||
NirsConstants.extinctionHbO2,
|
||||
NirsConstants.extinctionHb,
|
||||
NirsConstants.extinctionH2O
|
||||
)
|
||||
|
||||
val n = Array(3) { DoubleArray(3) }
|
||||
for (i in 0 until 3) {
|
||||
for (j in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
|
||||
n[i][j] = sum
|
||||
}
|
||||
}
|
||||
|
||||
val b = DoubleArray(3)
|
||||
for (i in 0 until 3) {
|
||||
var sum = 0.0
|
||||
for (k in 0 until 6) sum += eps[i][k] * muA[k]
|
||||
b[i] = sum
|
||||
}
|
||||
|
||||
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
|
||||
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
|
||||
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
|
||||
|
||||
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
|
||||
|
||||
val inv = Array(3) { DoubleArray(3) }
|
||||
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
|
||||
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
|
||||
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
|
||||
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
|
||||
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
|
||||
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
|
||||
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
|
||||
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
|
||||
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
|
||||
|
||||
val c = DoubleArray(3) { i ->
|
||||
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
|
||||
}
|
||||
|
||||
return Triple(c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
// Full Processing Pipeline
|
||||
data class ProcessResult(
|
||||
val degain: DoubleArray,
|
||||
val degainFiltered: DoubleArray,
|
||||
val oxy: NirsOxyResult,
|
||||
val oxyFiltered: NirsOxyResult
|
||||
)
|
||||
|
||||
fun process(rawLed: IntArray): ProcessResult {
|
||||
val dg = degain(rawLed)
|
||||
val oxyRaw = calcOxygenation(dg)
|
||||
|
||||
val dgf: DoubleArray
|
||||
val oxyF: NirsOxyResult
|
||||
if (isFilterEnabled) {
|
||||
dgf = filterDeGain(dg)
|
||||
oxyF = calcOxygenation(dgf)
|
||||
} else {
|
||||
dgf = dg
|
||||
oxyF = oxyRaw
|
||||
}
|
||||
|
||||
return ProcessResult(dg, dgf, oxyRaw, oxyF)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.medilightv2android.managers
|
||||
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.example.medilightv2android.models.NirsMagResponse
|
||||
import com.example.medilightv2android.models.NirsMcjPacket
|
||||
import com.example.medilightv2android.models.NirsConstants
|
||||
import com.example.medilightv2android.models.NirsOxyResult
|
||||
|
||||
class NirsManager private constructor() {
|
||||
companion object {
|
||||
val instance: NirsManager by lazy { NirsManager() }
|
||||
}
|
||||
|
||||
// State
|
||||
val isStreaming = mutableStateOf(false)
|
||||
val isCalibrated = mutableStateOf(false)
|
||||
val isFilterEnabled = mutableStateOf(true)
|
||||
|
||||
// Current Values
|
||||
val currentOxy = mutableStateOf(NirsOxyResult.zero)
|
||||
val sampleCount = mutableIntStateOf(0)
|
||||
|
||||
// Chart History
|
||||
val oxyHbO2History = mutableStateListOf<Double>()
|
||||
val oxyHbHistory = mutableStateListOf<Double>()
|
||||
|
||||
// Internal
|
||||
val calcEngine = NirsCalcEngine()
|
||||
private val maxPoints = NirsConstants.maxHistoryPoints
|
||||
|
||||
// Calibration
|
||||
fun setCalibrationGain(response: NirsMagResponse) {
|
||||
calcEngine.setGainFromMag(response)
|
||||
isCalibrated.value = true
|
||||
}
|
||||
|
||||
// Process mcj Packet
|
||||
fun processMcjPacket(packet: NirsMcjPacket) {
|
||||
val result = calcEngine.process(packet.ledValues)
|
||||
currentOxy.value = result.oxyFiltered
|
||||
sampleCount.intValue++
|
||||
|
||||
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
|
||||
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
|
||||
}
|
||||
|
||||
// Controls
|
||||
fun toggleFilter() {
|
||||
isFilterEnabled.value = !isFilterEnabled.value
|
||||
calcEngine.isFilterEnabled = isFilterEnabled.value
|
||||
if (isFilterEnabled.value) calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun clearData() {
|
||||
oxyHbO2History.clear()
|
||||
oxyHbHistory.clear()
|
||||
currentOxy.value = NirsOxyResult.zero
|
||||
sampleCount.intValue = 0
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun startStreaming() {
|
||||
isStreaming.value = true
|
||||
calcEngine.resetFilter()
|
||||
}
|
||||
|
||||
fun stopStreaming() {
|
||||
isStreaming.value = false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
stopStreaming()
|
||||
clearData()
|
||||
isCalibrated.value = false
|
||||
calcEngine.clearGain()
|
||||
}
|
||||
|
||||
// Helpers
|
||||
private fun appendTrimming(list: MutableList<Double>, value: Double) {
|
||||
list.add(value)
|
||||
while (list.size > maxPoints) list.removeAt(0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user