Skip to content

Commit fc3fb3b

Browse files
committed
Corrected SOC
1 parent 1aae3a1 commit fc3fb3b

19 files changed

Lines changed: 1553 additions & 287 deletions

pecan/src/assets/dbc.dbc

Lines changed: 153 additions & 91 deletions
Large diffs are not rendered by default.

pecan/src/assets/example.dbc

Lines changed: 153 additions & 91 deletions
Large diffs are not rendered by default.

pecan/src/assets/wcars.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,3 +706,23 @@
706706
.wcars-cool-peak--warning .wcars-cool-peak-val { fill: var(--wcars-red); }
707707
.wcars-cool-peak--caution .wcars-cool-peak-val { fill: var(--wcars-amber); }
708708
.wcars-cool-peak--missing .wcars-cool-peak-val { fill: var(--wcars-dim); }
709+
710+
/* ============================================================
711+
* ELEC HEALTH PANEL
712+
* MEAS = computed pack V from 100 cell readings
713+
* PRED = OCV(SoC) × 100
714+
* SAG = I × R₀(SoC, T) × 100
715+
* Δ = MEAS − PRED (small when SoC is well-tracked, ≈ −SAG under load)
716+
* ============================================================ */
717+
.wcars-health-panel {
718+
fill: #0a0e0a;
719+
stroke: var(--wcars-bezel);
720+
stroke-width: 1.5;
721+
}
722+
.wcars-schem-head--small { font-size: 13px; }
723+
.wcars-health-lbl { fill: var(--wcars-dim); font-size: 12px; letter-spacing: 0.1em; }
724+
.wcars-health-val { fill: var(--wcars-cyan); font-size: 18px; font-weight: 700; }
725+
.wcars-health-cell--ok .wcars-health-val { fill: var(--wcars-green); }
726+
.wcars-health-cell--drift .wcars-health-val { fill: var(--wcars-amber); }
727+
.wcars-health-cell--bad .wcars-health-val { fill: var(--wcars-red); }
728+
.wcars-health-cell--missing .wcars-health-val { fill: var(--wcars-dim); }

pecan/src/assets/wcars/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# WFR25 cell-level characterisation data
2+
3+
These two CSVs are the **per-cell** characterisation measurements for the WFR25 Li-ion cells, used by `pecan/src/lib/wcars/batteryModel.ts` to predict pack voltage and sag from SoC / current / temperature.
4+
5+
## `ocv.csv` — Open-Circuit Voltage vs SoC (per cell)
6+
7+
- Format: `SoC,OCV,Temperature_C` — 3 columns, 57 rows
8+
- Tested at **~25 °C** (all rows in 24.9–25.5 °C range)
9+
- SoC range: 0.027 → 0.949 (charge direction; upper end not measured)
10+
- OCV range: 3.115 V (empty) → 4.113 V (full)
11+
- Model applies this curve at the **per-cell** level; multiply by 100 to get the 100S pack voltage
12+
13+
## `dcir.csv` — DC Internal Resistance (R₀) vs SoC and Temperature
14+
15+
- Format: `SoC,R0_Ohms,Temperature_C` — 3 columns, 400 rows
16+
- SoC range: 0.0 → 1.0 across multiple temperature sweeps
17+
- Temperature range: 5 °C → 46 °C
18+
- R₀ range: 0.014 Ω → 0.045 Ω (U-shaped, rises at low SoC and low T)
19+
- Model applies at the **per-cell** level; for pack sag multiply by 100 (Nseries) and by current
20+
21+
## Caveats
22+
23+
- OCV is at 25 °C only. Outside 20–30 °C the OCV shifts and the prediction drifts.
24+
- DCIR is charge-direction. Discharging R₀ is similar for Li-ion but technically different.
25+
- The OCV top end is cut at SoC 0.949; the model clamps to that max.
26+
- Per-cell R₀ at 5 °C is ~3× the 25 °C value — the cold-launch sag is real and this curve captures it.

