Compare commits
65 Commits
a04163cade
...
8403ff349e
| Author | SHA1 | Date |
|---|---|---|
|
|
8403ff349e | |
|
|
1dca7b035e | |
|
|
e4db734098 | |
|
|
35be164188 | |
|
|
b6ff02bdb4 | |
|
|
95ca2f7cdc | |
|
|
6eab85af57 | |
|
|
f5394d01ca | |
|
|
d061a17e54 | |
|
|
dcde79cf08 | |
|
|
1ba6772738 | |
|
|
8297773827 | |
|
|
80dc8ef561 | |
|
|
bb894b42be | |
|
|
ea3356ac20 | |
|
|
3239eaf9c8 | |
|
|
9825ddb287 | |
|
|
a9f6f9f6ac | |
|
|
3e0cbfd131 | |
|
|
5f550f031a | |
|
|
618e9ed4c8 | |
|
|
d409f3569e | |
|
|
c1721dfd1f | |
|
|
292a1a2e87 | |
|
|
a2a48f47a3 | |
|
|
9bc5347c66 | |
|
|
804ea21d71 | |
|
|
399ee4229b | |
|
|
91a361732d | |
|
|
03d10ab678 | |
|
|
c6bbaa5bc4 | |
|
|
01edb88e0b | |
|
|
cabf04551c | |
|
|
34f8bda191 | |
|
|
73899beaa5 | |
|
|
8de67ca66e | |
|
|
090fcfa2f5 | |
|
|
a21b014d89 | |
|
|
8e1153585b | |
|
|
311fb8ecc7 | |
|
|
c0a0904a44 | |
|
|
1441c5ec42 | |
|
|
818c4ff7a2 | |
|
|
d5e1a7dd0f | |
|
|
bdb72a9917 | |
|
|
5b051cfa20 | |
|
|
3c33c7806d | |
|
|
0cfeb287e6 | |
|
|
e8ce7eb98c | |
|
|
d84ed33c14 | |
|
|
2e1a2f98f2 | |
|
|
b17d12195d | |
|
|
4c914a5101 | |
|
|
95997e4fd5 | |
|
|
491befa8c6 | |
|
|
324c8a7f5a | |
|
|
df6268d2ac | |
|
|
30cd80d03b | |
|
|
e5fe1c9229 | |
|
|
1abf46f0c3 | |
|
|
6a09782d30 | |
|
|
9e9410a78e | |
|
|
e1209a89cc | |
|
|
a611a72c48 | |
|
|
1ea5c760d7 |
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cue Desktop ==="
|
||||
cd cue
|
||||
cargo clean
|
||||
./build.sh
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "=== Cue iOS ==="
|
||||
cd cue-ios
|
||||
SVG_SRC="../cue/assets/cue.svg"
|
||||
ICON_DIR="CueIOS/Assets.xcassets/AppIcon.appiconset"
|
||||
if [ -f "$SVG_SRC" ] && command -v rsvg-convert &>/dev/null; then
|
||||
echo "Regenerating iOS app icon"
|
||||
rsvg-convert -w 1024 -h 1024 "$SVG_SRC" -o /tmp/cue-icon-raw.png
|
||||
if command -v magick &>/dev/null; then
|
||||
magick /tmp/cue-icon-raw.png -background black -flatten \
|
||||
-gravity center -extent 1024x1024 "$ICON_DIR/appicon-1024.png"
|
||||
else
|
||||
magick convert /tmp/cue-icon-raw.png -background black -flatten \
|
||||
"$ICON_DIR/appicon-1024.png" 2>/dev/null || \
|
||||
cp /tmp/cue-icon-raw.png "$ICON_DIR/appicon-1024.png"
|
||||
fi
|
||||
rm -f /tmp/cue-icon-raw.png
|
||||
fi
|
||||
xcodegen generate
|
||||
echo "Xcode project regenerated — open CueIOS.xcodeproj to build"
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "=== Firmware ==="
|
||||
make fcf
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum ClAutoState: Equatable {
|
||||
case idle, lsvRunning, measureRunning
|
||||
}
|
||||
|
||||
enum LsvDensityMode: String, CaseIterable, Identifiable {
|
||||
case ptsPerMv = "pts/mV"
|
||||
case ptsPerSec = "pts/s"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum Tab: String, CaseIterable, Identifiable {
|
||||
case eis = "EIS"
|
||||
case lsv = "LSV"
|
||||
|
|
@ -37,11 +47,14 @@ final class AppState {
|
|||
|
||||
// LSV
|
||||
var lsvPoints: [LsvPoint] = []
|
||||
var lsvPeaks: [LsvPeak] = []
|
||||
var lsvTotal: UInt16 = 0
|
||||
var lsvStartV: String = "0"
|
||||
var lsvStopV: String = "500"
|
||||
var lsvScanRate: String = "50"
|
||||
var lsvRtia: LpRtia = .r10K
|
||||
var lsvDensityMode: LsvDensityMode = .ptsPerMv
|
||||
var lsvDensity: String = "1"
|
||||
|
||||
// Amperometry
|
||||
var ampPoints: [AmpPoint] = []
|
||||
|
|
@ -63,6 +76,9 @@ final class AppState {
|
|||
var clDepT: String = "5000"
|
||||
var clMeasT: String = "5000"
|
||||
var clRtia: LpRtia = .r10K
|
||||
var clManualPeaks: Bool = false
|
||||
var clAutoState: ClAutoState = .idle
|
||||
var clAutoPotentials: ClPotentials? = nil
|
||||
|
||||
// pH
|
||||
var phResult: PhResult? = nil
|
||||
|
|
@ -84,6 +100,9 @@ final class AppState {
|
|||
|
||||
// Session
|
||||
var currentSessionId: Int64? = nil
|
||||
var firmwareSessionMap: [UInt8: Int64] = [:]
|
||||
var sessionListReceived: Bool = false
|
||||
private var pendingEspTimestamp: Int64? = nil
|
||||
|
||||
// Calibration
|
||||
var calVolumeGal: Double = 25
|
||||
|
|
@ -93,6 +112,12 @@ final class AppState {
|
|||
var calTempC: String = "40"
|
||||
var calCellConstant: Double? = nil
|
||||
var calRs: Double? = nil
|
||||
var clFactor: Double? = nil
|
||||
var clCalKnownPpm: String = "5"
|
||||
var phSlope: Double? = nil
|
||||
var phOffset: Double? = nil
|
||||
var phCalPoints: [(ph: Double, mV: Double)] = []
|
||||
var phCalKnown: String = "7.00"
|
||||
|
||||
// Clean
|
||||
var cleanV: String = "1200"
|
||||
|
|
@ -103,6 +128,10 @@ final class AppState {
|
|||
transport.setMessageHandler { [weak self] msg in
|
||||
self?.handleMessage(msg)
|
||||
}
|
||||
transport.setDisconnectHandler { [weak self] in
|
||||
self?.sessionListReceived = false
|
||||
self?.firmwareSessionMap.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send helper
|
||||
|
|
@ -116,7 +145,8 @@ final class AppState {
|
|||
private func handleMessage(_ msg: EisMessage) {
|
||||
switch msg {
|
||||
|
||||
case .sweepStart(let numPoints, let freqStart, let freqStop):
|
||||
case .sweepStart(let numPoints, let freqStart, let freqStop, let espTs, _):
|
||||
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||
if collectingRefs {
|
||||
eisPoints.removeAll()
|
||||
sweepTotal = numPoints
|
||||
|
|
@ -150,9 +180,14 @@ final class AppState {
|
|||
rtia = cfg.rtia
|
||||
rcal = cfg.rcal
|
||||
electrode = cfg.electrode
|
||||
if !sessionListReceived {
|
||||
sessionListReceived = true
|
||||
send(buildSysexSessionList())
|
||||
}
|
||||
status = "Config received"
|
||||
|
||||
case .lsvStart(let numPoints, let vStart, let vStop):
|
||||
case .lsvStart(let numPoints, let vStart, let vStop, let espTs, _):
|
||||
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||
lsvPoints.removeAll()
|
||||
lsvTotal = numPoints
|
||||
status = String(format: "LSV: %d pts, %.0f--%.0f mV", numPoints, vStart, vStop)
|
||||
|
|
@ -163,9 +198,37 @@ final class AppState {
|
|||
|
||||
case .lsvEnd:
|
||||
saveLsv()
|
||||
status = "LSV complete: \(lsvPoints.count) points"
|
||||
lsvPeaks = detectLsvPeaks(lsvPoints)
|
||||
var st = "LSV complete: \(lsvPoints.count) points"
|
||||
if let s = phSlope, let o = phOffset, abs(s) > 1e-6 {
|
||||
if let peak = detectQhqPeak(lsvPoints) {
|
||||
let ph = (Double(peak) - o) / s
|
||||
st += String(format: " | pH=%.2f", ph)
|
||||
}
|
||||
}
|
||||
status = st
|
||||
|
||||
case .ampStart(let vHold):
|
||||
if clAutoState == .lsvRunning {
|
||||
let pots = deriveClPotentials(lsvPoints)
|
||||
clFreeV = String(format: "%.0f", pots.vFree)
|
||||
clTotalV = String(format: "%.0f", pots.vTotal)
|
||||
clAutoPotentials = pots
|
||||
clAutoState = .measureRunning
|
||||
|
||||
let vCond = Float(clCondV) ?? 800
|
||||
let tCond = Float(clCondT) ?? 2000
|
||||
let tDep = Float(clDepT) ?? 5000
|
||||
let tMeas = Float(clMeasT) ?? 5000
|
||||
send(buildSysexGetTemp())
|
||||
send(buildSysexStartCl(
|
||||
vCond: vCond, tCondMs: tCond, vFree: pots.vFree, vTotal: pots.vTotal,
|
||||
tDepMs: tDep, tMeasMs: tMeas, lpRtia: clRtia
|
||||
))
|
||||
status = String(format: "Auto Cl: measuring (free=%.0f, total=%.0f)", pots.vFree, pots.vTotal)
|
||||
}
|
||||
|
||||
case .ampStart(let vHold, let espTs, _):
|
||||
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||
ampPoints.removeAll()
|
||||
ampRunning = true
|
||||
status = String(format: "Amp: %.0f mV", vHold)
|
||||
|
|
@ -180,7 +243,8 @@ final class AppState {
|
|||
saveAmp()
|
||||
status = "Amp complete: \(ampPoints.count) points"
|
||||
|
||||
case .clStart(let numPoints):
|
||||
case .clStart(let numPoints, let espTs, _):
|
||||
pendingEspTimestamp = espTs.map { Int64($0) }
|
||||
clPoints.removeAll()
|
||||
clResult = nil
|
||||
clTotal = numPoints
|
||||
|
|
@ -196,9 +260,22 @@ final class AppState {
|
|||
|
||||
case .clEnd:
|
||||
saveCl()
|
||||
status = "Chlorine complete: \(clPoints.count) points"
|
||||
if clAutoState == .measureRunning {
|
||||
clAutoState = .idle
|
||||
if let pots = clAutoPotentials {
|
||||
let fd = pots.vFreeDetected ? "" : " dflt"
|
||||
let td = pots.vTotalDetected ? "" : " dflt"
|
||||
status = String(format: "Auto Cl complete: %d pts (free=%.0f%@, total=%.0f%@)",
|
||||
clPoints.count, pots.vFree, fd, pots.vTotal, td)
|
||||
} else {
|
||||
status = "Chlorine complete: \(clPoints.count) points"
|
||||
}
|
||||
} else {
|
||||
status = "Chlorine complete: \(clPoints.count) points"
|
||||
}
|
||||
|
||||
case .phResult(let r):
|
||||
transport.measuring = false
|
||||
if collectingRefs {
|
||||
phRef = r
|
||||
} else {
|
||||
|
|
@ -232,6 +309,7 @@ final class AppState {
|
|||
break
|
||||
|
||||
case .refsDone:
|
||||
transport.measuring = false
|
||||
collectingRefs = false
|
||||
hasDeviceRefs = true
|
||||
refMode = nil
|
||||
|
|
@ -251,9 +329,80 @@ final class AppState {
|
|||
case .cellK(let k):
|
||||
calCellConstant = Double(k)
|
||||
status = String(format: "Device cell constant: %.4f cm\u{207B}\u{00B9}", k)
|
||||
|
||||
case .clFactor(let f):
|
||||
clFactor = Double(f)
|
||||
status = String(format: "Device Cl factor: %.6f", f)
|
||||
|
||||
case .phCal(let slope, let offset):
|
||||
phSlope = Double(slope)
|
||||
phOffset = Double(offset)
|
||||
status = String(format: "pH cal: slope=%.4f offset=%.4f", slope, offset)
|
||||
|
||||
case .sessionCreated(let fwId, let name):
|
||||
handleSessionCreated(fwId: fwId, name: name)
|
||||
|
||||
case .sessionSwitched(let fwId):
|
||||
handleSessionSwitched(fwId: fwId)
|
||||
|
||||
case .sessionList(_, let currentId, let sessions):
|
||||
handleSessionList(currentId: currentId, sessions: sessions)
|
||||
|
||||
case .sessionRenamed(let fwId, let name):
|
||||
handleSessionRenamed(fwId: fwId, name: name)
|
||||
|
||||
case .keepalive:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session sync
|
||||
|
||||
private func handleSessionCreated(fwId: UInt8, name: String) {
|
||||
let fwId64 = Int64(fwId)
|
||||
if let existing = Storage.shared.sessionByFirmwareId(fwId64) {
|
||||
firmwareSessionMap[fwId] = existing.id
|
||||
} else if let session = try? Storage.shared.createSession(
|
||||
label: name.isEmpty ? nil : name,
|
||||
firmwareSessionId: fwId64
|
||||
) {
|
||||
firmwareSessionMap[fwId] = session.id
|
||||
currentSessionId = session.id
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSessionSwitched(fwId: UInt8) {
|
||||
if let localId = firmwareSessionMap[fwId] {
|
||||
currentSessionId = localId
|
||||
} else {
|
||||
let fwId64 = Int64(fwId)
|
||||
if let existing = Storage.shared.sessionByFirmwareId(fwId64) {
|
||||
firmwareSessionMap[fwId] = existing.id
|
||||
currentSessionId = existing.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSessionList(currentId: UInt8, sessions: [(id: UInt8, name: String)]) {
|
||||
for entry in sessions {
|
||||
let fwId64 = Int64(entry.id)
|
||||
if let existing = Storage.shared.sessionByFirmwareId(fwId64) {
|
||||
firmwareSessionMap[entry.id] = existing.id
|
||||
} else if let session = try? Storage.shared.createSession(
|
||||
label: entry.name.isEmpty ? nil : entry.name,
|
||||
firmwareSessionId: fwId64
|
||||
) {
|
||||
firmwareSessionMap[entry.id] = session.id
|
||||
}
|
||||
}
|
||||
handleSessionSwitched(fwId: currentId)
|
||||
}
|
||||
|
||||
private func handleSessionRenamed(fwId: UInt8, name: String) {
|
||||
guard let localId = firmwareSessionMap[fwId] else { return }
|
||||
try? Storage.shared.updateSessionLabel(localId, label: name)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func applyEISSettings() {
|
||||
|
|
@ -273,13 +422,30 @@ final class AppState {
|
|||
send(buildSysexStartSweep())
|
||||
}
|
||||
|
||||
func lsvCalcPoints() -> UInt16 {
|
||||
let vs = Float(lsvStartV) ?? 0
|
||||
let ve = Float(lsvStopV) ?? 500
|
||||
let sr = Float(lsvScanRate) ?? 50
|
||||
let d = Float(lsvDensity) ?? 1
|
||||
let range = abs(ve - vs)
|
||||
let raw: Float
|
||||
switch lsvDensityMode {
|
||||
case .ptsPerMv:
|
||||
raw = range * d
|
||||
case .ptsPerSec:
|
||||
raw = abs(sr) < 0.001 ? 2 : (range / abs(sr)) * d
|
||||
}
|
||||
return max(2, min(500, UInt16(raw)))
|
||||
}
|
||||
|
||||
func startLSV() {
|
||||
lsvPoints.removeAll()
|
||||
let vs = Float(lsvStartV) ?? 0
|
||||
let ve = Float(lsvStopV) ?? 500
|
||||
let sr = Float(lsvScanRate) ?? 50
|
||||
let n = lsvCalcPoints()
|
||||
send(buildSysexGetTemp())
|
||||
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia))
|
||||
send(buildSysexStartLsv(vStart: vs, vStop: ve, scanRate: sr, lpRtia: lsvRtia, numPoints: n))
|
||||
}
|
||||
|
||||
func startAmp() {
|
||||
|
|
@ -312,8 +478,31 @@ final class AppState {
|
|||
))
|
||||
}
|
||||
|
||||
func lsvCalcPointsFor(vStart: Float, vStop: Float, scanRate: Float) -> UInt16 {
|
||||
let d = Float(lsvDensity) ?? 1
|
||||
let range = abs(vStop - vStart)
|
||||
let raw: Float
|
||||
switch lsvDensityMode {
|
||||
case .ptsPerMv:
|
||||
raw = range * d
|
||||
case .ptsPerSec:
|
||||
raw = abs(scanRate) < 0.001 ? 2 : (range / abs(scanRate)) * d
|
||||
}
|
||||
return max(2, min(500, UInt16(raw)))
|
||||
}
|
||||
|
||||
func startClAuto() {
|
||||
clAutoState = .lsvRunning
|
||||
clAutoPotentials = nil
|
||||
lsvPoints.removeAll()
|
||||
let n = lsvCalcPointsFor(vStart: -1100, vStop: 1100, scanRate: 50)
|
||||
send(buildSysexStartLsv(vStart: -1100, vStop: 1100, scanRate: 50, lpRtia: lsvRtia, numPoints: n))
|
||||
status = "Auto Cl: running LSV sweep..."
|
||||
}
|
||||
|
||||
func startPh() {
|
||||
phResult = nil
|
||||
transport.measuring = true
|
||||
let stab = Float(phStabilize) ?? 30
|
||||
send(buildSysexGetTemp())
|
||||
send(buildSysexStartPh(stabilizeS: stab))
|
||||
|
|
@ -358,6 +547,7 @@ final class AppState {
|
|||
|
||||
func collectRefs() {
|
||||
collectingRefs = true
|
||||
transport.measuring = true
|
||||
eisRefs.removeAll()
|
||||
status = "Starting reference collection..."
|
||||
send(buildSysexStartRefs())
|
||||
|
|
@ -365,6 +555,7 @@ final class AppState {
|
|||
|
||||
func getRefs() {
|
||||
collectingRefs = true
|
||||
transport.measuring = true
|
||||
eisRefs.removeAll()
|
||||
send(buildSysexGetRefs())
|
||||
}
|
||||
|
|
@ -382,8 +573,13 @@ final class AppState {
|
|||
func startClean() {
|
||||
let v = Float(cleanV) ?? 1200
|
||||
let d = Float(cleanDur) ?? 30
|
||||
transport.measuring = true
|
||||
send(buildSysexStartClean(vMv: v, durationS: d))
|
||||
status = String(format: "Cleaning: %.0f mV for %.0fs", v, d)
|
||||
let t = transport
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(d) + 2) {
|
||||
t.measuring = false
|
||||
}
|
||||
}
|
||||
|
||||
var hasCurrentRef: Bool {
|
||||
|
|
@ -412,6 +608,8 @@ final class AppState {
|
|||
|
||||
private func saveEis() {
|
||||
guard let sid = currentSessionId else { return }
|
||||
let ts = pendingEspTimestamp
|
||||
pendingEspTimestamp = nil
|
||||
let params: [String: String] = [
|
||||
"freq_start": freqStart,
|
||||
"freq_stop": freqStop,
|
||||
|
|
@ -421,7 +619,7 @@ final class AppState {
|
|||
"electrode": electrode.label,
|
||||
]
|
||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .eis, espTimestamp: ts) else { return }
|
||||
meas.config = configData
|
||||
guard let mid = meas.id else { return }
|
||||
let indexed = eisPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||
|
|
@ -430,6 +628,8 @@ final class AppState {
|
|||
|
||||
private func saveLsv() {
|
||||
guard let sid = currentSessionId else { return }
|
||||
let ts = pendingEspTimestamp
|
||||
pendingEspTimestamp = nil
|
||||
let params: [String: String] = [
|
||||
"v_start": lsvStartV,
|
||||
"v_stop": lsvStopV,
|
||||
|
|
@ -437,7 +637,7 @@ final class AppState {
|
|||
"rtia": lsvRtia.label,
|
||||
]
|
||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .lsv, espTimestamp: ts) else { return }
|
||||
meas.config = configData
|
||||
guard let mid = meas.id else { return }
|
||||
let indexed = lsvPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||
|
|
@ -446,6 +646,8 @@ final class AppState {
|
|||
|
||||
private func saveAmp() {
|
||||
guard let sid = currentSessionId else { return }
|
||||
let ts = pendingEspTimestamp
|
||||
pendingEspTimestamp = nil
|
||||
let params: [String: String] = [
|
||||
"v_hold": ampVHold,
|
||||
"interval_ms": ampInterval,
|
||||
|
|
@ -453,7 +655,7 @@ final class AppState {
|
|||
"rtia": ampRtia.label,
|
||||
]
|
||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .amp, espTimestamp: ts) else { return }
|
||||
meas.config = configData
|
||||
guard let mid = meas.id else { return }
|
||||
let indexed = ampPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||
|
|
@ -462,6 +664,8 @@ final class AppState {
|
|||
|
||||
private func saveCl() {
|
||||
guard let sid = currentSessionId else { return }
|
||||
let ts = pendingEspTimestamp
|
||||
pendingEspTimestamp = nil
|
||||
let params: [String: String] = [
|
||||
"cond_v": clCondV,
|
||||
"cond_t": clCondT,
|
||||
|
|
@ -472,7 +676,7 @@ final class AppState {
|
|||
"rtia": clRtia.label,
|
||||
]
|
||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .chlorine, espTimestamp: ts) else { return }
|
||||
meas.config = configData
|
||||
guard let mid = meas.id else { return }
|
||||
let indexed = clPoints.enumerated().map { (index: $0.offset, value: $0.element) }
|
||||
|
|
@ -484,11 +688,13 @@ final class AppState {
|
|||
|
||||
private func savePh(_ result: PhResult) {
|
||||
guard let sid = currentSessionId else { return }
|
||||
let ts = pendingEspTimestamp
|
||||
pendingEspTimestamp = nil
|
||||
let params: [String: String] = [
|
||||
"stabilize_s": phStabilize,
|
||||
]
|
||||
guard let configData = try? JSONEncoder().encode(params) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph) else { return }
|
||||
guard var meas = try? Storage.shared.addMeasurement(sessionId: sid, type: .ph, espTimestamp: ts) else { return }
|
||||
meas.config = configData
|
||||
guard let mid = meas.id else { return }
|
||||
try? Storage.shared.addDataPoint(measurementId: mid, index: 0, point: result)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 179 KiB |
|
|
@ -0,0 +1,208 @@
|
|||
import Foundation
|
||||
|
||||
enum PeakKind {
|
||||
case freeCl, totalCl, crossover
|
||||
}
|
||||
|
||||
struct LsvPeak {
|
||||
var vMv: Float
|
||||
var iUa: Float
|
||||
var kind: PeakKind
|
||||
}
|
||||
|
||||
func smoothLsv(_ data: [Float], window: Int) -> [Float] {
|
||||
let n = data.count
|
||||
if n == 0 || window < 2 { return data }
|
||||
let half = window / 2
|
||||
var out = [Float](repeating: 0, count: n)
|
||||
for i in 0..<n {
|
||||
let lo = max(0, i - half)
|
||||
let hi = min(n - 1, i + half)
|
||||
var sum: Float = 0
|
||||
for j in lo...hi { sum += data[j] }
|
||||
out[i] = sum / Float(hi - lo + 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findExtrema(_ v: [Float], _ iSmooth: [Float], minProminence: Float) -> [(Int, Bool)] {
|
||||
let n = iSmooth.count
|
||||
if n < 3 { return [] }
|
||||
|
||||
var candidates: [(Int, Bool)] = []
|
||||
for i in 1..<(n - 1) {
|
||||
let prev = iSmooth[i - 1]
|
||||
let curr = iSmooth[i]
|
||||
let next = iSmooth[i + 1]
|
||||
if curr > prev && curr > next {
|
||||
candidates.append((i, true))
|
||||
} else if curr < prev && curr < next {
|
||||
candidates.append((i, false))
|
||||
}
|
||||
}
|
||||
|
||||
var result: [(Int, Bool)] = []
|
||||
for (idx, isMax) in candidates {
|
||||
let val = iSmooth[idx]
|
||||
let leftSlice = iSmooth[..<idx]
|
||||
let rightSlice = iSmooth[(idx + 1)...]
|
||||
if isMax {
|
||||
let lb = leftSlice.min() ?? val
|
||||
let rb = rightSlice.min() ?? val
|
||||
if val - max(lb, rb) >= minProminence { result.append((idx, isMax)) }
|
||||
} else {
|
||||
let lb = leftSlice.max() ?? val
|
||||
let rb = rightSlice.max() ?? val
|
||||
if min(lb, rb) - val >= minProminence { result.append((idx, isMax)) }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Detect Q/HQ redox peak in the -100 to +600 mV window.
|
||||
/// Returns peak voltage in mV if found.
|
||||
func detectQhqPeak(_ points: [LsvPoint]) -> Float? {
|
||||
if points.count < 5 { return nil }
|
||||
|
||||
let iVals = points.map { $0.iUa }
|
||||
let vVals = points.map { $0.vMv }
|
||||
|
||||
let window = max(5, points.count / 50)
|
||||
let smoothed = smoothLsv(iVals, window: window)
|
||||
|
||||
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return nil }
|
||||
let prominence = (iMax - iMin) * 0.05
|
||||
|
||||
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
|
||||
|
||||
var bestIdx: Int? = nil
|
||||
var bestVal: Float = -.infinity
|
||||
for (idx, isMax) in extrema {
|
||||
guard isMax, vVals[idx] >= -100, vVals[idx] <= 600 else { continue }
|
||||
if smoothed[idx] > bestVal {
|
||||
bestVal = smoothed[idx]
|
||||
bestIdx = idx
|
||||
}
|
||||
}
|
||||
if let idx = bestIdx { return vVals[idx] }
|
||||
return nil
|
||||
}
|
||||
|
||||
struct ClPotentials: Equatable {
|
||||
var vFree: Float
|
||||
var vFreeDetected: Bool
|
||||
var vTotal: Float
|
||||
var vTotalDetected: Bool
|
||||
}
|
||||
|
||||
func deriveClPotentials(_ points: [LsvPoint]) -> ClPotentials {
|
||||
let dflt = ClPotentials(vFree: 100, vFreeDetected: false, vTotal: -200, vTotalDetected: false)
|
||||
guard points.count >= 5 else { return dflt }
|
||||
|
||||
let iVals = points.map { $0.iUa }
|
||||
let vVals = points.map { $0.vMv }
|
||||
|
||||
let window = max(5, points.count / 50)
|
||||
let smoothed = smoothLsv(iVals, window: window)
|
||||
|
||||
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return dflt }
|
||||
let prominence = (iMax - iMin) * 0.05
|
||||
|
||||
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
|
||||
|
||||
// v_free: most prominent cathodic peak (isMax==false) in +300 to -300 mV
|
||||
var vFree: Float = 100
|
||||
var vFreeDetected = false
|
||||
var freeIdx: Int? = nil
|
||||
var freeBest: Float = .infinity
|
||||
for (idx, isMax) in extrema {
|
||||
guard !isMax, vVals[idx] >= -300, vVals[idx] <= 300 else { continue }
|
||||
if smoothed[idx] < freeBest {
|
||||
freeBest = smoothed[idx]
|
||||
vFree = vVals[idx]
|
||||
vFreeDetected = true
|
||||
freeIdx = idx
|
||||
}
|
||||
}
|
||||
|
||||
// v_total: secondary cathodic peak between (vFree-100) and -500, excluding free peak
|
||||
let totalHi = vFree - 100
|
||||
let totalLo: Float = -500
|
||||
var vTotal: Float = vFree - 300
|
||||
var vTotalDetected = false
|
||||
var totalBest: Float = .infinity
|
||||
for (idx, isMax) in extrema {
|
||||
guard !isMax, vVals[idx] >= totalLo, vVals[idx] <= totalHi, idx != freeIdx else { continue }
|
||||
if smoothed[idx] < totalBest {
|
||||
totalBest = smoothed[idx]
|
||||
vTotal = vVals[idx]
|
||||
vTotalDetected = true
|
||||
}
|
||||
}
|
||||
vTotal = max(vTotal, -400)
|
||||
|
||||
return ClPotentials(vFree: vFree, vFreeDetected: vFreeDetected, vTotal: vTotal, vTotalDetected: vTotalDetected)
|
||||
}
|
||||
|
||||
func detectLsvPeaks(_ points: [LsvPoint]) -> [LsvPeak] {
|
||||
if points.count < 5 { return [] }
|
||||
|
||||
let iVals = points.map { $0.iUa }
|
||||
let vVals = points.map { $0.vMv }
|
||||
|
||||
let window = max(5, points.count / 50)
|
||||
let smoothed = smoothLsv(iVals, window: window)
|
||||
|
||||
guard let iMin = smoothed.min(), let iMax = smoothed.max() else { return [] }
|
||||
let prominence = (iMax - iMin) * 0.05
|
||||
|
||||
let extrema = findExtrema(vVals, smoothed, minProminence: prominence)
|
||||
|
||||
var peaks: [LsvPeak] = []
|
||||
|
||||
// crossover: where current changes sign
|
||||
for i in 1..<smoothed.count {
|
||||
let prev = smoothed[i - 1]
|
||||
let curr = smoothed[i]
|
||||
let crossed = (prev > 0 && curr < 0) || (prev < 0 && curr > 0)
|
||||
if crossed {
|
||||
let aPrev = abs(prev)
|
||||
let aCurr = abs(curr)
|
||||
let frac = aPrev / (aPrev + aCurr)
|
||||
let dv = vVals[i] - vVals[i - 1]
|
||||
let vCross = vVals[i - 1] + frac * dv
|
||||
peaks.append(LsvPeak(vMv: vCross, iUa: 0, kind: .crossover))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// largest peak in positive voltage region -> freeCl
|
||||
var freeClIdx: Int? = nil
|
||||
var freeClVal: Float = -.infinity
|
||||
for (idx, isMax) in extrema {
|
||||
guard isMax, vVals[idx] >= 0 else { continue }
|
||||
if smoothed[idx] > freeClVal {
|
||||
freeClVal = smoothed[idx]
|
||||
freeClIdx = idx
|
||||
}
|
||||
}
|
||||
if let idx = freeClIdx {
|
||||
peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .freeCl))
|
||||
}
|
||||
|
||||
// largest peak in negative voltage region -> totalCl
|
||||
var totalClIdx: Int? = nil
|
||||
var totalClVal: Float = -.infinity
|
||||
for (idx, isMax) in extrema {
|
||||
guard isMax, vVals[idx] < 0 else { continue }
|
||||
if smoothed[idx] > totalClVal {
|
||||
totalClVal = smoothed[idx]
|
||||
totalClIdx = idx
|
||||
}
|
||||
}
|
||||
if let idx = totalClIdx {
|
||||
peaks.append(LsvPeak(vMv: vVals[idx], iUa: smoothed[idx], kind: .totalCl))
|
||||
}
|
||||
|
||||
return peaks
|
||||
}
|
||||
|
|
@ -29,6 +29,13 @@ let RSP_REF_FRAME: UInt8 = 0x20
|
|||
let RSP_REF_LP_RANGE: UInt8 = 0x21
|
||||
let RSP_REFS_DONE: UInt8 = 0x22
|
||||
let RSP_REF_STATUS: UInt8 = 0x23
|
||||
let RSP_CL_FACTOR: UInt8 = 0x24
|
||||
let RSP_PH_CAL: UInt8 = 0x25
|
||||
let RSP_SESSION_CREATED: UInt8 = 0x40
|
||||
let RSP_SESSION_SWITCHED: UInt8 = 0x41
|
||||
let RSP_SESSION_LIST: UInt8 = 0x42
|
||||
let RSP_SESSION_RENAMED: UInt8 = 0x43
|
||||
let RSP_KEEPALIVE: UInt8 = 0x50
|
||||
|
||||
// Cue -> ESP32
|
||||
let CMD_SET_SWEEP: UInt8 = 0x10
|
||||
|
|
@ -46,9 +53,17 @@ let CMD_START_PH: UInt8 = 0x24
|
|||
let CMD_START_CLEAN: UInt8 = 0x25
|
||||
let CMD_SET_CELL_K: UInt8 = 0x28
|
||||
let CMD_GET_CELL_K: UInt8 = 0x29
|
||||
let CMD_START_REFS: UInt8 = 0x30
|
||||
let CMD_GET_REFS: UInt8 = 0x31
|
||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||
let CMD_SET_CL_FACTOR: UInt8 = 0x33
|
||||
let CMD_GET_CL_FACTOR: UInt8 = 0x34
|
||||
let CMD_SET_PH_CAL: UInt8 = 0x35
|
||||
let CMD_GET_PH_CAL: UInt8 = 0x36
|
||||
let CMD_START_REFS: UInt8 = 0x30
|
||||
let CMD_GET_REFS: UInt8 = 0x31
|
||||
let CMD_CLEAR_REFS: UInt8 = 0x32
|
||||
let CMD_SESSION_CREATE: UInt8 = 0x40
|
||||
let CMD_SESSION_SWITCH: UInt8 = 0x41
|
||||
let CMD_SESSION_LIST: UInt8 = 0x42
|
||||
let CMD_SESSION_RENAME: UInt8 = 0x43
|
||||
|
||||
// MARK: - 7-bit MIDI encoding
|
||||
|
||||
|
|
@ -87,6 +102,31 @@ func encodeU16(_ val: UInt16) -> [UInt8] {
|
|||
return [mask, p[0] & 0x7F, p[1] & 0x7F]
|
||||
}
|
||||
|
||||
/// Encode a UInt32 into 5 MIDI-safe bytes.
|
||||
func encodeU32(_ val: UInt32) -> [UInt8] {
|
||||
var v = val
|
||||
let p = withUnsafeBytes(of: &v) { Array($0) }
|
||||
let mask: UInt8 = ((p[0] >> 7) & 1)
|
||||
| ((p[1] >> 6) & 2)
|
||||
| ((p[2] >> 5) & 4)
|
||||
| ((p[3] >> 4) & 8)
|
||||
return [mask, p[0] & 0x7F, p[1] & 0x7F, p[2] & 0x7F, p[3] & 0x7F]
|
||||
}
|
||||
|
||||
/// Decode 5 MIDI-safe bytes back into a UInt32.
|
||||
func decodeU32(_ d: [UInt8], at offset: Int = 0) -> UInt32 {
|
||||
let m = d[offset]
|
||||
let b0 = d[offset + 1] | ((m & 1) << 7)
|
||||
let b1 = d[offset + 2] | ((m & 2) << 6)
|
||||
let b2 = d[offset + 3] | ((m & 4) << 5)
|
||||
let b3 = d[offset + 4] | ((m & 8) << 4)
|
||||
var val: UInt32 = 0
|
||||
withUnsafeMutableBytes(of: &val) { buf in
|
||||
buf[0] = b0; buf[1] = b1; buf[2] = b2; buf[3] = b3
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/// Decode 3 MIDI-safe bytes back into a UInt16.
|
||||
func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
||||
let m = d[offset]
|
||||
|
|
@ -102,17 +142,17 @@ func decodeU16(_ d: [UInt8], at offset: Int = 0) -> UInt16 {
|
|||
// MARK: - Message enum
|
||||
|
||||
enum EisMessage {
|
||||
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float)
|
||||
case sweepStart(numPoints: UInt16, freqStart: Float, freqStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||
case dataPoint(index: UInt16, point: EisPoint)
|
||||
case sweepEnd
|
||||
case config(EisConfig)
|
||||
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float)
|
||||
case lsvStart(numPoints: UInt16, vStart: Float, vStop: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||
case lsvPoint(index: UInt16, point: LsvPoint)
|
||||
case lsvEnd
|
||||
case ampStart(vHold: Float)
|
||||
case ampStart(vHold: Float, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||
case ampPoint(index: UInt16, point: AmpPoint)
|
||||
case ampEnd
|
||||
case clStart(numPoints: UInt16)
|
||||
case clStart(numPoints: UInt16, espTimestamp: UInt32?, espMeasId: UInt16?)
|
||||
case clPoint(index: UInt16, point: ClPoint)
|
||||
case clResult(ClResult)
|
||||
case clEnd
|
||||
|
|
@ -123,6 +163,13 @@ enum EisMessage {
|
|||
case refsDone
|
||||
case refStatus(hasRefs: Bool)
|
||||
case cellK(Float)
|
||||
case clFactor(Float)
|
||||
case phCal(slope: Float, offset: Float)
|
||||
case sessionCreated(id: UInt8, name: String)
|
||||
case sessionSwitched(id: UInt8)
|
||||
case sessionList(count: UInt8, currentId: UInt8, sessions: [(id: UInt8, name: String)])
|
||||
case sessionRenamed(id: UInt8, name: String)
|
||||
case keepalive
|
||||
}
|
||||
|
||||
// MARK: - Response parser
|
||||
|
|
@ -136,10 +183,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
|||
switch data[1] {
|
||||
|
||||
case RSP_SWEEP_START where p.count >= 13:
|
||||
let hasExt = p.count >= 21
|
||||
return .sweepStart(
|
||||
numPoints: decodeU16(p, at: 0),
|
||||
freqStart: decodeFloat(p, at: 3),
|
||||
freqStop: decodeFloat(p, at: 8)
|
||||
freqStop: decodeFloat(p, at: 8),
|
||||
espTimestamp: hasExt ? decodeU32(p, at: 13) : nil,
|
||||
espMeasId: hasExt ? decodeU16(p, at: 18) : nil
|
||||
)
|
||||
|
||||
case RSP_DATA_POINT where p.count >= 28:
|
||||
|
|
@ -174,10 +224,13 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
|||
))
|
||||
|
||||
case RSP_LSV_START where p.count >= 13:
|
||||
let hasExt = p.count >= 21
|
||||
return .lsvStart(
|
||||
numPoints: decodeU16(p, at: 0),
|
||||
vStart: decodeFloat(p, at: 3),
|
||||
vStop: decodeFloat(p, at: 8)
|
||||
vStop: decodeFloat(p, at: 8),
|
||||
espTimestamp: hasExt ? decodeU32(p, at: 13) : nil,
|
||||
espMeasId: hasExt ? decodeU16(p, at: 18) : nil
|
||||
)
|
||||
|
||||
case RSP_LSV_POINT where p.count >= 13:
|
||||
|
|
@ -193,7 +246,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
|||
return .lsvEnd
|
||||
|
||||
case RSP_AMP_START where p.count >= 5:
|
||||
return .ampStart(vHold: decodeFloat(p, at: 0))
|
||||
let hasExt = p.count >= 13
|
||||
return .ampStart(
|
||||
vHold: decodeFloat(p, at: 0),
|
||||
espTimestamp: hasExt ? decodeU32(p, at: 5) : nil,
|
||||
espMeasId: hasExt ? decodeU16(p, at: 10) : nil
|
||||
)
|
||||
|
||||
case RSP_AMP_POINT where p.count >= 13:
|
||||
return .ampPoint(
|
||||
|
|
@ -208,7 +266,12 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
|||
return .ampEnd
|
||||
|
||||
case RSP_CL_START where p.count >= 3:
|
||||
return .clStart(numPoints: decodeU16(p, at: 0))
|
||||
let hasExt = p.count >= 11
|
||||
return .clStart(
|
||||
numPoints: decodeU16(p, at: 0),
|
||||
espTimestamp: hasExt ? decodeU32(p, at: 3) : nil,
|
||||
espMeasId: hasExt ? decodeU16(p, at: 8) : nil
|
||||
)
|
||||
|
||||
case RSP_CL_POINT where p.count >= 14:
|
||||
return .clPoint(
|
||||
|
|
@ -254,6 +317,58 @@ func parseSysex(_ data: [UInt8]) -> EisMessage? {
|
|||
case RSP_CELL_K where p.count >= 5:
|
||||
return .cellK(decodeFloat(p, at: 0))
|
||||
|
||||
case RSP_CL_FACTOR where p.count >= 5:
|
||||
return .clFactor(decodeFloat(p, at: 0))
|
||||
|
||||
case RSP_PH_CAL where p.count >= 10:
|
||||
return .phCal(
|
||||
slope: decodeFloat(p, at: 0),
|
||||
offset: decodeFloat(p, at: 5)
|
||||
)
|
||||
|
||||
case RSP_SESSION_CREATED where p.count >= 2:
|
||||
let sid = p[0]
|
||||
let nameLen = Int(p[1])
|
||||
let name = nameLen > 0 && p.count >= 2 + nameLen
|
||||
? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? ""
|
||||
: ""
|
||||
return .sessionCreated(id: sid, name: name)
|
||||
|
||||
case RSP_SESSION_SWITCHED where p.count >= 1:
|
||||
return .sessionSwitched(id: p[0])
|
||||
|
||||
case RSP_SESSION_LIST where p.count >= 2:
|
||||
let count = p[0]
|
||||
let currentId = p[1]
|
||||
var sessions: [(id: UInt8, name: String)] = []
|
||||
var off = 2
|
||||
for _ in 0..<count {
|
||||
guard off < p.count else { break }
|
||||
let sid = p[off]; off += 1
|
||||
guard off < p.count else { break }
|
||||
let nameLen = Int(p[off]); off += 1
|
||||
let name: String
|
||||
if nameLen > 0 && off + nameLen <= p.count {
|
||||
name = String(bytes: p[off..<(off + nameLen)], encoding: .utf8) ?? ""
|
||||
off += nameLen
|
||||
} else {
|
||||
name = ""
|
||||
}
|
||||
sessions.append((id: sid, name: name))
|
||||
}
|
||||
return .sessionList(count: count, currentId: currentId, sessions: sessions)
|
||||
|
||||
case RSP_SESSION_RENAMED where p.count >= 2:
|
||||
let sid = p[0]
|
||||
let nameLen = Int(p[1])
|
||||
let name = nameLen > 0 && p.count >= 2 + nameLen
|
||||
? String(bytes: p[2..<(2 + nameLen)], encoding: .utf8) ?? ""
|
||||
: ""
|
||||
return .sessionRenamed(id: sid, name: name)
|
||||
|
||||
case RSP_KEEPALIVE:
|
||||
return .keepalive
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
@ -290,12 +405,13 @@ func buildSysexGetConfig() -> [UInt8] {
|
|||
[0xF0, sysexMfr, CMD_GET_CONFIG, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia) -> [UInt8] {
|
||||
func buildSysexStartLsv(vStart: Float, vStop: Float, scanRate: Float, lpRtia: LpRtia, numPoints: UInt16) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_START_LSV]
|
||||
sx.append(contentsOf: encodeFloat(vStart))
|
||||
sx.append(contentsOf: encodeFloat(vStop))
|
||||
sx.append(contentsOf: encodeFloat(scanRate))
|
||||
sx.append(lpRtia.rawValue)
|
||||
sx.append(contentsOf: encodeU16(numPoints))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
|
@ -371,3 +487,52 @@ func buildSysexSetCellK(_ k: Float) -> [UInt8] {
|
|||
func buildSysexGetCellK() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_CELL_K, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSetClFactor(_ f: Float) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_CL_FACTOR]
|
||||
sx.append(contentsOf: encodeFloat(f))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexGetClFactor() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_CL_FACTOR, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSetPhCal(_ slope: Float, _ offset: Float) -> [UInt8] {
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SET_PH_CAL]
|
||||
sx.append(contentsOf: encodeFloat(slope))
|
||||
sx.append(contentsOf: encodeFloat(offset))
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexGetPhCal() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_GET_PH_CAL, 0xF7]
|
||||
}
|
||||
|
||||
// MARK: - Session commands
|
||||
|
||||
func buildSysexSessionCreate(name: String) -> [UInt8] {
|
||||
let nameBytes = Array(name.utf8.prefix(32))
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_CREATE, UInt8(nameBytes.count)]
|
||||
sx.append(contentsOf: nameBytes)
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
||||
func buildSysexSessionSwitch(id: UInt8) -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_SESSION_SWITCH, id, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSessionList() -> [UInt8] {
|
||||
[0xF0, sysexMfr, CMD_SESSION_LIST, 0xF7]
|
||||
}
|
||||
|
||||
func buildSysexSessionRename(id: UInt8, name: String) -> [UInt8] {
|
||||
let nameBytes = Array(name.utf8.prefix(32))
|
||||
var sx: [UInt8] = [0xF0, sysexMfr, CMD_SESSION_RENAME, id, UInt8(nameBytes.count)]
|
||||
sx.append(contentsOf: nameBytes)
|
||||
sx.append(0xF7)
|
||||
return sx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ struct Session: Codable, FetchableRecord, MutablePersistableRecord {
|
|||
var startedAt: Date
|
||||
var label: String?
|
||||
var notes: String?
|
||||
var firmwareSessionId: Int64?
|
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
|
|
@ -24,6 +25,7 @@ struct Measurement: Codable, FetchableRecord, MutablePersistableRecord {
|
|||
var startedAt: Date
|
||||
var config: Data?
|
||||
var resultSummary: Data?
|
||||
var espTimestamp: Int64?
|
||||
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
|
|
@ -89,19 +91,34 @@ final class Storage: @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
migrator.registerMigration("v2") { db in
|
||||
try db.alter(table: "measurement") { t in
|
||||
t.add(column: "espTimestamp", .integer)
|
||||
}
|
||||
try db.alter(table: "session") { t in
|
||||
t.add(column: "firmwareSessionId", .integer)
|
||||
}
|
||||
}
|
||||
|
||||
try migrator.migrate(dbQueue)
|
||||
}
|
||||
|
||||
// MARK: - Sessions
|
||||
|
||||
func createSession(label: String? = nil) throws -> Session {
|
||||
func createSession(label: String? = nil, firmwareSessionId: Int64? = nil) throws -> Session {
|
||||
try dbQueue.write { db in
|
||||
var s = Session(startedAt: Date(), label: label)
|
||||
var s = Session(startedAt: Date(), label: label, firmwareSessionId: firmwareSessionId)
|
||||
try s.insert(db)
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSession(_ id: Int64) -> Session? {
|
||||
try? dbQueue.read { db in
|
||||
try Session.fetchOne(db, key: id)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSessions() throws -> [Session] {
|
||||
try dbQueue.read { db in
|
||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||
|
|
@ -123,13 +140,43 @@ final class Storage: @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
func updateSessionLabel(_ id: Int64, label: String) throws {
|
||||
try dbQueue.write { db in
|
||||
try db.execute(
|
||||
sql: "UPDATE session SET label = ? WHERE id = ?",
|
||||
arguments: [label, id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionByFirmwareId(_ fwId: Int64) -> Session? {
|
||||
try? dbQueue.read { db in
|
||||
try Session
|
||||
.filter(Column("firmwareSessionId") == fwId)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Measurements
|
||||
|
||||
func measurementExists(sessionId: Int64, espTimestamp: Int64) -> Bool {
|
||||
(try? dbQueue.read { db in
|
||||
try Measurement
|
||||
.filter(Column("sessionId") == sessionId)
|
||||
.filter(Column("espTimestamp") == espTimestamp)
|
||||
.fetchCount(db) > 0
|
||||
}) ?? false
|
||||
}
|
||||
|
||||
func addMeasurement(
|
||||
sessionId: Int64,
|
||||
type: MeasurementType,
|
||||
config: (any Encodable)? = nil
|
||||
config: (any Encodable)? = nil,
|
||||
espTimestamp: Int64? = nil
|
||||
) throws -> Measurement {
|
||||
if let ts = espTimestamp, measurementExists(sessionId: sessionId, espTimestamp: ts) {
|
||||
throw StorageError.duplicate
|
||||
}
|
||||
let configData: Data? = if let config {
|
||||
try JSONEncoder().encode(config)
|
||||
} else {
|
||||
|
|
@ -140,7 +187,8 @@ final class Storage: @unchecked Sendable {
|
|||
sessionId: sessionId,
|
||||
type: type.rawValue,
|
||||
startedAt: Date(),
|
||||
config: configData
|
||||
config: configData,
|
||||
espTimestamp: espTimestamp
|
||||
)
|
||||
try m.insert(db)
|
||||
return m
|
||||
|
|
@ -231,9 +279,10 @@ final class Storage: @unchecked Sendable {
|
|||
|
||||
// MARK: - Observation (for SwiftUI live updates)
|
||||
|
||||
@MainActor
|
||||
func observeDataPoints(
|
||||
measurementId: Int64,
|
||||
onChange: @escaping ([DataPoint]) -> Void
|
||||
onChange: @escaping @Sendable ([DataPoint]) -> Void
|
||||
) -> DatabaseCancellable {
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try DataPoint
|
||||
|
|
@ -244,7 +293,8 @@ final class Storage: @unchecked Sendable {
|
|||
return observation.start(in: dbQueue, onError: { _ in }, onChange: onChange)
|
||||
}
|
||||
|
||||
func observeSessions(onChange: @escaping ([Session]) -> Void) -> DatabaseCancellable {
|
||||
@MainActor
|
||||
func observeSessions(onChange: @escaping @Sendable ([Session]) -> Void) -> DatabaseCancellable {
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try Session.order(Column("startedAt").desc).fetchAll(db)
|
||||
}
|
||||
|
|
@ -458,6 +508,7 @@ final class Storage: @unchecked Sendable {
|
|||
|
||||
enum StorageError: Error {
|
||||
case notFound
|
||||
case duplicate
|
||||
case parseError(String)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ final class UDPManager: @unchecked Sendable {
|
|||
var address: String
|
||||
var port: UInt16
|
||||
|
||||
/// Suppress keepalive timeout during blocking firmware operations (pH, clean, refs)
|
||||
var measuring: Bool = false
|
||||
|
||||
private var connection: NWConnection?
|
||||
private let queue = DispatchQueue(label: "udp.eis4", qos: .userInitiated)
|
||||
private var onMessage: ((EisMessage) -> Void)?
|
||||
private var onDisconnect: (() -> Void)?
|
||||
private var keepaliveTimer: Timer?
|
||||
private var timeoutTimer: Timer?
|
||||
private var lastReceived: Date = .distantPast
|
||||
|
|
@ -46,6 +50,10 @@ final class UDPManager: @unchecked Sendable {
|
|||
onMessage = handler
|
||||
}
|
||||
|
||||
func setDisconnectHandler(_ handler: @escaping () -> Void) {
|
||||
onDisconnect = handler
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
func connect() {
|
||||
|
|
@ -64,8 +72,9 @@ final class UDPManager: @unchecked Sendable {
|
|||
connection = conn
|
||||
|
||||
conn.stateUpdateHandler = { [weak self] newState in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self?.handleStateChange(newState)
|
||||
self.handleStateChange(newState)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +85,9 @@ final class UDPManager: @unchecked Sendable {
|
|||
stopTimers()
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
measuring = false
|
||||
state = .disconnected
|
||||
onDisconnect?()
|
||||
}
|
||||
|
||||
func send(_ sysex: [UInt8]) {
|
||||
|
|
@ -95,6 +106,8 @@ final class UDPManager: @unchecked Sendable {
|
|||
send(buildSysexGetTemp())
|
||||
send(buildSysexGetConfig())
|
||||
send(buildSysexGetCellK())
|
||||
send(buildSysexGetClFactor())
|
||||
send(buildSysexGetPhCal())
|
||||
startTimers()
|
||||
receiveLoop()
|
||||
|
||||
|
|
@ -172,16 +185,18 @@ final class UDPManager: @unchecked Sendable {
|
|||
|
||||
private func startTimers() {
|
||||
keepaliveTimer = Timer.scheduledTimer(withTimeInterval: Self.keepaliveInterval, repeats: true) { [weak self] _ in
|
||||
self?.send(buildSysexGetTemp())
|
||||
guard let self else { return }
|
||||
self.send(buildSysexGetTemp())
|
||||
}
|
||||
|
||||
timeoutTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
|
||||
guard let self, self.state == .connected else { return }
|
||||
guard let self, self.state == .connected, !self.measuring else { return }
|
||||
if Date().timeIntervalSince(self.lastReceived) > Self.timeout {
|
||||
self.state = .disconnected
|
||||
self.stopTimers()
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.onDisconnect?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ struct CalibrateView: View {
|
|||
inputSection
|
||||
resultsSection
|
||||
cellConstantSection
|
||||
chlorineCalSection
|
||||
phCalibrationSection
|
||||
}
|
||||
.navigationTitle("Calibrate")
|
||||
}
|
||||
|
|
@ -115,6 +117,122 @@ struct CalibrateView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Chlorine calibration
|
||||
|
||||
private var chlorineCalSection: some View {
|
||||
Section("Chlorine Calibration") {
|
||||
if let f = state.clFactor {
|
||||
Text(String(format: "Cl factor: %.6f ppm/\u{00B5}A", f))
|
||||
}
|
||||
if let r = state.clResult {
|
||||
Text(String(format: "Last free Cl peak: %.3f \u{00B5}A", r.iFreeUa))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Known Cl ppm")
|
||||
Spacer()
|
||||
TextField("ppm", text: $state.clCalKnownPpm)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
#if os(iOS)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
|
||||
Button("Set Cl Factor") {
|
||||
guard let r = state.clResult else {
|
||||
state.status = "No chlorine measurement"
|
||||
return
|
||||
}
|
||||
let knownPpm = Double(state.clCalKnownPpm) ?? 0
|
||||
let peak = abs(Double(r.iFreeUa))
|
||||
guard peak > 0 else {
|
||||
state.status = "Peak current is zero"
|
||||
return
|
||||
}
|
||||
let factor = knownPpm / peak
|
||||
state.clFactor = factor
|
||||
state.send(buildSysexSetClFactor(Float(factor)))
|
||||
state.status = String(format: "Cl factor: %.6f ppm/\u{00B5}A", factor)
|
||||
}
|
||||
.disabled(state.clResult == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - pH calibration
|
||||
|
||||
private var phCalibrationSection: some View {
|
||||
Section("pH Calibration (Q/HQ peak-shift)") {
|
||||
if let s = state.phSlope, let o = state.phOffset {
|
||||
Text(String(format: "slope: %.4f mV/pH offset: %.4f mV", s, o))
|
||||
if let peak = detectQhqPeak(state.lsvPoints) {
|
||||
if abs(s) > 1e-6 {
|
||||
let ph = (Double(peak) - o) / s
|
||||
Text(String(format: "Computed pH: %.2f (peak at %.1f mV)", ph, peak))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Known pH")
|
||||
Spacer()
|
||||
TextField("7.00", text: $state.phCalKnown)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
#if os(iOS)
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
|
||||
Button("Add Calibration Point") {
|
||||
guard let peak = detectQhqPeak(state.lsvPoints) else {
|
||||
state.status = "No Q/HQ peak found in LSV data"
|
||||
return
|
||||
}
|
||||
guard let ph = Double(state.phCalKnown) else { return }
|
||||
state.phCalPoints.append((ph: ph, mV: Double(peak)))
|
||||
state.status = String(format: "pH cal point: pH=%.2f peak=%.1f mV (%d pts)",
|
||||
ph, peak, state.phCalPoints.count)
|
||||
}
|
||||
.disabled(state.lsvPoints.isEmpty)
|
||||
|
||||
ForEach(Array(state.phCalPoints.enumerated()), id: \.offset) { i, pt in
|
||||
Text(String(format: "%d. pH=%.2f peak=%.1f mV", i + 1, pt.ph, pt.mV))
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Button("Clear Points") {
|
||||
state.phCalPoints.removeAll()
|
||||
state.status = "pH cal points cleared"
|
||||
}
|
||||
.disabled(state.phCalPoints.isEmpty)
|
||||
|
||||
Button("Compute & Set pH Cal") {
|
||||
let pts = state.phCalPoints
|
||||
guard pts.count >= 2 else {
|
||||
state.status = "Need at least 2 calibration points"
|
||||
return
|
||||
}
|
||||
let n = Double(pts.count)
|
||||
let meanPh = pts.map(\.ph).reduce(0, +) / n
|
||||
let meanV = pts.map(\.mV).reduce(0, +) / n
|
||||
let num = pts.map { ($0.ph - meanPh) * ($0.mV - meanV) }.reduce(0, +)
|
||||
let den = pts.map { ($0.ph - meanPh) * ($0.ph - meanPh) }.reduce(0, +)
|
||||
guard abs(den) > 1e-12 else {
|
||||
state.status = "Degenerate calibration data"
|
||||
return
|
||||
}
|
||||
let slope = num / den
|
||||
let offset = meanV - slope * meanPh
|
||||
state.phSlope = slope
|
||||
state.phOffset = offset
|
||||
state.send(buildSysexSetPhCal(Float(slope), Float(offset)))
|
||||
state.status = String(format: "pH cal set: slope=%.4f offset=%.4f", slope, offset)
|
||||
}
|
||||
.disabled(state.phCalPoints.count < 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calculations
|
||||
|
||||
private func saltGrams(volumeGal: Double, ppm: Double) -> Double {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ struct ChlorineView: View {
|
|||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
controlsRow
|
||||
clPeakLabels
|
||||
Divider()
|
||||
GeometryReader { geo in
|
||||
if geo.size.width > 700 {
|
||||
HSplitLayout(ratio: 0.55) {
|
||||
VStack(spacing: 4) {
|
||||
voltammogramPlot
|
||||
resultBanner
|
||||
chlorinePlot
|
||||
}
|
||||
|
|
@ -21,8 +23,9 @@ struct ChlorineView: View {
|
|||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
voltammogramPlot.frame(height: 250)
|
||||
resultBanner
|
||||
chlorinePlot.frame(height: 350)
|
||||
chlorinePlot.frame(height: 250)
|
||||
clTable.frame(height: 300)
|
||||
}
|
||||
.padding()
|
||||
|
|
@ -37,18 +40,54 @@ struct ChlorineView: View {
|
|||
private var controlsRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
||||
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
||||
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
||||
LabeledField("Total mV", text: $state.clTotalV, width: 70)
|
||||
LabeledField("Settle ms", text: $state.clDepT, width: 70)
|
||||
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
|
||||
if state.clManualPeaks {
|
||||
Button("Start LSV") { state.startLSV() }
|
||||
.buttonStyle(ActionButtonStyle(color: .green))
|
||||
|
||||
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
|
||||
.frame(width: 120)
|
||||
Button("Manual") {
|
||||
state.clManualPeaks = false
|
||||
state.lsvPeaks = detectLsvPeaks(state.lsvPoints)
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Button("Measure") { state.startChlorine() }
|
||||
.buttonStyle(ActionButtonStyle(color: .green))
|
||||
Divider().frame(height: 24)
|
||||
|
||||
LabeledField("Cond mV", text: $state.clCondV, width: 70)
|
||||
LabeledField("Cond ms", text: $state.clCondT, width: 70)
|
||||
LabeledField("Free mV", text: $state.clFreeV, width: 70)
|
||||
LabeledField("Total mV", text: $state.clTotalV, width: 70)
|
||||
LabeledField("Settle ms", text: $state.clDepT, width: 70)
|
||||
LabeledField("Meas ms", text: $state.clMeasT, width: 70)
|
||||
|
||||
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
|
||||
.frame(width: 120)
|
||||
|
||||
Button("Measure") { state.startChlorine() }
|
||||
.buttonStyle(ActionButtonStyle(color: .green))
|
||||
} else {
|
||||
Button("Start Auto") { state.startClAuto() }
|
||||
.buttonStyle(ActionButtonStyle(color: .green))
|
||||
.disabled(state.clAutoState != .idle)
|
||||
|
||||
Button("Auto") {
|
||||
state.clManualPeaks = true
|
||||
state.lsvPeaks.removeAll()
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
LabeledPicker("RTIA", selection: $state.clRtia, items: LpRtia.allCases) { $0.label }
|
||||
.frame(width: 120)
|
||||
|
||||
if let pots = state.clAutoPotentials {
|
||||
Text(String(format: "free=%.0f%@ total=%.0f%@",
|
||||
pots.vFree,
|
||||
pots.vFreeDetected ? "" : "?",
|
||||
pots.vTotal,
|
||||
pots.vTotalDetected ? "" : "?"))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
|
@ -68,6 +107,12 @@ struct ChlorineView: View {
|
|||
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let f = state.clFactor {
|
||||
let ppm = f * Double(abs(r.iFreeUa))
|
||||
Text(String(format: "Free Cl: %.2f ppm", ppm))
|
||||
.foregroundStyle(.cyan)
|
||||
}
|
||||
|
||||
if let (_, refR) = state.clRef {
|
||||
Divider().frame(height: 16)
|
||||
Text(String(format: "vs Ref: dFree=%.3f dTotal=%.3f",
|
||||
|
|
@ -82,7 +127,102 @@ struct ChlorineView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Plot
|
||||
// MARK: - Peak labels
|
||||
|
||||
@ViewBuilder
|
||||
private var clPeakLabels: some View {
|
||||
if !state.lsvPeaks.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
|
||||
let label: String = {
|
||||
switch peak.kind {
|
||||
case .freeCl: return String(format: "Free: %.0fmV %.2fuA", peak.vMv, peak.iUa)
|
||||
case .totalCl: return String(format: "Total: %.0fmV %.2fuA", peak.vMv, peak.iUa)
|
||||
case .crossover: return String(format: "X-over: %.0fmV", peak.vMv)
|
||||
}
|
||||
}()
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(clPeakColor(peak.kind))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func clPeakColor(_ kind: PeakKind) -> Color {
|
||||
switch kind {
|
||||
case .freeCl: .green
|
||||
case .totalCl: .orange
|
||||
case .crossover: .purple
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voltammogram
|
||||
|
||||
private var voltammogramPlot: some View {
|
||||
Group {
|
||||
if state.lsvPoints.isEmpty {
|
||||
Text("No LSV data")
|
||||
.foregroundStyle(Color(white: 0.4))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.3))
|
||||
} else {
|
||||
PlotContainer(title: "") {
|
||||
Chart {
|
||||
if let ref = state.lsvRef {
|
||||
ForEach(Array(ref.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(x: .value("V", Double(pt.vMv)), y: .value("I", Double(pt.iUa)))
|
||||
.foregroundStyle(Color.gray.opacity(0.5))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(x: .value("V", Double(pt.vMv)), y: .value("I", Double(pt.iUa)))
|
||||
.foregroundStyle(Color.yellow)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
ForEach(Array(state.lsvPoints.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(x: .value("V", Double(pt.vMv)), y: .value("I", Double(pt.iUa)))
|
||||
.foregroundStyle(Color.yellow)
|
||||
.symbolSize(16)
|
||||
}
|
||||
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
|
||||
PointMark(x: .value("V", Double(peak.vMv)), y: .value("I", Double(peak.iUa)))
|
||||
.foregroundStyle(clPeakColor(peak.kind))
|
||||
.symbolSize(100)
|
||||
.symbol(.diamond)
|
||||
RuleMark(x: .value("V", Double(peak.vMv)))
|
||||
.foregroundStyle(clPeakColor(peak.kind).opacity(0.3))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("V (mV)")
|
||||
.chartYAxisLabel("I (uA)", position: .leading)
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel().font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel().font(.caption2).foregroundStyle(Color.yellow)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
// MARK: - Chlorine plot
|
||||
|
||||
private var chlorinePlot: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -285,46 +285,68 @@ struct EISView: View {
|
|||
with: .color(nyqColor))
|
||||
}
|
||||
|
||||
// circle fit
|
||||
if let fit = kasaCircleFit(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
||||
let disc = fit.r * fit.r - fit.cy * fit.cy
|
||||
if disc > 0 {
|
||||
let sd = sqrt(disc)
|
||||
let rs = fit.cx - sd
|
||||
let rp = 2 * sd
|
||||
// fit overlay
|
||||
let fitColor = Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)
|
||||
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
|
||||
|
||||
if rp > 0 {
|
||||
let thetaR = atan2(-fit.cy, sd)
|
||||
var thetaL = atan2(-fit.cy, -sd)
|
||||
if thetaL < thetaR { thetaL += 2 * .pi }
|
||||
if let result = fitNyquist(points: points.map { (Double($0.zReal), Double(-$0.zImag)) }) {
|
||||
switch result {
|
||||
case .circle(let fit):
|
||||
let disc = fit.r * fit.r - fit.cy * fit.cy
|
||||
if disc > 0 {
|
||||
let sd = sqrt(disc)
|
||||
let rs = fit.cx - sd
|
||||
let rp = 2 * sd
|
||||
|
||||
let nArc = 120
|
||||
var arcPath = Path()
|
||||
for i in 0...nArc {
|
||||
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
|
||||
let ax = fit.cx + fit.r * cos(t)
|
||||
let ay = fit.cy + fit.r * sin(t)
|
||||
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
||||
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
||||
if rp > 0 {
|
||||
let thetaR = atan2(-fit.cy, sd)
|
||||
var thetaL = atan2(-fit.cy, -sd)
|
||||
if thetaL < thetaR { thetaL += 2 * .pi }
|
||||
|
||||
let nArc = 120
|
||||
var arcPath = Path()
|
||||
for i in 0...nArc {
|
||||
let t = thetaR + (thetaL - thetaR) * Double(i) / Double(nArc)
|
||||
let ax = fit.cx + fit.r * cos(t)
|
||||
let ay = fit.cy + fit.r * sin(t)
|
||||
let pt = CGPoint(x: lx(CGFloat(ax)), y: ly(CGFloat(ay)))
|
||||
if i == 0 { arcPath.move(to: pt) } else { arcPath.addLine(to: pt) }
|
||||
}
|
||||
context.stroke(arcPath, with: .color(fitColor), lineWidth: 1.5)
|
||||
|
||||
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
|
||||
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
|
||||
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||
with: .color(fitPtColor))
|
||||
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
|
||||
with: .color(fitPtColor))
|
||||
context.draw(
|
||||
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
|
||||
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
|
||||
context.draw(
|
||||
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
|
||||
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
|
||||
}
|
||||
context.stroke(arcPath, with: .color(Color(red: 0.7, green: 0.3, blue: 0.9).opacity(0.5)),
|
||||
lineWidth: 1.5)
|
||||
|
||||
// Rs and Rp markers
|
||||
let fitPtColor = Color(red: 0.9, green: 0.4, blue: 0.9)
|
||||
let rsScr = CGPoint(x: lx(CGFloat(rs)), y: ly(0))
|
||||
let rpScr = CGPoint(x: lx(CGFloat(rs + rp)), y: ly(0))
|
||||
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||
with: .color(fitPtColor))
|
||||
context.fill(Path(ellipseIn: CGRect(x: rpScr.x - 5, y: rpScr.y - 5, width: 10, height: 10)),
|
||||
with: .color(fitPtColor))
|
||||
context.draw(
|
||||
Text(String(format: "Rs=%.0f", rs)).font(.caption2).foregroundStyle(fitPtColor),
|
||||
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
|
||||
context.draw(
|
||||
Text(String(format: "Rp=%.0f", rp)).font(.caption2).foregroundStyle(fitPtColor),
|
||||
at: CGPoint(x: rpScr.x, y: rpScr.y + 14))
|
||||
}
|
||||
case .linear(let fit):
|
||||
let xVals = points.map { CGFloat($0.zReal) }.filter { $0.isFinite }
|
||||
guard let xMin = xVals.min(), let xMax = xVals.max() else { break }
|
||||
let pad = (xMax - xMin) * 0.1
|
||||
let x0 = Double(xMin - pad)
|
||||
let x1 = Double(xMax + pad)
|
||||
let y0 = fit.slope * x0 + fit.yIntercept
|
||||
let y1 = fit.slope * x1 + fit.yIntercept
|
||||
var linePath = Path()
|
||||
linePath.move(to: CGPoint(x: lx(CGFloat(x0)), y: ly(CGFloat(y0))))
|
||||
linePath.addLine(to: CGPoint(x: lx(CGFloat(x1)), y: ly(CGFloat(y1))))
|
||||
context.stroke(linePath, with: .color(fitColor), lineWidth: 1.5)
|
||||
|
||||
let rsScr = CGPoint(x: lx(CGFloat(fit.rs)), y: ly(0))
|
||||
context.fill(Path(ellipseIn: CGRect(x: rsScr.x - 5, y: rsScr.y - 5, width: 10, height: 10)),
|
||||
with: .color(fitPtColor))
|
||||
context.draw(
|
||||
Text(String(format: "Rs=%.0f", fit.rs)).font(.caption2).foregroundStyle(fitPtColor),
|
||||
at: CGPoint(x: rsScr.x, y: rsScr.y + 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -370,7 +392,7 @@ struct EISView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Kasa circle fit (ported from plot.rs)
|
||||
// MARK: - Nyquist fit
|
||||
|
||||
struct CircleFitResult {
|
||||
let cx: Double
|
||||
|
|
@ -378,6 +400,17 @@ struct CircleFitResult {
|
|||
let r: Double
|
||||
}
|
||||
|
||||
struct LinearFitResult {
|
||||
let slope: Double
|
||||
let yIntercept: Double
|
||||
let rs: Double
|
||||
}
|
||||
|
||||
enum NyquistFitResult {
|
||||
case circle(CircleFitResult)
|
||||
case linear(LinearFitResult)
|
||||
}
|
||||
|
||||
func kasaCircleFit(points: [(Double, Double)]) -> CircleFitResult? {
|
||||
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
||||
guard all.count >= 4 else { return nil }
|
||||
|
|
@ -463,6 +496,57 @@ private func kasaFitRaw(_ pts: [(Double, Double)]) -> (Double, Double, Double)?
|
|||
return (cx, cy, sqrt(rSq))
|
||||
}
|
||||
|
||||
private func cumulativeTurning(_ pts: [(Double, Double)]) -> Double {
|
||||
guard pts.count >= 3 else { return 0 }
|
||||
var total = 0.0
|
||||
for i in 1..<(pts.count - 1) {
|
||||
let (dx1, dy1) = (pts[i].0 - pts[i-1].0, pts[i].1 - pts[i-1].1)
|
||||
let (dx2, dy2) = (pts[i+1].0 - pts[i].0, pts[i+1].1 - pts[i].1)
|
||||
let cross = dx1 * dy2 - dy1 * dx2
|
||||
let dot = dx1 * dx2 + dy1 * dy2
|
||||
total += abs(atan2(cross, dot))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func fitLinear(_ pts: [(Double, Double)]) -> LinearFitResult? {
|
||||
guard pts.count >= 2 else { return nil }
|
||||
let n = Double(pts.count)
|
||||
let sx = pts.map(\.0).reduce(0, +)
|
||||
let sy = pts.map(\.1).reduce(0, +)
|
||||
let sx2 = pts.map { $0.0 * $0.0 }.reduce(0, +)
|
||||
let sxy = pts.map { $0.0 * $0.1 }.reduce(0, +)
|
||||
let denom = n * sx2 - sx * sx
|
||||
guard abs(denom) > 1e-20 else { return nil }
|
||||
let slope = (n * sxy - sx * sy) / denom
|
||||
let yInt = (sy - slope * sx) / n
|
||||
let rs = abs(slope) > 1e-10 ? -yInt / slope : sx / n
|
||||
return LinearFitResult(slope: slope, yIntercept: yInt, rs: rs)
|
||||
}
|
||||
|
||||
func fitNyquist(points: [(Double, Double)]) -> NyquistFitResult? {
|
||||
let all = points.filter { $0.0.isFinite && $0.1.isFinite }
|
||||
guard all.count >= 4 else { return nil }
|
||||
|
||||
if cumulativeTurning(all) < 0.524 {
|
||||
if let lin = fitLinear(all) { return .linear(lin) }
|
||||
}
|
||||
|
||||
if let circle = kasaCircleFit(points: points) {
|
||||
let avgErr = all.map { p in
|
||||
abs(sqrt((p.0 - circle.cx) * (p.0 - circle.cx) +
|
||||
(p.1 - circle.cy) * (p.1 - circle.cy)) - circle.r)
|
||||
}.reduce(0, +) / (Double(all.count) * circle.r)
|
||||
if avgErr > 0.15 {
|
||||
if let lin = fitLinear(all) { return .linear(lin) }
|
||||
}
|
||||
return .circle(circle)
|
||||
}
|
||||
|
||||
if let lin = fitLinear(all) { return .linear(lin) }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Canvas drawing helpers
|
||||
|
||||
private func drawPolyline(context: GraphicsContext, points: [CGPoint], color: Color, width: CGFloat) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct LSVView: View {
|
|||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
controlsRow
|
||||
peakLabels
|
||||
Divider()
|
||||
GeometryReader { geo in
|
||||
if geo.size.width > 700 {
|
||||
|
|
@ -40,6 +41,15 @@ struct LSVView: View {
|
|||
LabeledPicker("RTIA", selection: $state.lsvRtia, items: LpRtia.allCases) { $0.label }
|
||||
.frame(width: 120)
|
||||
|
||||
LabeledPicker("Density", selection: $state.lsvDensityMode, items: LsvDensityMode.allCases) { $0.rawValue }
|
||||
.frame(width: 100)
|
||||
|
||||
LabeledField("Value", text: $state.lsvDensity, width: 60)
|
||||
|
||||
Text("\(state.lsvCalcPoints()) pts")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button("Start LSV") { state.startLSV() }
|
||||
.buttonStyle(ActionButtonStyle(color: .green))
|
||||
}
|
||||
|
|
@ -86,6 +96,18 @@ struct LSVView: View {
|
|||
.foregroundStyle(Color.yellow)
|
||||
.symbolSize(16)
|
||||
}
|
||||
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
|
||||
PointMark(
|
||||
x: .value("V", Double(peak.vMv)),
|
||||
y: .value("I", Double(peak.iUa))
|
||||
)
|
||||
.foregroundStyle(peakColor(peak.kind))
|
||||
.symbolSize(100)
|
||||
.symbol(.diamond)
|
||||
RuleMark(x: .value("V", Double(peak.vMv)))
|
||||
.foregroundStyle(peakColor(peak.kind).opacity(0.3))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("V (mV)")
|
||||
.chartYAxisLabel("I (uA)", position: .leading)
|
||||
|
|
@ -115,6 +137,36 @@ struct LSVView: View {
|
|||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var peakLabels: some View {
|
||||
if !state.lsvPeaks.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(Array(state.lsvPeaks.enumerated()), id: \.offset) { _, peak in
|
||||
let label: String = {
|
||||
switch peak.kind {
|
||||
case .freeCl: return String(format: "Free: %.0fmV %.2fuA", peak.vMv, peak.iUa)
|
||||
case .totalCl: return String(format: "Total: %.0fmV %.2fuA", peak.vMv, peak.iUa)
|
||||
case .crossover: return String(format: "X-over: %.0fmV", peak.vMv)
|
||||
}
|
||||
}()
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(peakColor(peak.kind))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func peakColor(_ kind: PeakKind) -> Color {
|
||||
switch kind {
|
||||
case .freeCl: .green
|
||||
case .totalCl: .orange
|
||||
case .crossover: .purple
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table
|
||||
|
||||
private var lsvTable: some View {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,641 @@
|
|||
/// Measurement data viewer — switches on type to show appropriate charts.
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
// MARK: - Router
|
||||
|
||||
struct MeasurementDataView: View {
|
||||
let measurement: Measurement
|
||||
|
||||
@State private var points: [DataPoint] = []
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !loaded {
|
||||
ProgressView()
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
.navigationTitle(typeLabel)
|
||||
.onAppear { loadPoints() }
|
||||
}
|
||||
|
||||
private func loadPoints() {
|
||||
guard let mid = measurement.id else { return }
|
||||
points = (try? Storage.shared.fetchDataPoints(measurementId: mid)) ?? []
|
||||
loaded = true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch MeasurementType(rawValue: measurement.type) {
|
||||
case .eis:
|
||||
EisDataView(points: decodePoints(EisPoint.self))
|
||||
case .lsv:
|
||||
LsvDataView(points: decodePoints(LsvPoint.self))
|
||||
case .amp:
|
||||
AmpDataView(points: decodePoints(AmpPoint.self))
|
||||
case .chlorine:
|
||||
ClDataView(
|
||||
points: decodePoints(ClPoint.self),
|
||||
result: decodeResult(ClResult.self)
|
||||
)
|
||||
case .ph:
|
||||
PhDataView(result: decodeResult(PhResult.self))
|
||||
case nil:
|
||||
Text("Unknown type: \(measurement.type)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var typeLabel: String {
|
||||
switch measurement.type {
|
||||
case "eis": "EIS"
|
||||
case "lsv": "LSV"
|
||||
case "amp": "Amperometry"
|
||||
case "chlorine": "Chlorine"
|
||||
case "ph": "pH"
|
||||
default: measurement.type
|
||||
}
|
||||
}
|
||||
|
||||
private func decodePoints<T: Decodable>(_ type: T.Type) -> [T] {
|
||||
let decoder = JSONDecoder()
|
||||
return points.compactMap { try? decoder.decode(T.self, from: $0.payload) }
|
||||
}
|
||||
|
||||
private func decodeResult<T: Decodable>(_ type: T.Type) -> T? {
|
||||
guard let data = measurement.resultSummary else { return nil }
|
||||
return try? JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EIS data view
|
||||
|
||||
enum EisPlotMode: String, CaseIterable, Identifiable {
|
||||
case nyquist = "Nyquist"
|
||||
case bodeMag = "Bode |Z|"
|
||||
case bodePhase = "Bode Phase"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct EisDataView: View {
|
||||
let points: [EisPoint]
|
||||
@State private var plotMode: EisPlotMode = .nyquist
|
||||
@State private var showTable = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Picker("Plot", selection: $plotMode) {
|
||||
ForEach(EisPlotMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
if points.isEmpty {
|
||||
noData
|
||||
} else {
|
||||
plotView
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if showTable && !points.isEmpty {
|
||||
Divider()
|
||||
eisTable
|
||||
.frame(maxHeight: 250)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(showTable ? "Hide Table" : "Show Table") {
|
||||
showTable.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var plotView: some View {
|
||||
switch plotMode {
|
||||
case .nyquist:
|
||||
nyquistChart
|
||||
.padding()
|
||||
case .bodeMag:
|
||||
bodeMagChart
|
||||
.padding()
|
||||
case .bodePhase:
|
||||
bodePhaseChart
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private var nyquistChart: some View {
|
||||
Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(
|
||||
x: .value("Z'", Double(pt.zReal)),
|
||||
y: .value("-Z''", Double(-pt.zImag))
|
||||
)
|
||||
.foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4))
|
||||
.symbolSize(20)
|
||||
}
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("Z'", Double(pt.zReal)),
|
||||
y: .value("-Z''", Double(-pt.zImag))
|
||||
)
|
||||
.foregroundStyle(Color(red: 0.4, green: 1, blue: 0.4).opacity(0.6))
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("Z' (Ohm)")
|
||||
.chartYAxisLabel("-Z'' (Ohm)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bodeMagChart: some View {
|
||||
Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||
y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01)))
|
||||
)
|
||||
.foregroundStyle(Color.cyan)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(
|
||||
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||
y: .value("|Z|", log10(max(Double(pt.magOhms), 0.01)))
|
||||
)
|
||||
.foregroundStyle(Color.cyan)
|
||||
.symbolSize(16)
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("log10(Freq Hz)")
|
||||
.chartYAxisLabel("log10(|Z| Ohm)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bodePhaseChart: some View {
|
||||
Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||
y: .value("Phase", Double(pt.phaseDeg))
|
||||
)
|
||||
.foregroundStyle(Color.orange)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(
|
||||
x: .value("Freq", log10(max(Double(pt.freqHz), 1))),
|
||||
y: .value("Phase", Double(pt.phaseDeg))
|
||||
)
|
||||
.foregroundStyle(Color.orange)
|
||||
.symbolSize(16)
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("log10(Freq Hz)")
|
||||
.chartYAxisLabel("Phase (deg)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var eisTable: some View {
|
||||
MeasurementTable(
|
||||
columns: [
|
||||
MeasurementColumn(header: "Freq (Hz)", width: 80, alignment: .trailing),
|
||||
MeasurementColumn(header: "|Z| (Ohm)", width: 90, alignment: .trailing),
|
||||
MeasurementColumn(header: "Phase", width: 70, alignment: .trailing),
|
||||
MeasurementColumn(header: "Re", width: 80, alignment: .trailing),
|
||||
MeasurementColumn(header: "Im", width: 80, alignment: .trailing),
|
||||
],
|
||||
rows: points,
|
||||
cellText: { pt, col in
|
||||
switch col {
|
||||
case 0: String(format: "%.1f", pt.freqHz)
|
||||
case 1: String(format: "%.2f", pt.magOhms)
|
||||
case 2: String(format: "%.2f", pt.phaseDeg)
|
||||
case 3: String(format: "%.2f", pt.zReal)
|
||||
case 4: String(format: "%.2f", pt.zImag)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var noData: some View {
|
||||
Text("No data points")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LSV data view
|
||||
|
||||
struct LsvDataView: View {
|
||||
let points: [LsvPoint]
|
||||
@State private var showTable = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if points.isEmpty {
|
||||
noData
|
||||
} else {
|
||||
ivChart
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if showTable && !points.isEmpty {
|
||||
Divider()
|
||||
lsvTable
|
||||
.frame(maxHeight: 250)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(showTable ? "Hide Table" : "Show Table") {
|
||||
showTable.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ivChart: some View {
|
||||
Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("V", Double(pt.vMv)),
|
||||
y: .value("I", Double(pt.iUa))
|
||||
)
|
||||
.foregroundStyle(Color.yellow)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(
|
||||
x: .value("V", Double(pt.vMv)),
|
||||
y: .value("I", Double(pt.iUa))
|
||||
)
|
||||
.foregroundStyle(Color.yellow)
|
||||
.symbolSize(16)
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("Voltage (mV)")
|
||||
.chartYAxisLabel("Current (uA)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var lsvTable: some View {
|
||||
MeasurementTable(
|
||||
columns: [
|
||||
MeasurementColumn(header: "V (mV)", width: 80, alignment: .trailing),
|
||||
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||
],
|
||||
rows: points,
|
||||
cellText: { pt, col in
|
||||
switch col {
|
||||
case 0: String(format: "%.1f", pt.vMv)
|
||||
case 1: String(format: "%.3f", pt.iUa)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var noData: some View {
|
||||
Text("No data points")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Amperometry data view
|
||||
|
||||
struct AmpDataView: View {
|
||||
let points: [AmpPoint]
|
||||
@State private var showTable = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if points.isEmpty {
|
||||
noData
|
||||
} else {
|
||||
ampChart
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if showTable && !points.isEmpty {
|
||||
Divider()
|
||||
ampTable
|
||||
.frame(maxHeight: 250)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(showTable ? "Hide Table" : "Show Table") {
|
||||
showTable.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ampChart: some View {
|
||||
let ampColor = Color(red: 0.6, green: 0.6, blue: 1.0)
|
||||
return Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("t", Double(pt.tMs)),
|
||||
y: .value("I", Double(pt.iUa))
|
||||
)
|
||||
.foregroundStyle(ampColor)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
PointMark(
|
||||
x: .value("t", Double(pt.tMs)),
|
||||
y: .value("I", Double(pt.iUa))
|
||||
)
|
||||
.foregroundStyle(ampColor)
|
||||
.symbolSize(16)
|
||||
}
|
||||
}
|
||||
.chartXAxisLabel("Time (ms)")
|
||||
.chartYAxisLabel("Current (uA)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ampTable: some View {
|
||||
MeasurementTable(
|
||||
columns: [
|
||||
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
|
||||
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||
],
|
||||
rows: points,
|
||||
cellText: { pt, col in
|
||||
switch col {
|
||||
case 0: String(format: "%.1f", pt.tMs)
|
||||
case 1: String(format: "%.3f", pt.iUa)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var noData: some View {
|
||||
Text("No data points")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chlorine data view
|
||||
|
||||
struct ClDataView: View {
|
||||
let points: [ClPoint]
|
||||
let result: ClResult?
|
||||
@State private var showTable = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let r = result {
|
||||
resultBanner(r)
|
||||
}
|
||||
|
||||
if points.isEmpty {
|
||||
noData
|
||||
} else {
|
||||
clChart
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
if showTable && !points.isEmpty {
|
||||
Divider()
|
||||
clTable
|
||||
.frame(maxHeight: 250)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(showTable ? "Hide Table" : "Show Table") {
|
||||
showTable.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resultBanner(_ r: ClResult) -> some View {
|
||||
HStack(spacing: 16) {
|
||||
Text(String(format: "Free: %.3f uA", r.iFreeUa))
|
||||
.foregroundStyle(Color(red: 0.2, green: 1, blue: 0.5))
|
||||
Text(String(format: "Total: %.3f uA", r.iTotalUa))
|
||||
.foregroundStyle(Color(red: 1, green: 0.6, blue: 0.2))
|
||||
Text(String(format: "Combined: %.3f uA", r.iTotalUa - r.iFreeUa))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
private var clChart: some View {
|
||||
Chart {
|
||||
ForEach(Array(points.enumerated()), id: \.offset) { _, pt in
|
||||
LineMark(
|
||||
x: .value("t", Double(pt.tMs)),
|
||||
y: .value("I", Double(pt.iUa))
|
||||
)
|
||||
.foregroundStyle(by: .value("Phase", phaseLabel(pt.phase)))
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
}
|
||||
.chartForegroundStyleScale([
|
||||
"Conditioning": Color.gray,
|
||||
"Free": Color(red: 0.2, green: 1, blue: 0.5),
|
||||
"Total": Color(red: 1, green: 0.6, blue: 0.2),
|
||||
])
|
||||
.chartXAxisLabel("Time (ms)")
|
||||
.chartYAxisLabel("Current (uA)")
|
||||
.chartXAxis {
|
||||
AxisMarks { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { _ in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
AxisValueLabel()
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.chartLegend(position: .top)
|
||||
}
|
||||
|
||||
private func phaseLabel(_ phase: UInt8) -> String {
|
||||
switch phase {
|
||||
case 1: "Free"
|
||||
case 2: "Total"
|
||||
default: "Conditioning"
|
||||
}
|
||||
}
|
||||
|
||||
private var clTable: some View {
|
||||
MeasurementTable(
|
||||
columns: [
|
||||
MeasurementColumn(header: "t (ms)", width: 80, alignment: .trailing),
|
||||
MeasurementColumn(header: "I (uA)", width: 90, alignment: .trailing),
|
||||
MeasurementColumn(header: "Phase", width: 70, alignment: .trailing),
|
||||
],
|
||||
rows: points,
|
||||
cellText: { pt, col in
|
||||
switch col {
|
||||
case 0: String(format: "%.1f", pt.tMs)
|
||||
case 1: String(format: "%.3f", pt.iUa)
|
||||
case 2: phaseLabel(pt.phase)
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var noData: some View {
|
||||
Text("No data points")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - pH data view
|
||||
|
||||
struct PhDataView: View {
|
||||
let result: PhResult?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let r = result {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(String(format: "pH: %.2f", r.ph))
|
||||
.font(.system(size: 56, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(String(format: "OCP: %.1f mV", r.vOcpMv))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(String(format: "Temperature: %.1f C", r.tempC))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
} else {
|
||||
Text("No pH result")
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import SwiftUI
|
||||
import GRDB
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SessionView: View {
|
||||
@Bindable var state: AppState
|
||||
|
|
@ -27,7 +28,9 @@ struct SessionView: View {
|
|||
|
||||
private func startObserving() {
|
||||
sessionCancellable = Storage.shared.observeSessions { sessions in
|
||||
self.sessions = sessions
|
||||
Task { @MainActor in
|
||||
self.sessions = sessions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,16 +194,31 @@ struct SessionDetailView: View {
|
|||
@State private var editing = false
|
||||
@State private var editLabel = ""
|
||||
@State private var editNotes = ""
|
||||
@State private var showingFileImporter = false
|
||||
@State private var showingShareSheet = false
|
||||
@State private var exportFileURL: URL?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
measurementsList
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
measurementsList
|
||||
}
|
||||
}
|
||||
.onAppear { loadMeasurements() }
|
||||
.onChange(of: session.id) { loadMeasurements() }
|
||||
.sheet(isPresented: $editing) { editSheet }
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = exportFileURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingFileImporter,
|
||||
allowedContentTypes: [.plainText],
|
||||
onCompletion: handleImportedFile
|
||||
)
|
||||
}
|
||||
|
||||
private func loadMeasurements() {
|
||||
|
|
@ -208,6 +226,40 @@ struct SessionDetailView: View {
|
|||
measurements = (try? Storage.shared.fetchMeasurements(sessionId: sid)) ?? []
|
||||
}
|
||||
|
||||
private func exportSession() {
|
||||
guard let sid = session.id else { return }
|
||||
do {
|
||||
let toml = try Storage.shared.exportSession(sid)
|
||||
let name = (session.label ?? "session").replacingOccurrences(of: " ", with: "_")
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).toml")
|
||||
try toml.write(to: url, atomically: true, encoding: .utf8)
|
||||
exportFileURL = url
|
||||
showingShareSheet = true
|
||||
} catch {
|
||||
state.status = "Export failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImportedFile(_ result: Result<URL, Error>) {
|
||||
switch result {
|
||||
case .success(let url):
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
state.status = "Cannot access file"
|
||||
return
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
do {
|
||||
let toml = try String(contentsOf: url, encoding: .utf8)
|
||||
let _ = try Storage.shared.importSession(from: toml)
|
||||
state.status = "Session imported"
|
||||
} catch {
|
||||
state.status = "Import failed: \(error.localizedDescription)"
|
||||
}
|
||||
case .failure(let error):
|
||||
state.status = "File error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
|
|
@ -222,6 +274,14 @@ struct SessionDetailView: View {
|
|||
Image(systemName: "pencil.circle")
|
||||
.imageScale(.large)
|
||||
}
|
||||
Button(action: { exportSession() }) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.imageScale(.large)
|
||||
}
|
||||
Button(action: { showingFileImporter = true }) {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.imageScale(.large)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text(session.startedAt, style: .date)
|
||||
|
|
@ -252,7 +312,11 @@ struct SessionDetailView: View {
|
|||
} else {
|
||||
List {
|
||||
ForEach(measurements, id: \.id) { meas in
|
||||
MeasurementRow(measurement: meas, state: state)
|
||||
NavigationLink {
|
||||
MeasurementDataView(measurement: meas)
|
||||
} label: {
|
||||
MeasurementRow(measurement: meas, state: state)
|
||||
}
|
||||
}
|
||||
.onDelete { indices in
|
||||
for idx in indices {
|
||||
|
|
@ -368,3 +432,11 @@ struct MeasurementRow: View {
|
|||
return (try? Storage.shared.dataPointCount(measurementId: mid)) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,28 @@ dependencies = [
|
|||
"libloading 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
|
||||
dependencies = [
|
||||
"async-fs",
|
||||
"async-net",
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.2",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus 5.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
|
|
@ -210,6 +232,17 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-net"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
|
|
@ -340,6 +373,15 @@ dependencies = [
|
|||
"objc2 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
|
|
@ -722,6 +764,7 @@ dependencies = [
|
|||
"futures",
|
||||
"iced",
|
||||
"muda",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -760,7 +803,7 @@ dependencies = [
|
|||
"rust-ini",
|
||||
"web-sys",
|
||||
"winreg",
|
||||
"zbus",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -839,9 +882,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
|
|
@ -1125,6 +1181,15 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1656,12 +1721,115 @@ dependencies = [
|
|||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
|
|
@ -1848,6 +2016,12 @@ version = "0.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
|
|
@ -2155,7 +2329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-data",
|
||||
|
|
@ -2171,6 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
|
|
@ -2183,7 +2358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2195,7 +2370,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2207,7 +2382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2242,7 +2417,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
|
|
@ -2254,7 +2429,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-contacts",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2273,7 +2448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"dispatch",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
|
|
@ -2307,7 +2482,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2320,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2332,7 +2507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-metal",
|
||||
|
|
@ -2367,7 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
|
|
@ -2387,7 +2562,7 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
|
@ -2399,7 +2574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
|
@ -2566,7 +2741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2673,6 +2848,21 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
|
|
@ -2759,8 +2949,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2770,7 +2970,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2782,6 +2992,15 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
|
|
@ -2883,6 +3102,30 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
|
|
@ -3276,6 +3519,12 @@ dependencies = [
|
|||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
|
|
@ -3327,6 +3576,17 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
|
|
@ -3437,6 +3697,16 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
|
|
@ -3688,6 +3958,42 @@ version = "0.2.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
|
@ -4421,7 +4727,7 @@ dependencies = [
|
|||
"android-activity",
|
||||
"atomic-waker",
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases 0.2.1",
|
||||
|
|
@ -4578,6 +4884,12 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
|
|
@ -4657,6 +4969,29 @@ version = "0.1.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
|
|
@ -4681,7 +5016,7 @@ dependencies = [
|
|||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
|
|
@ -4690,9 +5025,44 @@ dependencies = [
|
|||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zbus_macros 4.4.0",
|
||||
"zbus_names 3.0.0",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zbus_macros 5.14.0",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4705,7 +5075,22 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names 4.3.1",
|
||||
"zvariant 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4716,7 +5101,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
|||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
"zvariant 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant 5.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4745,6 +5141,60 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
|
@ -4761,7 +5211,22 @@ dependencies = [
|
|||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
"zvariant_derive 4.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow",
|
||||
"zvariant_derive 5.10.0",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4774,7 +5239,20 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
"zvariant_utils 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4787,3 +5265,16 @@ dependencies = [
|
|||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
toml = { version = "0.8", features = ["preserve_order"] }
|
||||
dirs-next = "2"
|
||||
rfd = "0.15"
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
winres = "0.1"
|
||||
|
|
|
|||
Binary file not shown.
630
cue/src/app.rs
630
cue/src/app.rs
|
|
@ -1,7 +1,7 @@
|
|||
use futures::SinkExt;
|
||||
use iced::widget::{
|
||||
button, canvas, column, container, pane_grid, pick_list, row, scrollable, text, text_editor,
|
||||
text_input,
|
||||
button, canvas, column, container, pane_grid, pick_list, row, rule, scrollable, text,
|
||||
text_editor, text_input,
|
||||
};
|
||||
use iced::widget::button::Style as ButtonStyle;
|
||||
use iced::{Border, Color, Element, Length, Subscription, Task, Theme};
|
||||
|
|
@ -17,6 +17,32 @@ use crate::protocol::{
|
|||
use crate::storage::{self, Session, Storage};
|
||||
use crate::udp::UdpEvent;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClAutoState {
|
||||
Idle,
|
||||
LsvRunning,
|
||||
MeasureRunning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LsvDensityMode {
|
||||
PtsPerMv,
|
||||
PtsPerSec,
|
||||
}
|
||||
|
||||
impl LsvDensityMode {
|
||||
pub const ALL: &[Self] = &[Self::PtsPerMv, Self::PtsPerSec];
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LsvDensityMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::PtsPerMv => f.write_str("pts/mV"),
|
||||
Self::PtsPerSec => f.write_str("pts/s"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tab {
|
||||
Eis,
|
||||
|
|
@ -71,6 +97,8 @@ pub enum Message {
|
|||
LsvStopVChanged(String),
|
||||
LsvScanRateChanged(String),
|
||||
LsvRtiaSelected(LpRtia),
|
||||
LsvDensityModeSelected(LsvDensityMode),
|
||||
LsvDensityChanged(String),
|
||||
StartLsv,
|
||||
/* Amperometry */
|
||||
AmpVholdChanged(String),
|
||||
|
|
@ -88,6 +116,8 @@ pub enum Message {
|
|||
ClMeasTChanged(String),
|
||||
ClRtiaSelected(LpRtia),
|
||||
StartCl,
|
||||
StartClAuto,
|
||||
ClToggleManual,
|
||||
/* pH */
|
||||
PhStabilizeChanged(String),
|
||||
StartPh,
|
||||
|
|
@ -98,6 +128,13 @@ pub enum Message {
|
|||
CalBleachChanged(String),
|
||||
CalTempChanged(String),
|
||||
CalComputeK,
|
||||
ClCalKnownPpmChanged(String),
|
||||
ClSetFactor,
|
||||
/* pH calibration */
|
||||
PhCalKnownChanged(String),
|
||||
PhAddCalPoint,
|
||||
PhClearCalPoints,
|
||||
PhComputeAndSetCal,
|
||||
/* Global */
|
||||
PollTemp,
|
||||
NativeMenuTick,
|
||||
|
|
@ -124,11 +161,24 @@ pub enum Message {
|
|||
BrowseLoadAsReference(i64),
|
||||
BrowseDeleteMeasurement(i64),
|
||||
BrowseBack,
|
||||
ExportSession(i64),
|
||||
ImportSession,
|
||||
/* Misc */
|
||||
Reconnect,
|
||||
UdpAddrChanged(String),
|
||||
}
|
||||
|
||||
fn lsv_calc_points(v_start: f32, v_stop: f32, scan_rate: f32, density: f32, mode: LsvDensityMode) -> u16 {
|
||||
let range = (v_stop - v_start).abs();
|
||||
let raw = match mode {
|
||||
LsvDensityMode::PtsPerMv => range * density,
|
||||
LsvDensityMode::PtsPerSec => {
|
||||
if scan_rate.abs() < 0.001 { 2.0 } else { (range / scan_rate.abs()) * density }
|
||||
}
|
||||
};
|
||||
(raw as u16).clamp(2, 500)
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
tab: Tab,
|
||||
status: String,
|
||||
|
|
@ -170,6 +220,9 @@ pub struct App {
|
|||
lsv_stop_v: String,
|
||||
lsv_scan_rate: String,
|
||||
lsv_rtia: LpRtia,
|
||||
lsv_density_mode: LsvDensityMode,
|
||||
lsv_density: String,
|
||||
lsv_peaks: Vec<crate::lsv_analysis::LsvPeak>,
|
||||
lsv_data: text_editor::Content,
|
||||
|
||||
/* Amp */
|
||||
|
|
@ -194,11 +247,17 @@ pub struct App {
|
|||
cl_meas_t: String,
|
||||
cl_rtia: LpRtia,
|
||||
cl_data: text_editor::Content,
|
||||
cl_manual_peaks: bool,
|
||||
cl_auto_state: ClAutoState,
|
||||
cl_auto_potentials: Option<crate::lsv_analysis::ClPotentials>,
|
||||
|
||||
/* pH */
|
||||
ph_result: Option<PhResult>,
|
||||
ph_stabilize: String,
|
||||
|
||||
/* measurement dedup */
|
||||
current_esp_ts: Option<u32>,
|
||||
|
||||
/* Reference baselines */
|
||||
eis_ref: Option<Vec<EisPoint>>,
|
||||
lsv_ref: Option<Vec<LsvPoint>>,
|
||||
|
|
@ -227,6 +286,12 @@ pub struct App {
|
|||
cal_bleach_pct: String,
|
||||
cal_temp_c: String,
|
||||
cal_cell_constant: Option<f32>,
|
||||
cl_factor: Option<f32>,
|
||||
cl_cal_known_ppm: String,
|
||||
ph_slope: Option<f32>,
|
||||
ph_offset: Option<f32>,
|
||||
ph_cal_points: Vec<(f32, f32)>,
|
||||
ph_cal_known: String,
|
||||
|
||||
/* Global */
|
||||
temp_c: f32,
|
||||
|
|
@ -407,6 +472,9 @@ impl App {
|
|||
lsv_stop_v: "500".into(),
|
||||
lsv_scan_rate: "50".into(),
|
||||
lsv_rtia: LpRtia::R10K,
|
||||
lsv_density_mode: LsvDensityMode::PtsPerMv,
|
||||
lsv_density: "1".into(),
|
||||
lsv_peaks: Vec::new(),
|
||||
lsv_data: text_editor::Content::with_text(&fmt_lsv(&[])),
|
||||
|
||||
amp_points: Vec::new(),
|
||||
|
|
@ -429,10 +497,15 @@ impl App {
|
|||
cl_meas_t: "5000".into(),
|
||||
cl_rtia: LpRtia::R10K,
|
||||
cl_data: text_editor::Content::with_text(&fmt_cl(&[])),
|
||||
cl_manual_peaks: false,
|
||||
cl_auto_state: ClAutoState::Idle,
|
||||
cl_auto_potentials: None,
|
||||
|
||||
ph_result: None,
|
||||
ph_stabilize: "30".into(),
|
||||
|
||||
current_esp_ts: None,
|
||||
|
||||
eis_ref: None,
|
||||
lsv_ref: None,
|
||||
amp_ref: None,
|
||||
|
|
@ -457,6 +530,12 @@ impl App {
|
|||
cal_bleach_pct: "7.825".into(),
|
||||
cal_temp_c: "40".into(),
|
||||
cal_cell_constant: None,
|
||||
cl_factor: None,
|
||||
cl_cal_known_ppm: String::from("5"),
|
||||
ph_slope: None,
|
||||
ph_offset: None,
|
||||
ph_cal_points: vec![],
|
||||
ph_cal_known: String::from("7.00"),
|
||||
|
||||
temp_c: 25.0,
|
||||
conn_gen: 0,
|
||||
|
|
@ -482,7 +561,7 @@ impl App {
|
|||
"rcal": format!("{}", self.rcal),
|
||||
"electrode": format!("{}", self.electrode),
|
||||
});
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "eis", ¶ms.to_string(), self.current_esp_ts) {
|
||||
let pts: Vec<(i32, String)> = self.eis_points.iter().enumerate()
|
||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||
.collect();
|
||||
|
|
@ -497,7 +576,7 @@ impl App {
|
|||
"scan_rate": self.lsv_scan_rate,
|
||||
"rtia": format!("{}", self.lsv_rtia),
|
||||
});
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "lsv", ¶ms.to_string(), self.current_esp_ts) {
|
||||
let pts: Vec<(i32, String)> = self.lsv_points.iter().enumerate()
|
||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||
.collect();
|
||||
|
|
@ -512,7 +591,7 @@ impl App {
|
|||
"duration_s": self.amp_duration,
|
||||
"rtia": format!("{}", self.amp_rtia),
|
||||
});
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "amp", ¶ms.to_string(), self.current_esp_ts) {
|
||||
let pts: Vec<(i32, String)> = self.amp_points.iter().enumerate()
|
||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||
.collect();
|
||||
|
|
@ -530,7 +609,7 @@ impl App {
|
|||
"meas_t": self.cl_meas_t,
|
||||
"rtia": format!("{}", self.cl_rtia),
|
||||
});
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "chlorine", ¶ms.to_string(), self.current_esp_ts) {
|
||||
let mut pts: Vec<(i32, String)> = self.cl_points.iter().enumerate()
|
||||
.filter_map(|(i, p)| serde_json::to_string(p).ok().map(|j| (i as i32, j)))
|
||||
.collect();
|
||||
|
|
@ -547,7 +626,7 @@ impl App {
|
|||
let params = serde_json::json!({
|
||||
"stabilize_s": self.ph_stabilize,
|
||||
});
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string()) {
|
||||
if let Ok(mid) = self.storage.create_measurement(session_id, "ph", ¶ms.to_string(), self.current_esp_ts) {
|
||||
if let Ok(j) = serde_json::to_string(result) {
|
||||
let _ = self.storage.add_data_point(mid, 0, &j);
|
||||
}
|
||||
|
|
@ -561,6 +640,8 @@ impl App {
|
|||
self.connected = true;
|
||||
self.send_cmd(&protocol::build_sysex_get_config());
|
||||
self.send_cmd(&protocol::build_sysex_get_cell_k());
|
||||
self.send_cmd(&protocol::build_sysex_get_cl_factor());
|
||||
self.send_cmd(&protocol::build_sysex_get_ph_cal());
|
||||
}
|
||||
Message::DeviceStatus(s) => {
|
||||
if s.contains("Reconnecting") || s.contains("Connecting") {
|
||||
|
|
@ -570,9 +651,9 @@ impl App {
|
|||
self.status = s;
|
||||
}
|
||||
Message::DeviceData(msg) => match msg {
|
||||
EisMessage::SweepStart { num_points, freq_start, freq_stop } => {
|
||||
EisMessage::SweepStart { num_points, freq_start, freq_stop, esp_timestamp, .. } => {
|
||||
self.current_esp_ts = esp_timestamp;
|
||||
if self.collecting_refs {
|
||||
/* ref collection: clear temp buffer */
|
||||
self.eis_points.clear();
|
||||
self.sweep_total = num_points;
|
||||
} else {
|
||||
|
|
@ -615,7 +696,8 @@ impl App {
|
|||
self.electrode = cfg.electrode;
|
||||
self.status = "Config received".into();
|
||||
}
|
||||
EisMessage::LsvStart { num_points, v_start, v_stop } => {
|
||||
EisMessage::LsvStart { num_points, v_start, v_stop, esp_timestamp, .. } => {
|
||||
self.current_esp_ts = esp_timestamp;
|
||||
self.lsv_points.clear();
|
||||
self.lsv_total = num_points;
|
||||
self.lsv_data = text_editor::Content::with_text(&fmt_lsv(&self.lsv_points));
|
||||
|
|
@ -630,9 +712,41 @@ impl App {
|
|||
if let Some(sid) = self.current_session {
|
||||
self.save_lsv(sid);
|
||||
}
|
||||
self.status = format!("LSV complete: {} points", self.lsv_points.len());
|
||||
self.lsv_peaks = crate::lsv_analysis::detect_peaks(&self.lsv_points);
|
||||
let mut st = format!("LSV complete: {} points", self.lsv_points.len());
|
||||
if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) {
|
||||
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
|
||||
if s.abs() > 1e-6 {
|
||||
let ph = (peak - o) / s;
|
||||
write!(st, " | pH={:.2}", ph).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.status = st;
|
||||
|
||||
if self.cl_auto_state == ClAutoState::LsvRunning {
|
||||
let pots = crate::lsv_analysis::derive_cl_potentials(&self.lsv_points);
|
||||
self.cl_free_v = format!("{:.0}", pots.v_free);
|
||||
self.cl_total_v = format!("{:.0}", pots.v_total);
|
||||
self.cl_auto_potentials = Some(pots);
|
||||
self.cl_auto_state = ClAutoState::MeasureRunning;
|
||||
|
||||
let v_cond = self.cl_cond_v.parse::<f32>().unwrap_or(800.0);
|
||||
let t_cond = self.cl_cond_t.parse::<f32>().unwrap_or(2000.0);
|
||||
let t_dep = self.cl_dep_t.parse::<f32>().unwrap_or(5000.0);
|
||||
let t_meas = self.cl_meas_t.parse::<f32>().unwrap_or(5000.0);
|
||||
self.send_cmd(&protocol::build_sysex_get_temp());
|
||||
self.send_cmd(&protocol::build_sysex_start_cl(
|
||||
v_cond, t_cond, pots.v_free, pots.v_total, t_dep, t_meas, self.cl_rtia,
|
||||
));
|
||||
self.status = format!(
|
||||
"Auto Cl: measuring (free={:.0}, total={:.0})",
|
||||
pots.v_free, pots.v_total
|
||||
);
|
||||
}
|
||||
}
|
||||
EisMessage::AmpStart { v_hold } => {
|
||||
EisMessage::AmpStart { v_hold, esp_timestamp, .. } => {
|
||||
self.current_esp_ts = esp_timestamp;
|
||||
self.amp_points.clear();
|
||||
self.amp_running = true;
|
||||
self.amp_data = text_editor::Content::with_text(&fmt_amp(&self.amp_points));
|
||||
|
|
@ -651,7 +765,8 @@ impl App {
|
|||
}
|
||||
self.status = format!("Amp complete: {} points", self.amp_points.len());
|
||||
}
|
||||
EisMessage::ClStart { num_points } => {
|
||||
EisMessage::ClStart { num_points, esp_timestamp, .. } => {
|
||||
self.current_esp_ts = esp_timestamp;
|
||||
self.cl_points.clear();
|
||||
self.cl_result = None;
|
||||
self.cl_total = num_points;
|
||||
|
|
@ -673,13 +788,30 @@ impl App {
|
|||
if let Some(sid) = self.current_session {
|
||||
self.save_cl(sid);
|
||||
}
|
||||
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||
if self.cl_auto_state == ClAutoState::MeasureRunning {
|
||||
self.cl_auto_state = ClAutoState::Idle;
|
||||
if let Some(pots) = &self.cl_auto_potentials {
|
||||
self.status = format!(
|
||||
"Auto Cl complete: {} pts (free={:.0}{}, total={:.0}{})",
|
||||
self.cl_points.len(),
|
||||
pots.v_free,
|
||||
if pots.v_free_detected { "" } else { " dflt" },
|
||||
pots.v_total,
|
||||
if pots.v_total_detected { "" } else { " dflt" },
|
||||
);
|
||||
} else {
|
||||
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||
}
|
||||
} else {
|
||||
self.status = format!("Chlorine complete: {} points", self.cl_points.len());
|
||||
}
|
||||
}
|
||||
EisMessage::PhResult(r) => {
|
||||
EisMessage::PhResult(r, esp_ts, _) => {
|
||||
if self.collecting_refs {
|
||||
self.ph_ref = Some(r);
|
||||
} else {
|
||||
if let Some(sid) = self.current_session {
|
||||
self.current_esp_ts = esp_ts;
|
||||
self.save_ph(sid, &r);
|
||||
}
|
||||
self.status = format!("pH: {:.2} (OCP={:.1} mV, T={:.1}C)",
|
||||
|
|
@ -734,6 +866,16 @@ impl App {
|
|||
self.cal_cell_constant = Some(k);
|
||||
self.status = format!("Device cell constant: {:.4} cm-1", k);
|
||||
}
|
||||
EisMessage::ClFactor(f) => {
|
||||
self.cl_factor = Some(f);
|
||||
self.status = format!("Device Cl factor: {:.6}", f);
|
||||
}
|
||||
EisMessage::PhCal { slope, offset } => {
|
||||
self.ph_slope = Some(slope);
|
||||
self.ph_offset = Some(offset);
|
||||
self.status = format!("pH cal: slope={:.4} offset={:.4}", slope, offset);
|
||||
}
|
||||
EisMessage::Keepalive => {}
|
||||
},
|
||||
Message::TabSelected(t) => {
|
||||
if t == Tab::Browse {
|
||||
|
|
@ -800,12 +942,22 @@ impl App {
|
|||
Message::LsvStopVChanged(s) => self.lsv_stop_v = s,
|
||||
Message::LsvScanRateChanged(s) => self.lsv_scan_rate = s,
|
||||
Message::LsvRtiaSelected(r) => self.lsv_rtia = r,
|
||||
Message::LsvDensityModeSelected(m) => {
|
||||
self.lsv_density_mode = m;
|
||||
self.lsv_density = match m {
|
||||
LsvDensityMode::PtsPerMv => "1".into(),
|
||||
LsvDensityMode::PtsPerSec => "100".into(),
|
||||
};
|
||||
}
|
||||
Message::LsvDensityChanged(s) => self.lsv_density = s,
|
||||
Message::StartLsv => {
|
||||
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
|
||||
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
|
||||
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
|
||||
let density = self.lsv_density.parse::<f32>().unwrap_or(1.0);
|
||||
let n = lsv_calc_points(vs, ve, sr, density, self.lsv_density_mode);
|
||||
self.send_cmd(&protocol::build_sysex_get_temp());
|
||||
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia));
|
||||
self.send_cmd(&protocol::build_sysex_start_lsv(vs, ve, sr, self.lsv_rtia, n));
|
||||
}
|
||||
/* Amp */
|
||||
Message::AmpVholdChanged(s) => self.amp_v_hold = s,
|
||||
|
|
@ -842,6 +994,17 @@ impl App {
|
|||
v_cond, t_cond, v_free, v_total, t_dep, t_meas, self.cl_rtia,
|
||||
));
|
||||
}
|
||||
Message::StartClAuto => {
|
||||
self.cl_auto_state = ClAutoState::LsvRunning;
|
||||
self.cl_auto_potentials = None;
|
||||
let density = self.lsv_density.parse::<f32>().unwrap_or(1.0);
|
||||
let n = lsv_calc_points(-1100.0, 1100.0, 50.0, density, self.lsv_density_mode);
|
||||
self.send_cmd(&protocol::build_sysex_start_lsv(-1100.0, 1100.0, 50.0, self.lsv_rtia, n));
|
||||
self.status = "Auto Cl: running LSV sweep...".into();
|
||||
}
|
||||
Message::ClToggleManual => {
|
||||
self.cl_manual_peaks = !self.cl_manual_peaks;
|
||||
}
|
||||
/* pH */
|
||||
Message::PhStabilizeChanged(s) => self.ph_stabilize = s,
|
||||
Message::StartPh => {
|
||||
|
|
@ -951,6 +1114,58 @@ impl App {
|
|||
self.status = "No valid EIS data for Rs extraction".into();
|
||||
}
|
||||
}
|
||||
Message::PhCalKnownChanged(s) => { self.ph_cal_known = s; }
|
||||
Message::PhAddCalPoint => {
|
||||
if let Some(peak_mv) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
|
||||
if let Ok(ph) = self.ph_cal_known.parse::<f32>() {
|
||||
self.ph_cal_points.push((ph, peak_mv));
|
||||
self.status = format!("pH cal point: pH={:.2} peak={:.1} mV ({} pts)",
|
||||
ph, peak_mv, self.ph_cal_points.len());
|
||||
}
|
||||
} else {
|
||||
self.status = "No Q/HQ peak found in LSV data".into();
|
||||
}
|
||||
}
|
||||
Message::PhClearCalPoints => {
|
||||
self.ph_cal_points.clear();
|
||||
self.status = "pH cal points cleared".into();
|
||||
}
|
||||
Message::PhComputeAndSetCal => {
|
||||
if self.ph_cal_points.len() < 2 {
|
||||
self.status = "Need at least 2 calibration points".into();
|
||||
} else {
|
||||
let n = self.ph_cal_points.len() as f32;
|
||||
let mean_ph: f32 = self.ph_cal_points.iter().map(|p| p.0).sum::<f32>() / n;
|
||||
let mean_v: f32 = self.ph_cal_points.iter().map(|p| p.1).sum::<f32>() / n;
|
||||
let num: f32 = self.ph_cal_points.iter()
|
||||
.map(|p| (p.0 - mean_ph) * (p.1 - mean_v)).sum();
|
||||
let den: f32 = self.ph_cal_points.iter()
|
||||
.map(|p| (p.0 - mean_ph).powi(2)).sum();
|
||||
if den.abs() < 1e-12 {
|
||||
self.status = "Degenerate calibration data".into();
|
||||
} else {
|
||||
let slope = num / den;
|
||||
let offset = mean_v - slope * mean_ph;
|
||||
self.ph_slope = Some(slope);
|
||||
self.ph_offset = Some(offset);
|
||||
self.send_cmd(&protocol::build_sysex_set_ph_cal(slope, offset));
|
||||
self.status = format!("pH cal set: slope={:.4} offset={:.4}", slope, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ClCalKnownPpmChanged(s) => { self.cl_cal_known_ppm = s; }
|
||||
Message::ClSetFactor => {
|
||||
let known_ppm = self.cl_cal_known_ppm.parse::<f32>().unwrap_or(0.0);
|
||||
if let Some(r) = &self.cl_result {
|
||||
let peak = r.i_free_ua.abs();
|
||||
if peak > 0.0 {
|
||||
let factor = known_ppm / peak;
|
||||
self.cl_factor = Some(factor);
|
||||
self.send_cmd(&protocol::build_sysex_set_cl_factor(factor));
|
||||
self.status = format!("Cl factor: {:.6} ppm/uA", factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Clean */
|
||||
Message::CleanVChanged(s) => self.clean_v = s,
|
||||
Message::CleanDurChanged(s) => self.clean_dur = s,
|
||||
|
|
@ -1068,6 +1283,53 @@ impl App {
|
|||
self.browse_measurements.clear();
|
||||
}
|
||||
}
|
||||
Message::ExportSession(sid) => {
|
||||
match self.storage.export_session(sid) {
|
||||
Ok(toml_str) => {
|
||||
let name = self.browse_sessions.iter()
|
||||
.find(|(s, _)| s.id == sid)
|
||||
.map(|(s, _)| s.name.clone())
|
||||
.unwrap_or_else(|| format!("session_{}", sid));
|
||||
let filename = format!("{}.toml", name.replace(' ', "_"));
|
||||
let dialog = rfd::FileDialog::new()
|
||||
.set_file_name(&filename)
|
||||
.add_filter("TOML", &["toml"]);
|
||||
if let Some(path) = dialog.save_file() {
|
||||
match std::fs::write(&path, &toml_str) {
|
||||
Ok(_) => self.status = format!("Exported to {}", path.display()),
|
||||
Err(e) => self.status = format!("Write failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = format!("Export failed: {}", e),
|
||||
}
|
||||
}
|
||||
Message::ImportSession => {
|
||||
let dialog = rfd::FileDialog::new()
|
||||
.add_filter("TOML", &["toml"]);
|
||||
if let Some(path) = dialog.pick_file() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(toml_str) => {
|
||||
match self.storage.import_session(&toml_str) {
|
||||
Ok(_) => {
|
||||
self.browse_sessions = self.storage.list_sessions()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let cnt = self.storage.measurement_count(s.id).unwrap_or(0);
|
||||
(s, cnt)
|
||||
})
|
||||
.collect();
|
||||
self.sessions = self.storage.list_sessions().unwrap_or_default();
|
||||
self.status = format!("Imported from {}", path.display());
|
||||
}
|
||||
Err(e) => self.status = format!("Import failed: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = format!("Read failed: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Reconnect => {
|
||||
self.conn_gen += 1;
|
||||
self.cmd_tx = None;
|
||||
|
|
@ -1383,28 +1645,47 @@ impl App {
|
|||
.align_y(iced::Alignment::End)
|
||||
.into(),
|
||||
|
||||
Tab::Lsv => row![
|
||||
column![
|
||||
text("Start mV").size(12),
|
||||
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Stop mV").size(12),
|
||||
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Scan mV/s").size(12),
|
||||
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("RTIA").size(12),
|
||||
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
button(text("Start LSV").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::StartLsv),
|
||||
]
|
||||
Tab::Lsv => {
|
||||
let vs = self.lsv_start_v.parse::<f32>().unwrap_or(0.0);
|
||||
let ve = self.lsv_stop_v.parse::<f32>().unwrap_or(500.0);
|
||||
let sr = self.lsv_scan_rate.parse::<f32>().unwrap_or(50.0);
|
||||
let d = self.lsv_density.parse::<f32>().unwrap_or(1.0);
|
||||
let n = lsv_calc_points(vs, ve, sr, d, self.lsv_density_mode);
|
||||
row![
|
||||
column![
|
||||
text("Start mV").size(12),
|
||||
text_input("0", &self.lsv_start_v).on_input(Message::LsvStartVChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Stop mV").size(12),
|
||||
text_input("500", &self.lsv_stop_v).on_input(Message::LsvStopVChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Scan mV/s").size(12),
|
||||
text_input("50", &self.lsv_scan_rate).on_input(Message::LsvScanRateChanged).width(80),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("RTIA").size(12),
|
||||
pick_list(LpRtia::ALL, Some(self.lsv_rtia), Message::LsvRtiaSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Density").size(12),
|
||||
pick_list(LsvDensityMode::ALL, Some(self.lsv_density_mode), Message::LsvDensityModeSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Value").size(12),
|
||||
text_input("1", &self.lsv_density).on_input(Message::LsvDensityChanged).width(60),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Points").size(12),
|
||||
text(format!("{}", n)).size(13),
|
||||
].spacing(2),
|
||||
button(text("Start LSV").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::StartLsv),
|
||||
]
|
||||
}
|
||||
.spacing(10)
|
||||
.align_y(iced::Alignment::End)
|
||||
.into(),
|
||||
|
|
@ -1442,43 +1723,89 @@ impl App {
|
|||
.align_y(iced::Alignment::End)
|
||||
.into(),
|
||||
|
||||
Tab::Chlorine => row![
|
||||
column![
|
||||
text("Cond mV").size(12),
|
||||
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Cond ms").size(12),
|
||||
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Free mV").size(12),
|
||||
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Total mV").size(12),
|
||||
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Settle ms").size(12),
|
||||
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Meas ms").size(12),
|
||||
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("RTIA").size(12),
|
||||
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
button(text("Measure").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::StartCl),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::End)
|
||||
.into(),
|
||||
Tab::Chlorine => if self.cl_manual_peaks {
|
||||
row![
|
||||
button(text("Start LSV").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::StartLsv),
|
||||
button(text("Manual").size(13))
|
||||
.padding([6, 12])
|
||||
.on_press(Message::ClToggleManual),
|
||||
rule::Rule::vertical(1),
|
||||
column![
|
||||
text("Cond mV").size(12),
|
||||
text_input("800", &self.cl_cond_v).on_input(Message::ClCondVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Cond ms").size(12),
|
||||
text_input("2000", &self.cl_cond_t).on_input(Message::ClCondTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Free mV").size(12),
|
||||
text_input("100", &self.cl_free_v).on_input(Message::ClFreeVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Total mV").size(12),
|
||||
text_input("-200", &self.cl_total_v).on_input(Message::ClTotalVChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Settle ms").size(12),
|
||||
text_input("5000", &self.cl_dep_t).on_input(Message::ClDepTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("Meas ms").size(12),
|
||||
text_input("5000", &self.cl_meas_t).on_input(Message::ClMeasTChanged).width(70),
|
||||
].spacing(2),
|
||||
column![
|
||||
text("RTIA").size(12),
|
||||
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
button(text("Measure").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::StartCl),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::End)
|
||||
.into()
|
||||
} else {
|
||||
let auto_btn = {
|
||||
let b = button(text("Start Auto").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16]);
|
||||
if self.cl_auto_state == ClAutoState::Idle {
|
||||
b.on_press(Message::StartClAuto)
|
||||
} else {
|
||||
b
|
||||
}
|
||||
};
|
||||
let pot_text = if let Some(pots) = &self.cl_auto_potentials {
|
||||
format!(
|
||||
"free={:.0}{} total={:.0}{}",
|
||||
pots.v_free,
|
||||
if pots.v_free_detected { "" } else { "?" },
|
||||
pots.v_total,
|
||||
if pots.v_total_detected { "" } else { "?" },
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
row![
|
||||
auto_btn,
|
||||
button(text("Auto").size(13))
|
||||
.padding([6, 12])
|
||||
.on_press(Message::ClToggleManual),
|
||||
column![
|
||||
text("RTIA").size(12),
|
||||
pick_list(LpRtia::ALL, Some(self.cl_rtia), Message::ClRtiaSelected).width(Length::Shrink),
|
||||
].spacing(2),
|
||||
text(pot_text).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::End)
|
||||
.into()
|
||||
},
|
||||
|
||||
Tab::Ph => row![
|
||||
column![
|
||||
|
|
@ -1515,29 +1842,65 @@ impl App {
|
|||
.height(Length::Fill);
|
||||
row![bode, nyquist].spacing(10).height(Length::Fill).into()
|
||||
}
|
||||
Tab::Lsv => canvas(crate::plot::VoltammogramPlot {
|
||||
points: &self.lsv_points,
|
||||
reference: self.lsv_ref.as_deref(),
|
||||
})
|
||||
.width(Length::Fill).height(Length::Fill).into(),
|
||||
Tab::Lsv => {
|
||||
let plot = canvas(crate::plot::VoltammogramPlot {
|
||||
points: &self.lsv_points,
|
||||
reference: self.lsv_ref.as_deref(),
|
||||
peaks: &self.lsv_peaks,
|
||||
})
|
||||
.width(Length::Fill).height(Length::Fill);
|
||||
if self.lsv_peaks.is_empty() {
|
||||
plot.into()
|
||||
} else {
|
||||
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
|
||||
use crate::lsv_analysis::PeakKind;
|
||||
match p.kind {
|
||||
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
||||
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
||||
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
|
||||
}
|
||||
}).collect();
|
||||
column![text(info.join(" | ")).size(14), plot].spacing(4).height(Length::Fill).into()
|
||||
}
|
||||
}
|
||||
Tab::Amp => canvas(crate::plot::AmperogramPlot {
|
||||
points: &self.amp_points,
|
||||
reference: self.amp_ref.as_deref(),
|
||||
})
|
||||
.width(Length::Fill).height(Length::Fill).into(),
|
||||
Tab::Chlorine => {
|
||||
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
|
||||
let plot = canvas(crate::plot::ChlorinePlot {
|
||||
points: &self.cl_points,
|
||||
reference: ref_pts,
|
||||
let mut col = column![].spacing(4).height(Length::Fill);
|
||||
|
||||
if !self.lsv_peaks.is_empty() {
|
||||
let info: Vec<String> = self.lsv_peaks.iter().map(|p| {
|
||||
use crate::lsv_analysis::PeakKind;
|
||||
match p.kind {
|
||||
PeakKind::FreeCl => format!("Free: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
||||
PeakKind::TotalCl => format!("Total: {:.0}mV {:.2}uA", p.v_mv, p.i_ua),
|
||||
PeakKind::Crossover => format!("X-over: {:.0}mV", p.v_mv),
|
||||
}
|
||||
}).collect();
|
||||
col = col.push(text(info.join(" | ")).size(14));
|
||||
}
|
||||
|
||||
let voltammogram = canvas(crate::plot::VoltammogramPlot {
|
||||
points: &self.lsv_points,
|
||||
reference: self.lsv_ref.as_deref(),
|
||||
peaks: &self.lsv_peaks,
|
||||
})
|
||||
.width(Length::Fill).height(Length::Fill);
|
||||
.width(Length::Fill).height(Length::FillPortion(1));
|
||||
col = col.push(voltammogram);
|
||||
|
||||
let mut result_parts: Vec<String> = Vec::new();
|
||||
if let Some(r) = &self.cl_result {
|
||||
result_parts.push(format!(
|
||||
"Free: {:.3} uA | Total: {:.3} uA | Combined: {:.3} uA",
|
||||
r.i_free_ua, r.i_total_ua, r.i_total_ua - r.i_free_ua
|
||||
));
|
||||
if let (Some(f), Some(r)) = (self.cl_factor, &self.cl_result) {
|
||||
let ppm = f * r.i_free_ua.abs();
|
||||
result_parts.push(format!("Free Cl: {:.2} ppm", ppm));
|
||||
}
|
||||
if let Some((_, ref_r)) = &self.cl_ref {
|
||||
let df = r.i_free_ua - ref_r.i_free_ua;
|
||||
let dt = r.i_total_ua - ref_r.i_total_ua;
|
||||
|
|
@ -1547,12 +1910,19 @@ impl App {
|
|||
));
|
||||
}
|
||||
}
|
||||
if result_parts.is_empty() {
|
||||
plot.into()
|
||||
} else {
|
||||
let result_text = text(result_parts.join(" ")).size(14);
|
||||
column![result_text, plot].spacing(4).height(Length::Fill).into()
|
||||
if !result_parts.is_empty() {
|
||||
col = col.push(text(result_parts.join(" ")).size(14));
|
||||
}
|
||||
|
||||
let ref_pts = self.cl_ref.as_ref().map(|(pts, _)| pts.as_slice());
|
||||
let cl_plot = canvas(crate::plot::ChlorinePlot {
|
||||
points: &self.cl_points,
|
||||
reference: ref_pts,
|
||||
})
|
||||
.width(Length::Fill).height(Length::FillPortion(1));
|
||||
col = col.push(cl_plot);
|
||||
|
||||
col.into()
|
||||
}
|
||||
Tab::Ph | Tab::Calibrate | Tab::Browse => text("").into(),
|
||||
}
|
||||
|
|
@ -1645,6 +2015,69 @@ impl App {
|
|||
.on_press(Message::CalComputeK);
|
||||
results = results.push(compute_btn);
|
||||
|
||||
results = results.push(iced::widget::horizontal_rule(1));
|
||||
results = results.push(text("Chlorine Calibration").size(16));
|
||||
if let Some(f) = self.cl_factor {
|
||||
results = results.push(text(format!("Cl factor: {:.6} ppm/uA", f)).size(14));
|
||||
}
|
||||
if let Some(r) = &self.cl_result {
|
||||
results = results.push(text(format!("Last free Cl peak: {:.3} uA", r.i_free_ua)).size(14));
|
||||
}
|
||||
results = results.push(
|
||||
row![
|
||||
column![
|
||||
text("Known Cl ppm").size(12),
|
||||
text_input("5", &self.cl_cal_known_ppm)
|
||||
.on_input(Message::ClCalKnownPpmChanged).width(80),
|
||||
].spacing(2),
|
||||
button(text("Set Cl Factor").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::ClSetFactor),
|
||||
].spacing(10).align_y(iced::Alignment::End)
|
||||
);
|
||||
|
||||
/* pH calibration */
|
||||
results = results.push(iced::widget::horizontal_rule(1));
|
||||
results = results.push(text("pH Calibration (Q/HQ peak-shift)").size(16));
|
||||
if let (Some(s), Some(o)) = (self.ph_slope, self.ph_offset) {
|
||||
results = results.push(text(format!("slope: {:.4} mV/pH offset: {:.4} mV", s, o)).size(14));
|
||||
if let Some(peak) = crate::lsv_analysis::detect_qhq_peak(&self.lsv_points) {
|
||||
if s.abs() > 1e-6 {
|
||||
let ph = (peak - o) / s;
|
||||
results = results.push(text(format!("Computed pH: {:.2} (peak at {:.1} mV)", ph, peak)).size(14));
|
||||
}
|
||||
}
|
||||
}
|
||||
results = results.push(
|
||||
row![
|
||||
column![
|
||||
text("Known pH").size(12),
|
||||
text_input("7.00", &self.ph_cal_known)
|
||||
.on_input(Message::PhCalKnownChanged).width(80),
|
||||
].spacing(2),
|
||||
button(text("Add Point").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::PhAddCalPoint),
|
||||
].spacing(10).align_y(iced::Alignment::End)
|
||||
);
|
||||
for (i, (ph, mv)) in self.ph_cal_points.iter().enumerate() {
|
||||
results = results.push(text(format!(" {}. pH={:.2} peak={:.1} mV", i + 1, ph, mv)).size(13));
|
||||
}
|
||||
results = results.push(
|
||||
row![
|
||||
button(text("Clear Points").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::PhClearCalPoints),
|
||||
button(text("Compute & Set pH Cal").size(13))
|
||||
.style(style_action())
|
||||
.padding([6, 16])
|
||||
.on_press(Message::PhComputeAndSetCal),
|
||||
].spacing(10)
|
||||
);
|
||||
|
||||
row![
|
||||
container(inputs).width(Length::FillPortion(2)),
|
||||
iced::widget::vertical_rule(1),
|
||||
|
|
@ -1716,7 +2149,14 @@ impl App {
|
|||
|
||||
fn view_browse_sessions(&self) -> Element<'_, Message> {
|
||||
let mut items = column![
|
||||
text("Sessions").size(16),
|
||||
row![
|
||||
text("Sessions").size(16),
|
||||
iced::widget::horizontal_space(),
|
||||
button(text("Import").size(11))
|
||||
.style(style_apply())
|
||||
.padding([4, 8])
|
||||
.on_press(Message::ImportSession),
|
||||
].align_y(iced::Alignment::Center),
|
||||
iced::widget::horizontal_rule(1),
|
||||
].spacing(4);
|
||||
|
||||
|
|
@ -1792,6 +2232,14 @@ impl App {
|
|||
);
|
||||
}
|
||||
|
||||
header = header.push(iced::widget::horizontal_space());
|
||||
header = header.push(
|
||||
button(text("Export").size(11))
|
||||
.style(style_apply())
|
||||
.padding([4, 12])
|
||||
.on_press(Message::ExportSession(sid)),
|
||||
);
|
||||
|
||||
let mut mlist = column![].spacing(2);
|
||||
|
||||
if self.browse_measurements.is_empty() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
use crate::protocol::LsvPoint;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PeakKind {
|
||||
FreeCl,
|
||||
TotalCl,
|
||||
Crossover,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LsvPeak {
|
||||
pub v_mv: f32,
|
||||
pub i_ua: f32,
|
||||
pub kind: PeakKind,
|
||||
}
|
||||
|
||||
pub fn smooth(data: &[f32], window: usize) -> Vec<f32> {
|
||||
let n = data.len();
|
||||
if n == 0 || window < 2 {
|
||||
return data.to_vec();
|
||||
}
|
||||
let half = window / 2;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let lo = i.saturating_sub(half);
|
||||
let hi = (i + half).min(n - 1);
|
||||
let sum: f32 = data[lo..=hi].iter().sum();
|
||||
out.push(sum / (hi - lo + 1) as f32);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn find_extrema(_v: &[f32], i_smooth: &[f32], min_prominence: f32) -> Vec<(usize, bool)> {
|
||||
let n = i_smooth.len();
|
||||
if n < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut candidates: Vec<(usize, bool)> = Vec::new();
|
||||
for i in 1..n - 1 {
|
||||
let prev = i_smooth[i - 1];
|
||||
let curr = i_smooth[i];
|
||||
let next = i_smooth[i + 1];
|
||||
if curr > prev && curr > next {
|
||||
candidates.push((i, true));
|
||||
} else if curr < prev && curr < next {
|
||||
candidates.push((i, false));
|
||||
}
|
||||
}
|
||||
|
||||
candidates.retain(|&(idx, is_max)| {
|
||||
let val = i_smooth[idx];
|
||||
let left_bound = i_smooth[..idx].iter().copied()
|
||||
.reduce(if is_max { f32::min } else { f32::max })
|
||||
.unwrap_or(val);
|
||||
let right_bound = i_smooth[idx + 1..].iter().copied()
|
||||
.reduce(if is_max { f32::min } else { f32::max })
|
||||
.unwrap_or(val);
|
||||
let prom = if is_max {
|
||||
val - left_bound.max(right_bound)
|
||||
} else {
|
||||
left_bound.min(right_bound) - val
|
||||
};
|
||||
prom >= min_prominence
|
||||
});
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Detect Q/HQ redox peak in the -100 to +600 mV window.
|
||||
/// Returns peak voltage in mV if found.
|
||||
pub fn detect_qhq_peak(points: &[LsvPoint]) -> Option<f32> {
|
||||
if points.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
|
||||
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
|
||||
|
||||
let window = 5.max(points.len() / 50);
|
||||
let smoothed = smooth(&i_vals, window);
|
||||
|
||||
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let prominence = (i_max - i_min) * 0.05;
|
||||
|
||||
let extrema = find_extrema(&v_vals, &smoothed, prominence);
|
||||
|
||||
extrema.iter()
|
||||
.filter(|&&(idx, is_max)| is_max && v_vals[idx] >= -100.0 && v_vals[idx] <= 600.0)
|
||||
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|&(idx, _)| v_vals[idx])
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ClPotentials {
|
||||
pub v_free: f32,
|
||||
pub v_free_detected: bool,
|
||||
pub v_total: f32,
|
||||
pub v_total_detected: bool,
|
||||
}
|
||||
|
||||
pub fn derive_cl_potentials(points: &[LsvPoint]) -> ClPotentials {
|
||||
let default = ClPotentials {
|
||||
v_free: 100.0,
|
||||
v_free_detected: false,
|
||||
v_total: -200.0,
|
||||
v_total_detected: false,
|
||||
};
|
||||
if points.len() < 5 {
|
||||
return default;
|
||||
}
|
||||
|
||||
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
|
||||
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
|
||||
|
||||
let window = 5.max(points.len() / 50);
|
||||
let smoothed = smooth(&i_vals, window);
|
||||
|
||||
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let prominence = (i_max - i_min) * 0.05;
|
||||
|
||||
let extrema = find_extrema(&v_vals, &smoothed, prominence);
|
||||
|
||||
// v_free: most prominent cathodic peak (is_max==false) in +300 to -300 mV
|
||||
let free_peak = extrema.iter()
|
||||
.filter(|&&(idx, is_max)| !is_max && v_vals[idx] >= -300.0 && v_vals[idx] <= 300.0)
|
||||
.min_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let (v_free, v_free_detected, free_idx) = match free_peak {
|
||||
Some(&(idx, _)) => (v_vals[idx], true, Some(idx)),
|
||||
None => (100.0, false, None),
|
||||
};
|
||||
|
||||
// v_total: secondary cathodic peak between (v_free - 100) and -500 mV, excluding free peak
|
||||
let total_lo = -500.0_f32;
|
||||
let total_hi = v_free - 100.0;
|
||||
let total_peak = extrema.iter()
|
||||
.filter(|&&(idx, is_max)| {
|
||||
!is_max
|
||||
&& v_vals[idx] >= total_lo
|
||||
&& v_vals[idx] <= total_hi
|
||||
&& Some(idx) != free_idx
|
||||
})
|
||||
.min_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let (v_total, v_total_detected) = match total_peak {
|
||||
Some(&(idx, _)) => (v_vals[idx], true),
|
||||
None => (v_free - 300.0, false),
|
||||
};
|
||||
|
||||
let v_total = v_total.max(-400.0);
|
||||
|
||||
ClPotentials { v_free, v_free_detected, v_total, v_total_detected }
|
||||
}
|
||||
|
||||
pub fn detect_peaks(points: &[LsvPoint]) -> Vec<LsvPeak> {
|
||||
if points.len() < 5 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let i_vals: Vec<f32> = points.iter().map(|p| p.i_ua).collect();
|
||||
let v_vals: Vec<f32> = points.iter().map(|p| p.v_mv).collect();
|
||||
|
||||
let window = 5.max(points.len() / 50);
|
||||
let smoothed = smooth(&i_vals, window);
|
||||
|
||||
let i_min = smoothed.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let i_max = smoothed.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
||||
let prominence = (i_max - i_min) * 0.05;
|
||||
|
||||
let extrema = find_extrema(&v_vals, &smoothed, prominence);
|
||||
|
||||
let mut peaks: Vec<LsvPeak> = Vec::new();
|
||||
|
||||
// find crossover: where current changes sign
|
||||
for i in 1..smoothed.len() {
|
||||
if smoothed[i - 1].signum() != smoothed[i].signum() && smoothed[i - 1].signum() != 0.0 {
|
||||
let frac = smoothed[i - 1].abs() / (smoothed[i - 1].abs() + smoothed[i].abs());
|
||||
let v_cross = v_vals[i - 1] + frac * (v_vals[i] - v_vals[i - 1]);
|
||||
peaks.push(LsvPeak {
|
||||
v_mv: v_cross,
|
||||
i_ua: 0.0,
|
||||
kind: PeakKind::Crossover,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// largest peak in positive voltage region -> FreeCl
|
||||
let free_cl = extrema.iter()
|
||||
.filter(|&&(idx, is_max)| is_max && v_vals[idx] >= 0.0)
|
||||
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
if let Some(&(idx, _)) = free_cl {
|
||||
peaks.push(LsvPeak {
|
||||
v_mv: v_vals[idx],
|
||||
i_ua: smoothed[idx],
|
||||
kind: PeakKind::FreeCl,
|
||||
});
|
||||
}
|
||||
|
||||
// largest peak in negative voltage region -> TotalCl
|
||||
let total_cl = extrema.iter()
|
||||
.filter(|&&(idx, is_max)| is_max && v_vals[idx] < 0.0)
|
||||
.max_by(|a, b| smoothed[a.0].partial_cmp(&smoothed[b.0]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
if let Some(&(idx, _)) = total_cl {
|
||||
peaks.push(LsvPeak {
|
||||
v_mv: v_vals[idx],
|
||||
i_ua: smoothed[idx],
|
||||
kind: PeakKind::TotalCl,
|
||||
});
|
||||
}
|
||||
|
||||
peaks
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod app;
|
||||
mod lsv_analysis;
|
||||
mod native_menu;
|
||||
mod plot;
|
||||
mod protocol;
|
||||
|
|
|
|||
164
cue/src/plot.rs
164
cue/src/plot.rs
|
|
@ -3,6 +3,7 @@ use iced::{Color, Point, Rectangle, Renderer, Theme};
|
|||
use iced::mouse;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::lsv_analysis::{LsvPeak, PeakKind};
|
||||
use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint};
|
||||
|
||||
const MARGIN_L: f32 = 55.0;
|
||||
|
|
@ -153,15 +154,60 @@ fn kasa_fit(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
|
|||
Some((cx, cy, r_sq.sqrt()))
|
||||
}
|
||||
|
||||
/// Fit the dominant Nyquist semicircle, trimming first-arc points from the
|
||||
/// low-frequency end before falling back to outlier removal within each subset.
|
||||
fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
|
||||
fn cumulative_turning(pts: &[(f64, f64)]) -> f64 {
|
||||
if pts.len() < 3 { return 0.0; }
|
||||
let mut total = 0.0;
|
||||
for i in 1..pts.len() - 1 {
|
||||
let (dx1, dy1) = (pts[i].0 - pts[i-1].0, pts[i].1 - pts[i-1].1);
|
||||
let (dx2, dy2) = (pts[i+1].0 - pts[i].0, pts[i+1].1 - pts[i].1);
|
||||
let cross = dx1 * dy2 - dy1 * dx2;
|
||||
let dot = dx1 * dx2 + dy1 * dy2;
|
||||
total += cross.atan2(dot).abs();
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
struct LinearFit {
|
||||
slope: f32,
|
||||
y_intercept: f32,
|
||||
rs: f32,
|
||||
}
|
||||
|
||||
fn fit_linear(pts: &[(f64, f64)]) -> Option<LinearFit> {
|
||||
if pts.len() < 2 { return None; }
|
||||
let n = pts.len() as f64;
|
||||
let sx: f64 = pts.iter().map(|p| p.0).sum();
|
||||
let sy: f64 = pts.iter().map(|p| p.1).sum();
|
||||
let sx2: f64 = pts.iter().map(|p| p.0 * p.0).sum();
|
||||
let sxy: f64 = pts.iter().map(|p| p.0 * p.1).sum();
|
||||
let denom = n * sx2 - sx * sx;
|
||||
if denom.abs() < 1e-20 { return None; }
|
||||
let slope = (n * sxy - sx * sy) / denom;
|
||||
let y_int = (sy - slope * sx) / n;
|
||||
let rs = if slope.abs() > 1e-10 { -y_int / slope } else { sx / n };
|
||||
Some(LinearFit {
|
||||
slope: slope as f32,
|
||||
y_intercept: y_int as f32,
|
||||
rs: rs as f32,
|
||||
})
|
||||
}
|
||||
|
||||
enum NyquistFit {
|
||||
Circle(CircleFit),
|
||||
Linear(LinearFit),
|
||||
}
|
||||
|
||||
fn fit_nyquist(points: &[EisPoint]) -> Option<NyquistFit> {
|
||||
let all: Vec<(f64, f64)> = points.iter()
|
||||
.filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
|
||||
.map(|p| (p.z_real as f64, -p.z_imag as f64))
|
||||
.collect();
|
||||
if all.len() < 4 { return None; }
|
||||
|
||||
if cumulative_turning(&all) < 0.524 {
|
||||
return fit_linear(&all).map(NyquistFit::Linear);
|
||||
}
|
||||
|
||||
let min_pts = 4.max(all.len() / 3);
|
||||
let mut best: Option<CircleFit> = None;
|
||||
let mut best_score = f64::MAX;
|
||||
|
|
@ -210,7 +256,17 @@ fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
|
|||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
|
||||
if let Some(circle) = best {
|
||||
if best_score > 0.15 {
|
||||
if let Some(lin) = fit_linear(&all) {
|
||||
return Some(NyquistFit::Linear(lin));
|
||||
}
|
||||
}
|
||||
Some(NyquistFit::Circle(circle))
|
||||
} else {
|
||||
fit_linear(&all).map(NyquistFit::Linear)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Bode ---- */
|
||||
|
|
@ -676,33 +732,66 @@ impl<'a> canvas::Program<Message> for NyquistPlot<'a> {
|
|||
draw_polyline(&mut frame, &pts, COL_NYQ, 2.0);
|
||||
draw_dots(&mut frame, &pts, COL_NYQ, 3.0);
|
||||
|
||||
if let Some(fit) = fit_nyquist_circle(self.points) {
|
||||
let theta_r = (-fit.cy).atan2((fit.r * fit.r - fit.cy * fit.cy).sqrt());
|
||||
let mut theta_l = (-fit.cy).atan2(-(fit.r * fit.r - fit.cy * fit.cy).sqrt());
|
||||
if theta_l < theta_r { theta_l += std::f32::consts::TAU; }
|
||||
match fit_nyquist(self.points) {
|
||||
Some(NyquistFit::Circle(fit)) => {
|
||||
let theta_r = (-fit.cy).atan2((fit.r * fit.r - fit.cy * fit.cy).sqrt());
|
||||
let mut theta_l = (-fit.cy).atan2(-(fit.r * fit.r - fit.cy * fit.cy).sqrt());
|
||||
if theta_l < theta_r { theta_l += std::f32::consts::TAU; }
|
||||
|
||||
let n_arc = 120;
|
||||
let arc_pts: Vec<Point> = (0..=n_arc).map(|i| {
|
||||
let t = theta_r + (theta_l - theta_r) * i as f32 / n_arc as f32;
|
||||
let x = fit.cx + fit.r * t.cos();
|
||||
let y = fit.cy + fit.r * t.sin();
|
||||
Point::new(
|
||||
lerp(x, xv.lo, xv.hi, xl, xr),
|
||||
lerp(y, yv.hi, yv.lo, yt, yb),
|
||||
)
|
||||
}).collect();
|
||||
draw_polyline(&mut frame, &arc_pts, COL_FIT, 1.5);
|
||||
let n_arc = 120;
|
||||
let arc_pts: Vec<Point> = (0..=n_arc).map(|i| {
|
||||
let t = theta_r + (theta_l - theta_r) * i as f32 / n_arc as f32;
|
||||
let x = fit.cx + fit.r * t.cos();
|
||||
let y = fit.cy + fit.r * t.sin();
|
||||
Point::new(
|
||||
lerp(x, xv.lo, xv.hi, xl, xr),
|
||||
lerp(y, yv.hi, yv.lo, yt, yb),
|
||||
)
|
||||
}).collect();
|
||||
draw_polyline(&mut frame, &arc_pts, COL_FIT, 1.5);
|
||||
|
||||
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
|
||||
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
|
||||
let rp_scr = Point::new(lerp(fit.rs + fit.rp, xv.lo, xv.hi, xl, xr), y0_scr);
|
||||
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
|
||||
frame.fill(&Path::circle(rp_scr, 5.0), COL_FIT_PT);
|
||||
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
|
||||
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
|
||||
let rp_scr = Point::new(lerp(fit.rs + fit.rp, xv.lo, xv.hi, xl, xr), y0_scr);
|
||||
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
|
||||
frame.fill(&Path::circle(rp_scr, 5.0), COL_FIT_PT);
|
||||
|
||||
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
|
||||
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
|
||||
dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.0),
|
||||
&format!("Rp={:.0}", fit.rp), COL_FIT_PT, 10.0);
|
||||
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
|
||||
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
|
||||
dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.0),
|
||||
&format!("Rp={:.0}", fit.rp), COL_FIT_PT, 10.0);
|
||||
}
|
||||
Some(NyquistFit::Linear(fit)) => {
|
||||
let x_min = self.points.iter()
|
||||
.filter(|p| p.z_real.is_finite())
|
||||
.map(|p| p.z_real)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let x_max = self.points.iter()
|
||||
.filter(|p| p.z_real.is_finite())
|
||||
.map(|p| p.z_real)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let pad = (x_max - x_min) * 0.1;
|
||||
let x0 = x_min - pad;
|
||||
let x1 = x_max + pad;
|
||||
let y0 = fit.slope * x0 + fit.y_intercept;
|
||||
let y1 = fit.slope * x1 + fit.y_intercept;
|
||||
let p0 = Point::new(
|
||||
lerp(x0, xv.lo, xv.hi, xl, xr),
|
||||
lerp(y0, yv.hi, yv.lo, yt, yb),
|
||||
);
|
||||
let p1 = Point::new(
|
||||
lerp(x1, xv.lo, xv.hi, xl, xr),
|
||||
lerp(y1, yv.hi, yv.lo, yt, yb),
|
||||
);
|
||||
dl(&mut frame, p0, p1, COL_FIT, 1.5);
|
||||
|
||||
let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
|
||||
let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
|
||||
frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
|
||||
dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
|
||||
&format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
|
|
@ -735,6 +824,7 @@ pub struct VoltammogramState {
|
|||
pub struct VoltammogramPlot<'a> {
|
||||
pub points: &'a [LsvPoint],
|
||||
pub reference: Option<&'a [LsvPoint]>,
|
||||
pub peaks: &'a [LsvPeak],
|
||||
}
|
||||
|
||||
impl VoltammogramPlot<'_> {
|
||||
|
|
@ -895,6 +985,24 @@ impl<'a> canvas::Program<Message> for VoltammogramPlot<'a> {
|
|||
draw_polyline(&mut frame, &pts, COL_LSV, 2.0);
|
||||
draw_dots(&mut frame, &pts, COL_LSV, 2.5);
|
||||
|
||||
for peak in self.peaks {
|
||||
let px = lerp(peak.v_mv, xv.lo, xv.hi, xl, xr);
|
||||
let py = lerp(peak.i_ua, yv.hi, yv.lo, yt, yb);
|
||||
if !px.is_finite() || !py.is_finite() { continue; }
|
||||
let col = match peak.kind {
|
||||
PeakKind::FreeCl => COL_CL_FREE,
|
||||
PeakKind::TotalCl => COL_CL_TOTAL,
|
||||
PeakKind::Crossover => COL_FIT_PT,
|
||||
};
|
||||
frame.fill(&Path::circle(Point::new(px, py), 5.0), col);
|
||||
let label = match peak.kind {
|
||||
PeakKind::FreeCl => format!("Free {:.0}mV {:.2}uA", peak.v_mv, peak.i_ua),
|
||||
PeakKind::TotalCl => format!("Total {:.0}mV {:.2}uA", peak.v_mv, peak.i_ua),
|
||||
PeakKind::Crossover => format!("X-over {:.0}mV", peak.v_mv),
|
||||
};
|
||||
dt(&mut frame, Point::new(px - 20.0, py + 8.0), &label, col, 10.0);
|
||||
}
|
||||
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
|
||||
let v = lerp(pos.x, xl, xr, xv.lo, xv.hi);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ pub const RSP_REF_LP_RANGE: u8 = 0x21;
|
|||
pub const RSP_REFS_DONE: u8 = 0x22;
|
||||
pub const RSP_CELL_K: u8 = 0x11;
|
||||
pub const RSP_REF_STATUS: u8 = 0x23;
|
||||
pub const RSP_CL_FACTOR: u8 = 0x24;
|
||||
pub const RSP_PH_CAL: u8 = 0x25;
|
||||
pub const RSP_KEEPALIVE: u8 = 0x50;
|
||||
|
||||
/* Cue → ESP32 */
|
||||
pub const CMD_SET_SWEEP: u8 = 0x10;
|
||||
|
|
@ -44,6 +47,10 @@ pub const CMD_START_PH: u8 = 0x24;
|
|||
pub const CMD_START_CLEAN: u8 = 0x25;
|
||||
pub const CMD_SET_CELL_K: u8 = 0x28;
|
||||
pub const CMD_GET_CELL_K: u8 = 0x29;
|
||||
pub const CMD_SET_CL_FACTOR: u8 = 0x33;
|
||||
pub const CMD_GET_CL_FACTOR: u8 = 0x34;
|
||||
pub const CMD_SET_PH_CAL: u8 = 0x35;
|
||||
pub const CMD_GET_PH_CAL: u8 = 0x36;
|
||||
pub const CMD_START_REFS: u8 = 0x30;
|
||||
pub const CMD_GET_REFS: u8 = 0x31;
|
||||
pub const CMD_CLEAR_REFS: u8 = 0x32;
|
||||
|
|
@ -237,27 +244,32 @@ pub struct EisConfig {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EisMessage {
|
||||
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32 },
|
||||
SweepStart { num_points: u16, freq_start: f32, freq_stop: f32,
|
||||
esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||
DataPoint { _index: u16, point: EisPoint },
|
||||
SweepEnd,
|
||||
Config(EisConfig),
|
||||
LsvStart { num_points: u16, v_start: f32, v_stop: f32 },
|
||||
LsvStart { num_points: u16, v_start: f32, v_stop: f32,
|
||||
esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||
LsvPoint { _index: u16, point: LsvPoint },
|
||||
LsvEnd,
|
||||
AmpStart { v_hold: f32 },
|
||||
AmpStart { v_hold: f32, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||
AmpPoint { _index: u16, point: AmpPoint },
|
||||
AmpEnd,
|
||||
ClStart { num_points: u16 },
|
||||
ClStart { num_points: u16, esp_timestamp: Option<u32>, meas_id: Option<u16> },
|
||||
ClPoint { _index: u16, point: ClPoint },
|
||||
ClResult(ClResult),
|
||||
ClEnd,
|
||||
PhResult(PhResult),
|
||||
PhResult(PhResult, Option<u32>, Option<u16>),
|
||||
Temperature(f32),
|
||||
RefFrame { mode: u8, rtia_idx: u8 },
|
||||
RefLpRange { mode: u8, low_idx: u8, high_idx: u8 },
|
||||
RefsDone,
|
||||
RefStatus { has_refs: bool },
|
||||
CellK(f32),
|
||||
ClFactor(f32),
|
||||
PhCal { slope: f32, offset: f32 },
|
||||
Keepalive,
|
||||
}
|
||||
|
||||
fn decode_u16(data: &[u8]) -> u16 {
|
||||
|
|
@ -275,6 +287,16 @@ fn decode_float(data: &[u8]) -> f32 {
|
|||
f32::from_le_bytes([b0, b1, b2, b3])
|
||||
}
|
||||
|
||||
fn decode_u32(data: &[u8]) -> u32 {
|
||||
let b = [
|
||||
data[1] | ((data[0] & 1) << 7),
|
||||
data[2] | ((data[0] & 2) << 6),
|
||||
data[3] | ((data[0] & 4) << 5),
|
||||
data[4] | ((data[0] & 8) << 4),
|
||||
];
|
||||
u32::from_le_bytes(b)
|
||||
}
|
||||
|
||||
fn encode_float(val: f32) -> [u8; 5] {
|
||||
let p = val.to_le_bytes();
|
||||
[
|
||||
|
|
@ -293,10 +315,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
match data[1] {
|
||||
RSP_SWEEP_START if data.len() >= 15 => {
|
||||
let p = &data[2..];
|
||||
let (ts, mid) = if p.len() >= 21 {
|
||||
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
|
||||
} else { (None, None) };
|
||||
Some(EisMessage::SweepStart {
|
||||
num_points: decode_u16(&p[0..3]),
|
||||
freq_start: decode_float(&p[3..8]),
|
||||
freq_stop: decode_float(&p[8..13]),
|
||||
esp_timestamp: ts, meas_id: mid,
|
||||
})
|
||||
}
|
||||
RSP_DATA_POINT if data.len() >= 30 => {
|
||||
|
|
@ -332,10 +358,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
}
|
||||
RSP_LSV_START if data.len() >= 15 => {
|
||||
let p = &data[2..];
|
||||
let (ts, mid) = if p.len() >= 21 {
|
||||
(Some(decode_u32(&p[13..18])), Some(decode_u16(&p[18..21])))
|
||||
} else { (None, None) };
|
||||
Some(EisMessage::LsvStart {
|
||||
num_points: decode_u16(&p[0..3]),
|
||||
v_start: decode_float(&p[3..8]),
|
||||
v_stop: decode_float(&p[8..13]),
|
||||
esp_timestamp: ts, meas_id: mid,
|
||||
})
|
||||
}
|
||||
RSP_LSV_POINT if data.len() >= 15 => {
|
||||
|
|
@ -351,7 +381,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
RSP_LSV_END => Some(EisMessage::LsvEnd),
|
||||
RSP_AMP_START if data.len() >= 7 => {
|
||||
let p = &data[2..];
|
||||
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]) })
|
||||
let (ts, mid) = if p.len() >= 13 {
|
||||
(Some(decode_u32(&p[5..10])), Some(decode_u16(&p[10..13])))
|
||||
} else { (None, None) };
|
||||
Some(EisMessage::AmpStart { v_hold: decode_float(&p[0..5]),
|
||||
esp_timestamp: ts, meas_id: mid })
|
||||
}
|
||||
RSP_AMP_POINT if data.len() >= 15 => {
|
||||
let p = &data[2..];
|
||||
|
|
@ -366,7 +400,11 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
RSP_AMP_END => Some(EisMessage::AmpEnd),
|
||||
RSP_CL_START if data.len() >= 5 => {
|
||||
let p = &data[2..];
|
||||
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]) })
|
||||
let (ts, mid) = if p.len() >= 11 {
|
||||
(Some(decode_u32(&p[3..8])), Some(decode_u16(&p[8..11])))
|
||||
} else { (None, None) };
|
||||
Some(EisMessage::ClStart { num_points: decode_u16(&p[0..3]),
|
||||
esp_timestamp: ts, meas_id: mid })
|
||||
}
|
||||
RSP_CL_POINT if data.len() >= 16 => {
|
||||
let p = &data[2..];
|
||||
|
|
@ -393,11 +431,14 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
}
|
||||
RSP_PH_RESULT if data.len() >= 17 => {
|
||||
let p = &data[2..];
|
||||
let (ts, mid) = if p.len() >= 23 {
|
||||
(Some(decode_u32(&p[15..20])), Some(decode_u16(&p[20..23])))
|
||||
} else { (None, None) };
|
||||
Some(EisMessage::PhResult(PhResult {
|
||||
v_ocp_mv: decode_float(&p[0..5]),
|
||||
ph: decode_float(&p[5..10]),
|
||||
temp_c: decode_float(&p[10..15]),
|
||||
}))
|
||||
}, ts, mid))
|
||||
}
|
||||
RSP_REF_FRAME if data.len() >= 4 => {
|
||||
Some(EisMessage::RefFrame { mode: data[2], rtia_idx: data[3] })
|
||||
|
|
@ -413,6 +454,18 @@ pub fn parse_sysex(data: &[u8]) -> Option<EisMessage> {
|
|||
let p = &data[2..];
|
||||
Some(EisMessage::CellK(decode_float(&p[0..5])))
|
||||
}
|
||||
RSP_CL_FACTOR if data.len() >= 7 => {
|
||||
let p = &data[2..];
|
||||
Some(EisMessage::ClFactor(decode_float(&p[0..5])))
|
||||
}
|
||||
RSP_PH_CAL if data.len() >= 12 => {
|
||||
let p = &data[2..];
|
||||
Some(EisMessage::PhCal {
|
||||
slope: decode_float(&p[0..5]),
|
||||
offset: decode_float(&p[5..10]),
|
||||
})
|
||||
}
|
||||
RSP_KEEPALIVE => Some(EisMessage::Keepalive),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -446,12 +499,13 @@ pub fn build_sysex_get_config() -> Vec<u8> {
|
|||
vec![0xF0, SYSEX_MFR, CMD_GET_CONFIG, 0xF7]
|
||||
}
|
||||
|
||||
pub fn build_sysex_start_lsv(v_start: f32, v_stop: f32, scan_rate: f32, lp_rtia: LpRtia) -> Vec<u8> {
|
||||
pub fn build_sysex_start_lsv(v_start: f32, v_stop: f32, scan_rate: f32, lp_rtia: LpRtia, num_points: u16) -> Vec<u8> {
|
||||
let mut sx = vec![0xF0, SYSEX_MFR, CMD_START_LSV];
|
||||
sx.extend_from_slice(&encode_float(v_start));
|
||||
sx.extend_from_slice(&encode_float(v_stop));
|
||||
sx.extend_from_slice(&encode_float(scan_rate));
|
||||
sx.push(lp_rtia.as_byte());
|
||||
sx.extend_from_slice(&encode_u16(num_points));
|
||||
sx.push(0xF7);
|
||||
sx
|
||||
}
|
||||
|
|
@ -527,3 +581,26 @@ pub fn build_sysex_set_cell_k(k: f32) -> Vec<u8> {
|
|||
pub fn build_sysex_get_cell_k() -> Vec<u8> {
|
||||
vec![0xF0, SYSEX_MFR, CMD_GET_CELL_K, 0xF7]
|
||||
}
|
||||
|
||||
pub fn build_sysex_set_cl_factor(f: f32) -> Vec<u8> {
|
||||
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_CL_FACTOR];
|
||||
sx.extend_from_slice(&encode_float(f));
|
||||
sx.push(0xF7);
|
||||
sx
|
||||
}
|
||||
|
||||
pub fn build_sysex_get_cl_factor() -> Vec<u8> {
|
||||
vec![0xF0, SYSEX_MFR, CMD_GET_CL_FACTOR, 0xF7]
|
||||
}
|
||||
|
||||
pub fn build_sysex_set_ph_cal(slope: f32, offset: f32) -> Vec<u8> {
|
||||
let mut sx = vec![0xF0, SYSEX_MFR, CMD_SET_PH_CAL];
|
||||
sx.extend_from_slice(&encode_float(slope));
|
||||
sx.extend_from_slice(&encode_float(offset));
|
||||
sx.push(0xF7);
|
||||
sx
|
||||
}
|
||||
|
||||
pub fn build_sysex_get_ph_cal() -> Vec<u8> {
|
||||
vec![0xF0, SYSEX_MFR, CMD_GET_PH_CAL, 0xF7]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub struct Measurement {
|
|||
pub mtype: String,
|
||||
pub params_json: String,
|
||||
pub created_at: String,
|
||||
pub esp_timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -41,9 +42,21 @@ impl Storage {
|
|||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
Self::migrate_v2(&conn)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
fn migrate_v2(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
let has_col: bool = conn.prepare("SELECT esp_timestamp FROM measurements LIMIT 0")
|
||||
.is_ok();
|
||||
if !has_col {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE measurements ADD COLUMN esp_timestamp INTEGER;"
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_session(&self, name: &str, notes: &str) -> Result<i64, rusqlite::Error> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO sessions (name, notes) VALUES (?1, ?2)",
|
||||
|
|
@ -74,10 +87,21 @@ impl Storage {
|
|||
|
||||
pub fn create_measurement(
|
||||
&self, session_id: i64, mtype: &str, params_json: &str,
|
||||
esp_timestamp: Option<u32>,
|
||||
) -> Result<i64, rusqlite::Error> {
|
||||
if let Some(ts) = esp_timestamp {
|
||||
let exists: bool = self.conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM measurements WHERE session_id = ?1 AND esp_timestamp = ?2)",
|
||||
params![session_id, ts as i64],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if exists {
|
||||
return Err(rusqlite::Error::StatementChangedRows(0));
|
||||
}
|
||||
}
|
||||
self.conn.execute(
|
||||
"INSERT INTO measurements (session_id, type, params_json) VALUES (?1, ?2, ?3)",
|
||||
params![session_id, mtype, params_json],
|
||||
"INSERT INTO measurements (session_id, type, params_json, esp_timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![session_id, mtype, params_json, esp_timestamp.map(|t| t as i64)],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
|
@ -109,7 +133,7 @@ impl Storage {
|
|||
|
||||
pub fn get_measurements(&self, session_id: i64) -> Result<Vec<Measurement>, rusqlite::Error> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, session_id, type, params_json, created_at \
|
||||
"SELECT id, session_id, type, params_json, created_at, esp_timestamp \
|
||||
FROM measurements WHERE session_id = ?1 ORDER BY created_at DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
|
|
@ -119,6 +143,7 @@ impl Storage {
|
|||
mtype: row.get(2)?,
|
||||
params_json: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
esp_timestamp: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
|
|
@ -244,7 +269,7 @@ impl Storage {
|
|||
Some(t) => serde_json::to_string(&toml_table_to_json(t))?,
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
let mid = self.create_measurement(session_id, mtype, ¶ms_json)?;
|
||||
let mid = self.create_measurement(session_id, mtype, ¶ms_json, None)?;
|
||||
|
||||
if let Some(toml::Value::Array(data)) = mt.get("data") {
|
||||
let pts: Vec<(i32, String)> = data.iter().enumerate()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
idf_component_register(SRCS "eis4.c" "eis.c" "echem.c" "protocol.c" "wifi_transport.c" "temp.c" "refs.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event)
|
||||
REQUIRES ad5941 ad5941_port nvs_flash esp_wifi esp_netif esp_event esp_timer)
|
||||
|
||||
if(DEFINED ENV{WIFI_SSID})
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
|
|
|
|||
38
main/echem.c
38
main/echem.c
|
|
@ -327,7 +327,14 @@ int echem_clean(float v_mv, float duration_s)
|
|||
AD5940_LPDAC0WriteS(code, VZERO_CODE);
|
||||
|
||||
printf("Clean: %.0f mV for %.0f s\n", v_mv, duration_s);
|
||||
vTaskDelay(pdMS_TO_TICKS((uint32_t)(duration_s * 1000.0f)));
|
||||
|
||||
uint32_t remain_ms = (uint32_t)(duration_s * 1000.0f);
|
||||
while (remain_ms > 0) {
|
||||
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
|
||||
vTaskDelay(pdMS_TO_TICKS(chunk));
|
||||
remain_ms -= chunk;
|
||||
if (remain_ms > 0) send_keepalive();
|
||||
}
|
||||
|
||||
echem_shutdown_lp();
|
||||
AD5940_AFECtrlS(AFECTRL_ALL, bFALSE);
|
||||
|
|
@ -497,7 +504,15 @@ static uint32_t sample_phase(float v_mv, float t_dep_ms, float t_meas_ms,
|
|||
AD5940_LPDAC0WriteS(mv_to_vbias_code(v_mv), VZERO_CODE);
|
||||
|
||||
/* settling — no samples recorded */
|
||||
vTaskDelay(pdMS_TO_TICKS((uint32_t)t_dep_ms));
|
||||
{
|
||||
uint32_t remain_ms = (uint32_t)t_dep_ms;
|
||||
while (remain_ms > 0) {
|
||||
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
|
||||
vTaskDelay(pdMS_TO_TICKS(chunk));
|
||||
remain_ms -= chunk;
|
||||
if (remain_ms > 0) send_keepalive();
|
||||
}
|
||||
}
|
||||
|
||||
/* measurement — sample at ~50ms intervals */
|
||||
uint32_t n_samples = (uint32_t)(t_meas_ms / 50.0f + 0.5f);
|
||||
|
|
@ -541,7 +556,15 @@ int echem_chlorine(const ClConfig *cfg, ClPoint *out, uint32_t max_points,
|
|||
|
||||
printf("Cl: conditioning at %.0f mV for %.0f ms\n", cfg->v_cond, cfg->t_cond_ms);
|
||||
AD5940_LPDAC0WriteS(mv_to_vbias_code(cfg->v_cond), VZERO_CODE);
|
||||
vTaskDelay(pdMS_TO_TICKS((uint32_t)cfg->t_cond_ms));
|
||||
{
|
||||
uint32_t remain_ms = (uint32_t)cfg->t_cond_ms;
|
||||
while (remain_ms > 0) {
|
||||
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
|
||||
vTaskDelay(pdMS_TO_TICKS(chunk));
|
||||
remain_ms -= chunk;
|
||||
if (remain_ms > 0) send_keepalive();
|
||||
}
|
||||
}
|
||||
|
||||
printf("Cl: free chlorine at %.0f mV\n", cfg->v_free);
|
||||
idx = sample_phase(cfg->v_free, cfg->t_dep_ms, cfg->t_meas_ms,
|
||||
|
|
@ -579,7 +602,14 @@ int echem_ph_ocp(const PhConfig *cfg, PhResult *result)
|
|||
AD5940_ADCBaseCfgS(&adc);
|
||||
|
||||
printf("pH: stabilizing %0.f s\n", cfg->stabilize_s);
|
||||
vTaskDelay(pdMS_TO_TICKS((uint32_t)(cfg->stabilize_s * 1000.0f)));
|
||||
|
||||
uint32_t remain_ms = (uint32_t)(cfg->stabilize_s * 1000.0f);
|
||||
while (remain_ms > 0) {
|
||||
uint32_t chunk = remain_ms > 3000 ? 3000 : remain_ms;
|
||||
vTaskDelay(pdMS_TO_TICKS(chunk));
|
||||
remain_ms -= chunk;
|
||||
if (remain_ms > 0) send_keepalive();
|
||||
}
|
||||
|
||||
/* average N readings of V(SE0) and V(RE0) */
|
||||
#define PH_AVG_N 10
|
||||
|
|
|
|||
61
main/eis.c
61
main/eis.c
|
|
@ -25,6 +25,9 @@ static struct {
|
|||
|
||||
/* cell constant K (cm⁻¹), cached from NVS */
|
||||
static float cell_k_cached;
|
||||
static float cl_factor_cached;
|
||||
static float ph_slope_cached;
|
||||
static float ph_offset_cached;
|
||||
|
||||
/* open-circuit calibration data */
|
||||
static struct {
|
||||
|
|
@ -593,7 +596,10 @@ int eis_has_open_cal(void)
|
|||
return ocal.valid;
|
||||
}
|
||||
|
||||
#define NVS_CELLK_KEY "cell_k"
|
||||
#define NVS_CELLK_KEY "cell_k"
|
||||
#define NVS_CLFACTOR_KEY "cl_factor"
|
||||
#define NVS_PH_SLOPE_KEY "ph_slope"
|
||||
#define NVS_PH_OFFSET_KEY "ph_offset"
|
||||
|
||||
void eis_set_cell_k(float k)
|
||||
{
|
||||
|
|
@ -619,3 +625,56 @@ void eis_load_cell_k(void)
|
|||
cell_k_cached = 0.0f;
|
||||
nvs_close(h);
|
||||
}
|
||||
|
||||
void eis_set_cl_factor(float f)
|
||||
{
|
||||
cl_factor_cached = f;
|
||||
nvs_handle_t h;
|
||||
if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return;
|
||||
nvs_set_blob(h, NVS_CLFACTOR_KEY, &f, sizeof(f));
|
||||
nvs_commit(h);
|
||||
nvs_close(h);
|
||||
}
|
||||
|
||||
float eis_get_cl_factor(void)
|
||||
{
|
||||
return cl_factor_cached;
|
||||
}
|
||||
|
||||
void eis_load_cl_factor(void)
|
||||
{
|
||||
nvs_handle_t h;
|
||||
if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return;
|
||||
size_t len = sizeof(cl_factor_cached);
|
||||
if (nvs_get_blob(h, NVS_CLFACTOR_KEY, &cl_factor_cached, &len) != ESP_OK || len != sizeof(cl_factor_cached))
|
||||
cl_factor_cached = 0.0f;
|
||||
nvs_close(h);
|
||||
}
|
||||
|
||||
void eis_set_ph_cal(float slope, float offset)
|
||||
{
|
||||
ph_slope_cached = slope;
|
||||
ph_offset_cached = offset;
|
||||
nvs_handle_t h;
|
||||
if (nvs_open(NVS_OCAL_NS, NVS_READWRITE, &h) != ESP_OK) return;
|
||||
nvs_set_blob(h, NVS_PH_SLOPE_KEY, &slope, sizeof(slope));
|
||||
nvs_set_blob(h, NVS_PH_OFFSET_KEY, &offset, sizeof(offset));
|
||||
nvs_commit(h);
|
||||
nvs_close(h);
|
||||
}
|
||||
|
||||
float eis_get_ph_slope(void) { return ph_slope_cached; }
|
||||
float eis_get_ph_offset(void) { return ph_offset_cached; }
|
||||
|
||||
void eis_load_ph_cal(void)
|
||||
{
|
||||
nvs_handle_t h;
|
||||
if (nvs_open(NVS_OCAL_NS, NVS_READONLY, &h) != ESP_OK) return;
|
||||
size_t len = sizeof(ph_slope_cached);
|
||||
if (nvs_get_blob(h, NVS_PH_SLOPE_KEY, &ph_slope_cached, &len) != ESP_OK || len != sizeof(ph_slope_cached))
|
||||
ph_slope_cached = 0.0f;
|
||||
len = sizeof(ph_offset_cached);
|
||||
if (nvs_get_blob(h, NVS_PH_OFFSET_KEY, &ph_offset_cached, &len) != ESP_OK || len != sizeof(ph_offset_cached))
|
||||
ph_offset_cached = 0.0f;
|
||||
nvs_close(h);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,4 +67,13 @@ void eis_set_cell_k(float k);
|
|||
float eis_get_cell_k(void);
|
||||
void eis_load_cell_k(void);
|
||||
|
||||
void eis_set_cl_factor(float f);
|
||||
float eis_get_cl_factor(void);
|
||||
void eis_load_cl_factor(void);
|
||||
|
||||
void eis_set_ph_cal(float slope, float offset);
|
||||
float eis_get_ph_slope(void);
|
||||
float eis_get_ph_offset(void);
|
||||
void eis_load_ph_cal(void);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
67
main/eis4.c
67
main/eis4.c
|
|
@ -12,9 +12,11 @@
|
|||
#include "nvs_flash.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
#define AD5941_EXPECTED_ADIID 0x4144
|
||||
static EISConfig cfg;
|
||||
static uint16_t measurement_counter = 0;
|
||||
static EISPoint results[EIS_MAX_POINTS];
|
||||
static LSVPoint lsv_results[ECHEM_MAX_POINTS];
|
||||
static AmpPoint amp_results[ECHEM_MAX_POINTS];
|
||||
|
|
@ -25,8 +27,10 @@ static void do_sweep(void)
|
|||
{
|
||||
eis_init(&cfg);
|
||||
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
uint32_t n = eis_calc_num_points(&cfg);
|
||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
|
||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
|
||||
int got = eis_sweep(results, n, send_eis_point);
|
||||
printf("Sweep complete: %d points\n", got);
|
||||
send_sweep_end();
|
||||
|
|
@ -56,6 +60,8 @@ void app_main(void)
|
|||
eis_default_config(&cfg);
|
||||
eis_load_open_cal();
|
||||
eis_load_cell_k();
|
||||
eis_load_cl_factor();
|
||||
eis_load_ph_cal();
|
||||
temp_init();
|
||||
|
||||
esp_netif_init();
|
||||
|
|
@ -123,12 +129,18 @@ void app_main(void)
|
|||
lsv_cfg.v_stop = cmd.lsv.v_stop;
|
||||
lsv_cfg.scan_rate = cmd.lsv.scan_rate;
|
||||
lsv_cfg.lp_rtia = cmd.lsv.lp_rtia;
|
||||
printf("LSV: %.0f-%.0f mV, %.0f mV/s, rtia=%u\n",
|
||||
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia);
|
||||
uint32_t max_pts = ECHEM_MAX_POINTS;
|
||||
if (cmd.lsv.num_points > 0 && cmd.lsv.num_points < ECHEM_MAX_POINTS)
|
||||
max_pts = cmd.lsv.num_points;
|
||||
printf("LSV: %.0f-%.0f mV, %.0f mV/s, rtia=%u, max_pts=%lu\n",
|
||||
lsv_cfg.v_start, lsv_cfg.v_stop, lsv_cfg.scan_rate, lsv_cfg.lp_rtia,
|
||||
(unsigned long)max_pts);
|
||||
|
||||
uint32_t n = echem_lsv_calc_steps(&lsv_cfg, ECHEM_MAX_POINTS);
|
||||
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop);
|
||||
int got = echem_lsv(&lsv_cfg, lsv_results, ECHEM_MAX_POINTS, send_lsv_point);
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
uint32_t n = echem_lsv_calc_steps(&lsv_cfg, max_pts);
|
||||
send_lsv_start(n, lsv_cfg.v_start, lsv_cfg.v_stop, ts_ms, measurement_counter);
|
||||
int got = echem_lsv(&lsv_cfg, lsv_results, max_pts, send_lsv_point);
|
||||
printf("LSV complete: %d points\n", got);
|
||||
send_lsv_end();
|
||||
break;
|
||||
|
|
@ -143,7 +155,11 @@ void app_main(void)
|
|||
printf("Amp: %.0f mV, %.0f ms interval, %.0f s\n",
|
||||
amp_cfg.v_hold, amp_cfg.interval_ms, amp_cfg.duration_s);
|
||||
|
||||
send_amp_start(amp_cfg.v_hold);
|
||||
{
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
send_amp_start(amp_cfg.v_hold, ts_ms, measurement_counter);
|
||||
}
|
||||
int got = echem_amp(&_cfg, amp_results, ECHEM_MAX_POINTS, send_amp_point);
|
||||
printf("Amp complete: %d points\n", got);
|
||||
send_amp_end();
|
||||
|
|
@ -165,7 +181,12 @@ void app_main(void)
|
|||
echem_ph_ocp(&ph_cfg, &ph_result);
|
||||
printf("pH: OCP=%.1f mV, pH=%.2f\n",
|
||||
ph_result.v_ocp_mv, ph_result.ph);
|
||||
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c);
|
||||
{
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
send_ph_result(ph_result.v_ocp_mv, ph_result.ph, ph_result.temp_c,
|
||||
ts_ms, measurement_counter);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -191,8 +212,10 @@ void app_main(void)
|
|||
case CMD_OPEN_CAL: {
|
||||
printf("Open-circuit cal starting\n");
|
||||
eis_init(&cfg);
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
uint32_t n = eis_calc_num_points(&cfg);
|
||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz);
|
||||
send_sweep_start(n, cfg.freq_start_hz, cfg.freq_stop_hz, ts_ms, measurement_counter);
|
||||
int got = eis_open_cal(results, n, send_eis_point);
|
||||
printf("Open-circuit cal: %d points\n", got);
|
||||
send_sweep_end();
|
||||
|
|
@ -214,6 +237,26 @@ void app_main(void)
|
|||
send_cell_k(eis_get_cell_k());
|
||||
break;
|
||||
|
||||
case CMD_SET_CL_FACTOR:
|
||||
eis_set_cl_factor(cmd.cl_factor);
|
||||
send_cl_factor(cmd.cl_factor);
|
||||
printf("Cl factor set: %.6f\n", cmd.cl_factor);
|
||||
break;
|
||||
|
||||
case CMD_GET_CL_FACTOR:
|
||||
send_cl_factor(eis_get_cl_factor());
|
||||
break;
|
||||
|
||||
case CMD_SET_PH_CAL:
|
||||
eis_set_ph_cal(cmd.ph_cal.slope, cmd.ph_cal.offset);
|
||||
send_ph_cal(cmd.ph_cal.slope, cmd.ph_cal.offset);
|
||||
printf("pH cal set: slope=%.4f offset=%.4f\n", cmd.ph_cal.slope, cmd.ph_cal.offset);
|
||||
break;
|
||||
|
||||
case CMD_GET_PH_CAL:
|
||||
send_ph_cal(eis_get_ph_slope(), eis_get_ph_offset());
|
||||
break;
|
||||
|
||||
case CMD_START_CL: {
|
||||
ClConfig cl_cfg;
|
||||
cl_cfg.v_cond = cmd.cl.v_cond;
|
||||
|
|
@ -226,7 +269,11 @@ void app_main(void)
|
|||
|
||||
uint32_t n_per = (uint32_t)(cl_cfg.t_meas_ms / 50.0f + 0.5f);
|
||||
if (n_per < 2) n_per = 2;
|
||||
send_cl_start(2 * n_per);
|
||||
{
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
measurement_counter++;
|
||||
send_cl_start(2 * n_per, ts_ms, measurement_counter);
|
||||
}
|
||||
ClResult cl_result;
|
||||
int got = echem_chlorine(&cl_cfg, cl_results, ECHEM_MAX_POINTS,
|
||||
&cl_result, send_cl_point);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,29 @@ static void encode_u16(uint16_t val, uint8_t *out)
|
|||
out[2] = p[1] & 0x7F;
|
||||
}
|
||||
|
||||
void encode_u32(uint32_t val, uint8_t *out)
|
||||
{
|
||||
uint8_t *p = (uint8_t *)&val;
|
||||
out[0] = ((p[0] >> 7) & 1) | ((p[1] >> 6) & 2) |
|
||||
((p[2] >> 5) & 4) | ((p[3] >> 4) & 8);
|
||||
out[1] = p[0] & 0x7F;
|
||||
out[2] = p[1] & 0x7F;
|
||||
out[3] = p[2] & 0x7F;
|
||||
out[4] = p[3] & 0x7F;
|
||||
}
|
||||
|
||||
uint32_t decode_u32(const uint8_t *d)
|
||||
{
|
||||
uint8_t b[4];
|
||||
b[0] = d[1] | ((d[0] & 1) << 7);
|
||||
b[1] = d[2] | ((d[0] & 2) << 6);
|
||||
b[2] = d[3] | ((d[0] & 4) << 5);
|
||||
b[3] = d[4] | ((d[0] & 8) << 4);
|
||||
uint32_t v;
|
||||
memcpy(&v, b, 4);
|
||||
return v;
|
||||
}
|
||||
|
||||
float decode_float(const uint8_t *d)
|
||||
{
|
||||
uint8_t b[4];
|
||||
|
|
@ -144,16 +167,27 @@ static int send_sysex(const uint8_t *sysex, uint16_t len)
|
|||
return wifi_send_sysex(sysex, len);
|
||||
}
|
||||
|
||||
/* ---- outbound: keepalive ---- */
|
||||
|
||||
int send_keepalive(void)
|
||||
{
|
||||
uint8_t sx[] = { 0xF0, 0x7D, RSP_KEEPALIVE, 0xF7 };
|
||||
return send_sysex(sx, sizeof(sx));
|
||||
}
|
||||
|
||||
/* ---- outbound: EIS ---- */
|
||||
|
||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop)
|
||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
|
||||
uint32_t ts_ms, uint16_t meas_id)
|
||||
{
|
||||
uint8_t sx[20];
|
||||
uint8_t sx[28];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_SWEEP_START;
|
||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||
encode_float(freq_start, &sx[p]); p += 5;
|
||||
encode_float(freq_stop, &sx[p]); p += 5;
|
||||
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||
encode_u16(meas_id, &sx[p]); p += 3;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
|
@ -201,14 +235,17 @@ int send_config(const EISConfig *cfg)
|
|||
|
||||
/* ---- outbound: LSV ---- */
|
||||
|
||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop)
|
||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
|
||||
uint32_t ts_ms, uint16_t meas_id)
|
||||
{
|
||||
uint8_t sx[20];
|
||||
uint8_t sx[28];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_LSV_START;
|
||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||
encode_float(v_start, &sx[p]); p += 5;
|
||||
encode_float(v_stop, &sx[p]); p += 5;
|
||||
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||
encode_u16(meas_id, &sx[p]); p += 3;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
|
@ -233,12 +270,14 @@ int send_lsv_end(void)
|
|||
|
||||
/* ---- outbound: Amperometry ---- */
|
||||
|
||||
int send_amp_start(float v_hold)
|
||||
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id)
|
||||
{
|
||||
uint8_t sx[12];
|
||||
uint8_t sx[20];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_AMP_START;
|
||||
encode_float(v_hold, &sx[p]); p += 5;
|
||||
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||
encode_u16(meas_id, &sx[p]); p += 3;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
|
@ -263,12 +302,14 @@ int send_amp_end(void)
|
|||
|
||||
/* ---- outbound: Chlorine ---- */
|
||||
|
||||
int send_cl_start(uint32_t num_points)
|
||||
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id)
|
||||
{
|
||||
uint8_t sx[10];
|
||||
uint8_t sx[18];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_START;
|
||||
encode_u16((uint16_t)num_points, &sx[p]); p += 3;
|
||||
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||
encode_u16(meas_id, &sx[p]); p += 3;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
|
@ -303,16 +344,32 @@ int send_cl_end(void)
|
|||
return send_sysex(sx, sizeof(sx));
|
||||
}
|
||||
|
||||
/* ---- outbound: pH calibration ---- */
|
||||
|
||||
int send_ph_cal(float slope, float offset)
|
||||
{
|
||||
uint8_t sx[16];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_CAL;
|
||||
encode_float(slope, &sx[p]); p += 5;
|
||||
encode_float(offset, &sx[p]); p += 5;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
||||
/* ---- outbound: pH ---- */
|
||||
|
||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c)
|
||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
|
||||
uint32_t ts_ms, uint16_t meas_id)
|
||||
{
|
||||
uint8_t sx[20];
|
||||
uint8_t sx[28];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_PH_RESULT;
|
||||
encode_float(v_ocp_mv, &sx[p]); p += 5;
|
||||
encode_float(ph, &sx[p]); p += 5;
|
||||
encode_float(temp_c, &sx[p]); p += 5;
|
||||
encode_u32(ts_ms, &sx[p]); p += 5;
|
||||
encode_u16(meas_id, &sx[p]); p += 3;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
|
@ -341,6 +398,18 @@ int send_cell_k(float k)
|
|||
return send_sysex(sx, p);
|
||||
}
|
||||
|
||||
/* ---- outbound: chlorine factor ---- */
|
||||
|
||||
int send_cl_factor(float f)
|
||||
{
|
||||
uint8_t sx[12];
|
||||
uint16_t p = 0;
|
||||
sx[p++] = 0xF0; sx[p++] = 0x7D; sx[p++] = RSP_CL_FACTOR;
|
||||
encode_float(f, &sx[p]); p += 5;
|
||||
sx[p++] = 0xF7;
|
||||
return send_sysex(sx, p);
|
||||
}
|
||||
|
||||
/* ---- outbound: reference collection ---- */
|
||||
|
||||
int send_ref_frame(uint8_t mode, uint8_t rtia_idx)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
#define CMD_START_REFS 0x30
|
||||
#define CMD_GET_REFS 0x31
|
||||
#define CMD_CLEAR_REFS 0x32
|
||||
#define CMD_SET_CL_FACTOR 0x33
|
||||
#define CMD_GET_CL_FACTOR 0x34
|
||||
#define CMD_SET_PH_CAL 0x35
|
||||
#define CMD_GET_PH_CAL 0x36
|
||||
|
||||
/* Session sync commands (0x4x) */
|
||||
#define CMD_SESSION_CREATE 0x40
|
||||
|
|
@ -56,6 +60,9 @@
|
|||
#define RSP_REF_LP_RANGE 0x21
|
||||
#define RSP_REFS_DONE 0x22
|
||||
#define RSP_REF_STATUS 0x23
|
||||
#define RSP_CL_FACTOR 0x24
|
||||
#define RSP_PH_CAL 0x25
|
||||
#define RSP_KEEPALIVE 0x50
|
||||
|
||||
/* Session sync responses (0x4x) */
|
||||
#define RSP_SESSION_CREATED 0x40
|
||||
|
|
@ -75,12 +82,14 @@ typedef struct {
|
|||
uint8_t rtia;
|
||||
uint8_t rcal;
|
||||
uint8_t electrode;
|
||||
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; } lsv;
|
||||
struct { float v_start, v_stop, scan_rate; uint8_t lp_rtia; uint16_t num_points; } lsv;
|
||||
struct { float v_hold, interval_ms, duration_s; uint8_t lp_rtia; } amp;
|
||||
struct { float v_cond, t_cond_ms, v_free, v_total, t_dep_ms, t_meas_ms; uint8_t lp_rtia; } cl;
|
||||
struct { float stabilize_s; } ph;
|
||||
struct { float v_mv; float duration_s; } clean;
|
||||
float cell_k;
|
||||
float cl_factor;
|
||||
struct { float slope; float offset; } ph_cal;
|
||||
struct { uint8_t name_len; char name[MAX_SESSION_NAME]; } session_create;
|
||||
struct { uint8_t id; } session_switch;
|
||||
struct { uint8_t id; uint8_t name_len; char name[MAX_SESSION_NAME]; } session_rename;
|
||||
|
|
@ -97,34 +106,39 @@ int protocol_init(void);
|
|||
int protocol_recv_command(Command *cmd, uint32_t timeout_ms);
|
||||
void protocol_push_command(const Command *cmd);
|
||||
|
||||
/* 7-bit decode helpers */
|
||||
/* 7-bit encode/decode helpers */
|
||||
void encode_u32(uint32_t val, uint8_t *out);
|
||||
uint32_t decode_u32(const uint8_t *d);
|
||||
float decode_float(const uint8_t *d);
|
||||
uint16_t decode_u16(const uint8_t *d);
|
||||
|
||||
/* outbound: EIS */
|
||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop);
|
||||
int send_sweep_start(uint32_t num_points, float freq_start, float freq_stop,
|
||||
uint32_t ts_ms, uint16_t meas_id);
|
||||
int send_eis_point(uint16_t index, const EISPoint *pt);
|
||||
int send_sweep_end(void);
|
||||
int send_config(const EISConfig *cfg);
|
||||
|
||||
/* outbound: LSV */
|
||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop);
|
||||
int send_lsv_start(uint32_t num_points, float v_start, float v_stop,
|
||||
uint32_t ts_ms, uint16_t meas_id);
|
||||
int send_lsv_point(uint16_t index, float v_mv, float i_ua);
|
||||
int send_lsv_end(void);
|
||||
|
||||
/* outbound: Amperometry */
|
||||
int send_amp_start(float v_hold);
|
||||
int send_amp_start(float v_hold, uint32_t ts_ms, uint16_t meas_id);
|
||||
int send_amp_point(uint16_t index, float t_ms, float i_ua);
|
||||
int send_amp_end(void);
|
||||
|
||||
/* outbound: Chlorine */
|
||||
int send_cl_start(uint32_t num_points);
|
||||
int send_cl_start(uint32_t num_points, uint32_t ts_ms, uint16_t meas_id);
|
||||
int send_cl_point(uint16_t index, float t_ms, float i_ua, uint8_t phase);
|
||||
int send_cl_result(float i_free_ua, float i_total_ua);
|
||||
int send_cl_end(void);
|
||||
|
||||
/* outbound: pH */
|
||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c);
|
||||
int send_ph_result(float v_ocp_mv, float ph, float temp_c,
|
||||
uint32_t ts_ms, uint16_t meas_id);
|
||||
|
||||
/* outbound: temperature */
|
||||
int send_temp(float temp_c);
|
||||
|
|
@ -132,12 +146,21 @@ int send_temp(float temp_c);
|
|||
/* outbound: cell constant */
|
||||
int send_cell_k(float k);
|
||||
|
||||
/* outbound: chlorine factor */
|
||||
int send_cl_factor(float f);
|
||||
|
||||
/* outbound: pH calibration */
|
||||
int send_ph_cal(float slope, float offset);
|
||||
|
||||
/* outbound: reference collection */
|
||||
int send_ref_frame(uint8_t mode, uint8_t rtia_idx);
|
||||
int send_ref_lp_range(uint8_t mode, uint8_t low_idx, uint8_t high_idx);
|
||||
int send_refs_done(void);
|
||||
int send_ref_status(uint8_t has_refs);
|
||||
|
||||
/* keepalive (sent during long blocking ops) */
|
||||
int send_keepalive(void);
|
||||
|
||||
/* session management */
|
||||
const Session *session_get_all(uint8_t *count);
|
||||
uint8_t session_get_current(void);
|
||||
|
|
|
|||
15
main/refs.c
15
main/refs.c
|
|
@ -7,6 +7,7 @@
|
|||
#include <math.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
extern const uint32_t lp_rtia_map[];
|
||||
extern const float lp_rtia_ohms[];
|
||||
|
|
@ -232,7 +233,8 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
|
|||
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||
|
||||
uint32_t n = eis_calc_num_points(&ref_cfg);
|
||||
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz);
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
send_sweep_start(n, ref_cfg.freq_start_hz, ref_cfg.freq_stop_hz, ts_ms, 0);
|
||||
|
||||
int got = eis_sweep(store->eis[r].pts, n, send_eis_point);
|
||||
store->eis[r].n_points = (uint32_t)got;
|
||||
|
|
@ -270,7 +272,11 @@ void refs_collect(RefStore *store, const EISConfig *cfg)
|
|||
ph_cfg.temp_c = temp_get();
|
||||
echem_ph_ocp(&ph_cfg, &store->ph_ref);
|
||||
store->ph_valid = 1;
|
||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
|
||||
{
|
||||
uint32_t ts_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
|
||||
ts_ms, 0);
|
||||
}
|
||||
|
||||
store->has_refs = 1;
|
||||
send_refs_done();
|
||||
|
|
@ -291,7 +297,7 @@ void refs_send(const RefStore *store)
|
|||
send_ref_frame(REF_MODE_EIS, (uint8_t)r);
|
||||
uint32_t n = store->eis[r].n_points;
|
||||
send_sweep_start(n, store->eis[r].pts[0].freq_hz,
|
||||
store->eis[r].pts[n - 1].freq_hz);
|
||||
store->eis[r].pts[n - 1].freq_hz, 0, 0);
|
||||
for (uint32_t i = 0; i < n; i++)
|
||||
send_eis_point((uint16_t)i, &store->eis[r].pts[i]);
|
||||
send_sweep_end();
|
||||
|
|
@ -306,7 +312,8 @@ void refs_send(const RefStore *store)
|
|||
|
||||
if (store->ph_valid) {
|
||||
send_ref_frame(REF_MODE_PH, 0);
|
||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c);
|
||||
send_ph_result(store->ph_ref.v_ocp_mv, store->ph_ref.ph, store->ph_ref.temp_c,
|
||||
0, 0);
|
||||
}
|
||||
|
||||
send_refs_done();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_wifi_ap_get_sta_list.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
#include "lwip/sockets.h"
|
||||
|
|
@ -13,57 +15,86 @@
|
|||
#define WIFI_SSID "EIS4"
|
||||
#define WIFI_PASS "eis4data"
|
||||
#define WIFI_CHANNEL 1
|
||||
#define WIFI_MAX_CONN 4
|
||||
#define WIFI_MAX_CONN 10
|
||||
|
||||
#define UDP_PORT 5941
|
||||
#define UDP_BUF_SIZE 128
|
||||
#define MAX_UDP_CLIENTS 4
|
||||
#define CLIENT_TIMEOUT_MS 30000
|
||||
#define UDP_CLIENTS_MAX 16
|
||||
#define REAP_THRESHOLD 10
|
||||
#define REAP_WINDOW_MS 200
|
||||
#define REAP_INTERVAL_MS 5000
|
||||
|
||||
static int udp_sock = -1;
|
||||
static esp_netif_t *ap_netif;
|
||||
|
||||
static struct {
|
||||
struct sockaddr_in addr;
|
||||
TickType_t last_seen;
|
||||
uint8_t mac[6];
|
||||
uint32_t last_touch_ms;
|
||||
bool active;
|
||||
} clients[MAX_UDP_CLIENTS];
|
||||
} clients[UDP_CLIENTS_MAX];
|
||||
|
||||
static int client_count;
|
||||
static SemaphoreHandle_t client_mutex;
|
||||
|
||||
static void client_touch(const struct sockaddr_in *addr)
|
||||
{
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||
|
||||
for (int i = 0; i < client_count; i++) {
|
||||
if (clients[i].addr.sin_addr.s_addr == addr->sin_addr.s_addr &&
|
||||
clients[i].addr.sin_port == addr->sin_port) {
|
||||
clients[i].last_seen = now;
|
||||
clients[i].last_touch_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
xSemaphoreGive(client_mutex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (client_count < MAX_UDP_CLIENTS) {
|
||||
if (client_count < UDP_CLIENTS_MAX) {
|
||||
clients[client_count].addr = *addr;
|
||||
clients[client_count].last_seen = now;
|
||||
clients[client_count].active = true;
|
||||
memset(clients[client_count].mac, 0, 6);
|
||||
|
||||
wifi_sta_list_t sta_list;
|
||||
wifi_sta_mac_ip_list_t ip_list;
|
||||
if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK &&
|
||||
esp_wifi_ap_get_sta_list_with_ip(&sta_list, &ip_list) == ESP_OK) {
|
||||
for (int j = 0; j < ip_list.num; j++) {
|
||||
if (ip_list.sta[j].ip.addr == addr->sin_addr.s_addr) {
|
||||
memcpy(clients[client_count].mac, ip_list.sta[j].mac, 6);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clients[client_count].last_touch_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
client_count++;
|
||||
printf("UDP: client added (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
|
||||
printf("UDP: client added (%d)\n", client_count);
|
||||
}
|
||||
|
||||
xSemaphoreGive(client_mutex);
|
||||
}
|
||||
|
||||
static void clients_expire(void)
|
||||
static void client_remove_by_mac(const uint8_t *mac)
|
||||
{
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
TickType_t timeout = pdMS_TO_TICKS(CLIENT_TIMEOUT_MS);
|
||||
|
||||
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||
for (int i = 0; i < client_count; ) {
|
||||
if ((now - clients[i].last_seen) > timeout) {
|
||||
if (memcmp(clients[i].mac, mac, 6) == 0) {
|
||||
clients[i] = clients[--client_count];
|
||||
printf("UDP: client expired (%d/%d)\n", client_count, MAX_UDP_CLIENTS);
|
||||
printf("UDP: client removed by MAC (%d)\n", client_count);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(client_mutex);
|
||||
}
|
||||
|
||||
/* caller must hold client_mutex */
|
||||
static void client_remove_by_index(int idx)
|
||||
{
|
||||
if (idx < 0 || idx >= client_count) return;
|
||||
printf("REAP: removing client %d\n", idx);
|
||||
clients[idx] = clients[--client_count];
|
||||
}
|
||||
|
||||
static void parse_udp_sysex(const uint8_t *data, uint16_t len)
|
||||
|
|
@ -106,6 +137,8 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
|
|||
cmd.lsv.v_stop = decode_float(&data[8]);
|
||||
cmd.lsv.scan_rate = decode_float(&data[13]);
|
||||
cmd.lsv.lp_rtia = data[18];
|
||||
if (len >= 22)
|
||||
cmd.lsv.num_points = decode_u16(&data[19]);
|
||||
break;
|
||||
case CMD_START_AMP:
|
||||
if (len < 19) return;
|
||||
|
|
@ -137,6 +170,15 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
|
|||
if (len < 8) return;
|
||||
cmd.cell_k = decode_float(&data[3]);
|
||||
break;
|
||||
case CMD_SET_CL_FACTOR:
|
||||
if (len < 8) return;
|
||||
cmd.cl_factor = decode_float(&data[3]);
|
||||
break;
|
||||
case CMD_SET_PH_CAL:
|
||||
if (len < 13) return;
|
||||
cmd.ph_cal.slope = decode_float(&data[3]);
|
||||
cmd.ph_cal.offset = decode_float(&data[8]);
|
||||
break;
|
||||
case CMD_SESSION_CREATE:
|
||||
if (len < 5) return;
|
||||
cmd.session_create.name_len = data[3] & 0x7F;
|
||||
|
|
@ -163,6 +205,8 @@ static void parse_udp_sysex(const uint8_t *data, uint16_t len)
|
|||
case CMD_STOP_AMP:
|
||||
case CMD_GET_TEMP:
|
||||
case CMD_GET_CELL_K:
|
||||
case CMD_GET_CL_FACTOR:
|
||||
case CMD_GET_PH_CAL:
|
||||
case CMD_START_REFS:
|
||||
case CMD_GET_REFS:
|
||||
case CMD_CLEAR_REFS:
|
||||
|
|
@ -191,16 +235,49 @@ static void udp_rx_task(void *param)
|
|||
if (n <= 0) continue;
|
||||
|
||||
client_touch(&src);
|
||||
clients_expire();
|
||||
parse_udp_sysex(buf, (uint16_t)n);
|
||||
}
|
||||
}
|
||||
|
||||
static void udp_reaper_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
vTaskDelay(pdMS_TO_TICKS(REAP_INTERVAL_MS));
|
||||
|
||||
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||
if (client_count < REAP_THRESHOLD) {
|
||||
xSemaphoreGive(client_mutex);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t cutoff = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||
printf("REAP: cycle start, %d clients\n", client_count);
|
||||
xSemaphoreGive(client_mutex);
|
||||
|
||||
send_keepalive();
|
||||
vTaskDelay(pdMS_TO_TICKS(REAP_WINDOW_MS));
|
||||
|
||||
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||
int reaped = 0;
|
||||
for (int i = client_count - 1; i >= 0; i--) {
|
||||
if (clients[i].last_touch_ms < cutoff) {
|
||||
client_remove_by_index(i);
|
||||
reaped++;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(client_mutex);
|
||||
|
||||
if (reaped) printf("REAP: removed %d zombie(s), %d remain\n", reaped, client_count);
|
||||
}
|
||||
}
|
||||
|
||||
int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
|
||||
{
|
||||
if (udp_sock < 0 || client_count == 0)
|
||||
return -1;
|
||||
|
||||
xSemaphoreTake(client_mutex, portMAX_DELAY);
|
||||
int sent = 0;
|
||||
for (int i = 0; i < client_count; i++) {
|
||||
int r = sendto(udp_sock, sysex, len, 0,
|
||||
|
|
@ -208,6 +285,7 @@ int wifi_send_sysex(const uint8_t *sysex, uint16_t len)
|
|||
sizeof(clients[i].addr));
|
||||
if (r > 0) sent++;
|
||||
}
|
||||
xSemaphoreGive(client_mutex);
|
||||
return sent > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
|
|
@ -219,12 +297,15 @@ int wifi_get_client_count(void)
|
|||
static void wifi_event_handler(void *arg, esp_event_base_t base,
|
||||
int32_t id, void *data)
|
||||
{
|
||||
(void)arg; (void)data;
|
||||
(void)arg;
|
||||
if (base == WIFI_EVENT) {
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED)
|
||||
if (id == WIFI_EVENT_AP_STACONNECTED) {
|
||||
printf("WiFi: station connected\n");
|
||||
else if (id == WIFI_EVENT_AP_STADISCONNECTED)
|
||||
} else if (id == WIFI_EVENT_AP_STADISCONNECTED) {
|
||||
wifi_event_ap_stadisconnected_t *evt = data;
|
||||
printf("WiFi: station disconnected\n");
|
||||
client_remove_by_mac(evt->mac);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +331,7 @@ static void sta_event_handler(void *arg, esp_event_base_t base,
|
|||
|
||||
static int wifi_ap_init(void)
|
||||
{
|
||||
esp_netif_create_default_wifi_ap();
|
||||
ap_netif = esp_netif_create_default_wifi_ap();
|
||||
esp_netif_create_default_wifi_sta();
|
||||
|
||||
wifi_init_config_t wifi_cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
|
|
@ -290,6 +371,9 @@ static int wifi_ap_init(void)
|
|||
err = esp_wifi_start();
|
||||
if (err) return err;
|
||||
|
||||
esp_wifi_set_ps(WIFI_PS_NONE);
|
||||
esp_wifi_set_inactive_time(WIFI_IF_AP, 120);
|
||||
|
||||
if (strlen(STA_SSID) > 0) {
|
||||
esp_wifi_connect();
|
||||
printf("WiFi: STA connecting to \"%s\"\n", STA_SSID);
|
||||
|
|
@ -322,6 +406,8 @@ static int udp_init(void)
|
|||
|
||||
int wifi_transport_init(void)
|
||||
{
|
||||
client_mutex = xSemaphoreCreateMutex();
|
||||
|
||||
int rc = wifi_ap_init();
|
||||
if (rc) {
|
||||
printf("WiFi: AP init failed: %d\n", rc);
|
||||
|
|
@ -335,5 +421,6 @@ int wifi_transport_init(void)
|
|||
}
|
||||
|
||||
xTaskCreate(udp_rx_task, "udp_rx", 4096, NULL, 5, NULL);
|
||||
xTaskCreate(udp_reaper_task, "reaper", 2048, NULL, 4, NULL);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
CONFIG_IDF_TARGET="esp32s3"
|
||||
CONFIG_LWIP_DHCPS_MAX_STATION_NUM=12
|
||||
CONFIG_ESP_WIFI_SLP_IRAM_OPT=n
|
||||
|
|
|
|||
Loading…
Reference in New Issue