pecan/src/assets/wcars/dcir.csv

Lines changed: 401 additions & 0 deletions
Large diffs are not rendered by default.

pecan/src/assets/wcars/ocv.csv

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
SoC,OCV,Temperature_C
2+
0.949441155,4.112568,25.0730074997576
3+
0.898882804583333,4.08914,25.2224371927442
4+
0.848326098846154,4.075304,25.1125783107126
5+
0.797767411217949,4.04014,25.1240428848861
6+
0.74721096150641,3.983798,25.2364327436034
7+
0.696652248589744,3.933988,25.2867104046243
8+
0.646096185288461,3.872916,25.0910365911297
9+
0.595538091891026,3.831389,25.0183450492312
10+
0.544981135096154,3.794442,24.931093100621
11+
0.494422560801282,3.751848,25.3304746093826
12+
0.443866357948718,3.706408,25.0170699174797
13+
0.393307240544872,3.65651,25.0943275972006
14+
0.342751085128205,3.592955,25.2384024751897
15+
0.292201586153846,3.533428,25.1475692946663
16+
0.241645315032051,3.500232,25.0233096
17+
0.191097658269231,3.44368,25.1058821650085
18+
0.140540787275641,3.349463,25.4357211786444
19+
0.0899976515384615,3.238125,25.3640653294461
20+
0.0394412312179488,3.114964,25.3305286031808
21+
0.948795729935897,4.112796,25.2531333139647
22+
0.897592930128205,4.090749,25.0793494095977
23+
0.846388075160256,4.075417,25.3233413497675
24+
0.795185212532051,4.040464,25.0069851153324
25+
0.743980927179487,3.983918,24.9813134191016
26+
0.692778928269231,3.93419,25.0109358515284
27+
0.64157444400641,3.873056,25.5104800697269
28+
0.590372041923077,3.831623,25.1210084253796
29+
0.539167715032051,3.794666,25.1716395053482
30+
0.487965340865385,3.752476,24.9788216669894
31+
0.436760449358974,3.706247,25.01844500097
32+
0.385557716666667,3.656322,25.2674099679798
33+
0.334353490961539,3.59326,25.4435833462714
34+
0.283151390288462,3.533605,25.0413230777071
35+
0.231946716410257,3.500152,25.1843959756216
36+
0.180743275480769,3.443841,25.4788236902513
37+
0.129538854935898,3.349139,25.3219511161212
38+
0.0783366789102565,3.238652,25.1716273434241
39+
0.0271323385897438,3.115281,25.3387681009161
40+
0.948956929967949,4.112951,25.3403924339834
41+
0.897916493910256,4.089418,25.0556577166885
42+
0.846873762948718,4.075494,25.437622478533
43+
0.795833198910256,4.040914,25.1991433490268
44+
0.744790285448718,3.984434,25.0931915020396
45+
0.693749365224359,3.934646,25.0600740183468
46+
0.642706225641026,3.873164,25.100785657154
47+
0.591665724935897,3.832356,25.0190242886928
48+
0.540622655897436,3.795495,24.924068630157
49+
0.489581871634615,3.753245,24.9665019379431
50+
0.438539115288462,3.707781,25.0878858825815
51+
0.387498224102564,3.657386,25.0455137103502
52+
0.336455413397436,3.594391,25.3477768372093
53+
0.285414725641026,3.534549,25.1099776343302
54+
0.234371710320513,3.500884,25.4030318142045
55+
0.183330784615385,3.445307,25.0543110914655
56+
0.132287801634615,3.35199,25.4076238147788
57+
0.0812471087179487,3.241054,25.3614057161617
58+
0.0302041074679487,3.119354,25.3339586303502

pecan/src/components/wcars/sd/ElecSchematic.tsx

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
import type { SdValue } from "../../../lib/wcars/sdSignals";
22
import { useSdValue } from "../../../lib/wcars/sdSignals";
3+
import { usePackAggregates, usePackTemp } from "../../../lib/wcars/packAggregates";
4+
import { predictedPackV, predictedSagV } from "../../../lib/wcars/batteryModel";
5+
6+
/** Health of the battery model: how closely the measured (summed) pack
7+
* voltage tracks the OCV-predicted value at the current SoC. A small
8+
* delta is normal during current draw (== sag). At rest, the delta
9+
* should be ≈ 0. */
10+
type Health = "ok" | "drift" | "bad" | "missing";
11+
function healthFromDelta(deltaV: number): Health {
12+
const ad = Math.abs(deltaV);
13+
if (ad < 2) return "ok";
14+
if (ad < 8) return "drift";
15+
return "bad";
16+
}
317

418
/** A telemetry readout pinned to a schematic coordinate.
519
* label sits above, value + unit below, coloured by status. */
@@ -31,21 +45,28 @@ function Readout({
3145
}
3246

3347
export function ElecSchematic() {
34-
const v = useSdValue("packV");
48+
const pack = usePackAggregates();
49+
// Synthetic SdValue for the computed pack voltage, so the existing
50+
// EcamValueBox status logic still works.
51+
const packVsd: SdValue = {
52+
value: pack.cellCount > 0 ? pack.packVoltage : null,
53+
label: "",
54+
unit: "V",
55+
status: pack.cellCount > 0 ? "normal" : "missing",
56+
};
3557
const a = useSdValue("packA");
3658
const soc = useSdValue("packSoc");
3759
const dcl = useSdValue("packDcl");
3860
const ccl = useSdValue("packCcl");
3961
const busV = useSdValue("busV");
4062
const busA = useSdValue("busA");
41-
const hv = useSdValue("hvActive");
42-
43-
// Bus is energized per the real HV_Active flag from the safety loop;
44-
// fall back to DC bus voltage only if that frame hasn't arrived.
45-
const energized =
46-
hv.status !== "missing"
47-
? (hv.value ?? 0) >= 0.5
48-
: busV.status !== "missing" && (busV.value ?? 0) > 50;
63+
const airPos = useSdValue("airPos");
64+
const airNeg = useSdValue("airNeg");
65+
66+
// Bus is energized when both halves of the AIR contactor pair are closed.
67+
const airPosClosed = airPos.status !== "missing" && (airPos.value ?? 0) >= 1;
68+
const airNegClosed = airNeg.status !== "missing" && (airNeg.value ?? 0) >= 0.5;
69+
const energized = airPosClosed && airNegClosed;
4970
const flow = energized ? " wcars-flow--on" : "";
5071

5172
// SoC bar fill (bottom-up).
@@ -63,10 +84,30 @@ export function ElecSchematic() {
6384
// Switch arm endpoint: closed (horizontal) when energized, else lifted.
6485
const armEnd = energized ? { x: 510, y: 290 } : { x: 505, y: 266 };
6586

87+
// OCV model: predict the open-circuit pack voltage at the current SoC,
88+
// and the expected sag at the current draw + pack temperature.
89+
const packT = usePackTemp();
90+
const socFrac2 = (soc.value ?? 0) / 100;
91+
const hasSoc = soc.status !== "missing" && soc.value !== null;
92+
const hasCells = pack.cellCount > 0;
93+
const hasTemp = packT.tempC !== null;
94+
const hasCurrent = a.status !== "missing" && a.value !== null;
95+
const predV = hasSoc ? predictedPackV(socFrac2) : null;
96+
const sagV =
97+
hasSoc && hasCurrent && hasTemp
98+
? predictedSagV(a.value ?? 0, socFrac2, packT.tempC ?? 25)
99+
: null;
100+
const measV = hasCells ? pack.packVoltage : null;
101+
// At rest (low current), the measured pack V should ≈ OCV-predicted. Under
102+
// load, the difference ≈ sag. We surface the raw delta for honesty; the
103+
// HEALTH field classifies the magnitude.
104+
const deltaV = measV !== null && predV !== null ? measV - predV : null;
105+
const health: Health = deltaV === null ? "missing" : healthFromDelta(deltaV);
106+
66107
return (
67108
<div className="wcars-syn wcars-syn-elec wcars-elec-schem" data-testid="syn-elec">
68109
<div className="wcars-syn-title">ELEC / BATT</div>
69-
<svg viewBox="0 0 1000 540" className="wcars-schem-svg" role="img" aria-label="Electrical synoptic">
110+
<svg viewBox="0 0 1000 540" className="wcars-schem-svg" role="img" aria-label="Electrical synoptic" preserveAspectRatio="xMidYMid meet">
70111
{/* ---- HV BATTERY PACK ---- */}
71112
<rect className="wcars-schem-box" x={70} y={150} width={310} height={270} rx={10} />
72113
<text className="wcars-schem-head" x={225} y={184} textAnchor="middle">HV BATT</text>
@@ -80,14 +121,14 @@ export function ElecSchematic() {
80121
<Readout x={BAR_X + BAR_W / 2} y={BAR_Y + BAR_H + 34} label="SOC" sd={soc} />
81122

82123
{/* Pack readouts */}
83-
<Readout x={280} y={232} label="HV" sd={v} />
124+
<Readout x={280} y={232} label="HV" sd={packVsd} decimals={1} />
84125
<Readout x={280} y={322} label="CURR" sd={a} />
85126

86127
{/* Positive terminal nub */}
87128
<rect className="wcars-schem-term" x={380} y={272} width={16} height={36} />
88129

89-
{/* ---- AIR (accumulator isolation relay), closed when HV_Active ---- */}
90-
<text className="wcars-schem-tag" x={480} y={252} textAnchor="middle">AIR</text>
130+
{/* ---- AIR (accumulator isolation relay pair), closed when both halves are made ---- */}
131+
<text className="wcars-schem-tag" x={480} y={252} textAnchor="middle">AIR+ / AIR-</text>
91132
<line className={`wcars-flow${flow}`} x1={396} y1={290} x2={450} y2={290} />
92133
<circle className="wcars-schem-node" cx={450} cy={290} r={6} />
93134
<line
@@ -116,6 +157,40 @@ export function ElecSchematic() {
116157
{/* ---- CURRENT LIMITS (envelope) ---- */}
117158
<Readout x={160} y={500} label="DISCH LIM" sd={dcl} anchor="middle" />
118159
<Readout x={300} y={500} label="CHG LIM" sd={ccl} anchor="middle" />
160+
161+
{/* ---- HEALTH PANEL: MEAS vs PRED vs SAG ---- */}
162+
<rect className="wcars-health-panel" x={410} y={445} width={530} height={85} rx={6} />
163+
<text className="wcars-schem-head wcars-schem-head--small" x={420} y={463}>BATTERY HEALTH (100S)</text>
164+
<text className="wcars-schem-tag" x={420} y={478} textAnchor="start">
165+
cell Δ {hasCells ? `${(((pack.maxVoltage ?? 0) - (pack.minVoltage ?? 0)) * 1000).toFixed(0)} mV · ${pack.cellCount} cells` : "—"}
166+
</text>
167+
<text className={`wcars-health-cell wcars-health-cell--${health}`} x={490} y={505} textAnchor="middle">
168+
<tspan className="wcars-health-lbl" x={490} dy={0}>MEAS</tspan>
169+
<tspan className="wcars-health-val" x={490} dy={20}>
170+
{measV === null ? "XX" : `${measV.toFixed(1)} V`}
171+
</tspan>
172+
</text>
173+
<text className="wcars-health-cell" x={610} y={505} textAnchor="middle">
174+
<tspan className="wcars-health-lbl" x={610} dy={0}>PRED</tspan>
175+
<tspan className="wcars-health-val" x={610} dy={20}>
176+
{predV === null ? "XX" : `${predV.toFixed(1)} V`}
177+
</tspan>
178+
</text>
179+
<text className="wcars-health-cell" x={730} y={505} textAnchor="middle">
180+
<tspan className="wcars-health-lbl" x={730} dy={0}>SAG</tspan>
181+
<tspan className="wcars-health-val" x={730} dy={20}>
182+
{sagV === null ? "XX" : `${sagV.toFixed(1)} V`}
183+
</tspan>
184+
</text>
185+
<text className="wcars-health-cell" x={850} y={505} textAnchor="middle">
186+
<tspan className="wcars-health-lbl" x={850} dy={0}>Δ</tspan>
187+
<tspan className="wcars-health-val" x={850} dy={20}>
188+
{deltaV === null ? "XX" : `${deltaV >= 0 ? "+" : ""}${deltaV.toFixed(1)} V`}
189+
</tspan>
190+
</text>
191+
<text className="wcars-schem-tag" x={930} y={463} textAnchor="end">
192+
T {packT.tempC === null ? "XX" : `${packT.tempC.toFixed(1)}°C`} · {packT.sensorsRead}/{packT.totalSensors} thm
193+
</text>
119194
</svg>
120195
</div>
121196
);

pecan/src/components/wcars/sd/ElecSynoptic.test.tsx

Lines changed: 0 additions & 18 deletions
This file was deleted.

pecan/src/components/wcars/sd/ElecSynoptic.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.

pecan/src/components/wcars/sd/SafetySynoptic.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,30 @@ import { SafetySynoptic } from "./SafetySynoptic";
66
describe("SafetySynoptic", () => {
77
beforeEach(() => clearSignals());
88

9-
it("shows every contact CLOSED and HV ACTIVE on a healthy loop", () => {
9+
it("shows every contact CLOSED and AIR+ CLOSED on a healthy loop", () => {
1010
setSignal("0x420", "IMDRelay", 1);
1111
setSignal("0x420", "AMSRelay", 1);
1212
setSignal("0x420", "BSPDRelay", 1);
1313
setSignal("0x420", "LatchRelay", 1);
14-
setSignal("0x420", "Safetyloop_return", 1);
15-
setSignal("0x420", "HV_Active", 1);
14+
setSignal("0x420", "AIR_Positive_Relay", 1);
15+
setSignal("0x420", "AIR_Negative_Relay", 1);
1616
setSignal("0x420", "PackStatus", 3, "Active");
1717
render(<SafetySynoptic />);
1818
expect(screen.getAllByText("CLOSED")).toHaveLength(4);
19-
expect(screen.getByText("HV ACTIVE")).toBeTruthy();
19+
expect(screen.getByText("AIR+/– CLOSED")).toBeTruthy();
2020
expect(screen.getByText("Active")).toBeTruthy();
2121
});
2222

23-
it("shows a tripped contact OPEN and HV OFF when the loop is broken", () => {
23+
it("shows a tripped contact OPEN and AIR OPEN when the loop is broken", () => {
2424
setSignal("0x420", "IMDRelay", 1);
2525
setSignal("0x420", "AMSRelay", 1);
2626
setSignal("0x420", "BSPDRelay", 0); // BSPD tripped
2727
setSignal("0x420", "LatchRelay", 1);
28-
setSignal("0x420", "Safetyloop_return", 0);
29-
setSignal("0x420", "HV_Active", 0);
28+
setSignal("0x420", "AIR_Positive_Relay", 0);
29+
setSignal("0x420", "AIR_Negative_Relay", 0);
3030
render(<SafetySynoptic />);
3131
expect(screen.getByText("OPEN")).toBeTruthy();
32-
expect(screen.getByText("HV OFF")).toBeTruthy();
32+
expect(screen.getByText("AIR OPEN")).toBeTruthy();
3333
});
3434

3535
it("shows XX placeholders when the safety frame is missing", () => {

0 commit comments

Comments
 (0)