11#!/usr/bin/env python3
2- """BMS serial logger — streams HVC output to CSV and a live cell-voltage scatter plot."""
2+ """BMS serial logger — streams HVC output to CSV and a live cell-voltage plot.
3+
4+ Works in two modes automatically:
5+ - Pack-only: min/max lines from the Pack summary (always available)
6+ - Full: per-cell scatter plot when BMB lines are also present
7+ """
38from __future__ import annotations
49
510import re
3742RE_CELL = re .compile (r'(\d+\.\d+)(\*?)' )
3843RE_ANSI = re .compile (r'\x1b\[[0-9;]*m' )
3944
45+ PACK_HEADER = [
46+ 'timestamp_ms' , 'pack_v' ,
47+ 'fault_live' , 'fault_latched' ,
48+ 'cell_min_v' , 'cell_max_v' , 'cell_dv_mv' ,
49+ 'temp_min_c' , 'temp_max_c' , 'die_temp_max_c' ,
50+ 'bms_resp' , 'bms_disc' , 'bms_uv' , 'bms_ov' , 'bms_ot' ,
51+ 'state' , 'shutdown' , 'bal_cnt' ,
52+ ]
53+
4054
4155def select_port () -> str :
4256 ports = serial .tools .list_ports .comports ()
@@ -63,19 +77,22 @@ def __init__(self, port: str, debug: bool = False, resume: bool = False):
6377 self ._debug = debug
6478 self .ser = serial .Serial (port , BAUD_RATE , timeout = 1 )
6579
66- # Snapshot accumulator — only touched from the reader thread
67- self ._pack_data : dict | None = None
68- self ._bmbs : dict [int , dict ] = {}
80+ # Per-cell scatter (populated only when BMB lines arrive)
81+ self ._cell_times : list [float ] = []
82+ self ._cell_volts : list [float ] = []
83+ self ._cell_bals : list [bool ] = []
84+
85+ # Pack-level min/max (always populated)
86+ self ._pack_times : list [float ] = []
87+ self ._pack_min_v : list [float ] = []
88+ self ._pack_max_v : list [float ] = []
89+
90+ self ._lock = threading .Lock ()
91+ self ._t0_ms : int | None = None
6992
70- # Plot data — guarded by _lock
71- self ._lock = threading .Lock ()
72- self ._times : list [float ] = []
73- self ._volts : list [float ] = []
74- self ._bals : list [bool ] = []
75- self ._min_times : list [float ] = []
76- self ._min_volts : list [float ] = []
77- self ._max_volts : list [float ] = []
78- self ._t0_ms : int | None = None
93+ # BMB accumulator (per pack cycle, reader-thread only)
94+ self ._pending_pack : dict | None = None
95+ self ._bmbs : dict [int , dict ] = {}
7996
8097 out_dir = Path (__file__ ).parent / 'out'
8198 out_dir .mkdir (exist_ok = True )
@@ -95,80 +112,58 @@ def __init__(self, port: str, debug: bool = False, resume: bool = False):
95112 csv_path = out_dir / f"{ datetime .now ().strftime ('%Y-%m-%d_%H%M%S' )} .csv"
96113 self ._csvf = open (csv_path , 'w' , newline = '' )
97114 self ._writer = csv .writer (self ._csvf )
98- self ._write_header ()
115+ self ._writer .writerow (PACK_HEADER )
116+ self ._csvf .flush ()
99117 print (f"Logging → { csv_path } " )
100118
101119 def _load_existing (self , path : Path ) -> None :
102- n_cells = TOTAL_IC * CELLS_PER_IC
103120 with open (path , newline = '' ) as f :
104121 reader = csv .reader (f )
105122 header = next (reader )
106- ts_col = header .index ('timestamp_ms' )
107- v_start = header .index ('cell_1_1_v ' )
108- b_start = header .index ('cell_1_1_bal ' )
123+ ts_col = header .index ('timestamp_ms' )
124+ cmin_col = header .index ('cell_min_v ' )
125+ cmax_col = header .index ('cell_max_v ' )
109126 rows = list (reader )
110127 if not rows :
111128 return
112129 self ._t0_ms = int (rows [0 ][ts_col ])
113130 for row in rows :
114131 t_s = (int (row [ts_col ]) - self ._t0_ms ) / 1000.0
115- for i in range (n_cells ):
116- self ._times .append (t_s )
117- self ._volts .append (float (row [v_start + i ]))
118- self ._bals .append (bool (int (row [b_start + i ])))
119- print (f" loaded { len (rows )} existing rows ({ len (self ._times )} points)" )
120-
121- def _write_header (self ) -> None :
122- cell_v = [f'cell_{ ic } _{ c } _v' for ic in range (1 , TOTAL_IC + 1 ) for c in range (1 , CELLS_PER_IC + 1 )]
123- cell_b = [f'cell_{ ic } _{ c } _bal' for ic in range (1 , TOTAL_IC + 1 ) for c in range (1 , CELLS_PER_IC + 1 )]
124- die_t = [f'die_temp_{ ic } _c' for ic in range (1 , TOTAL_IC + 1 )]
125- self ._writer .writerow ([
126- 'timestamp_ms' , 'pack_v' ,
127- 'fault_live' , 'fault_latched' ,
128- 'cell_min_v' , 'cell_max_v' , 'cell_dv_mv' ,
129- 'temp_min_c' , 'temp_max_c' , 'die_temp_max_c' ,
130- 'bms_resp' , 'bms_disc' , 'bms_uv' , 'bms_ov' , 'bms_ot' ,
131- 'state' , 'shutdown' , 'bal_cnt' ,
132- * cell_v , * cell_b , * die_t ,
133- ])
134- self ._csvf .flush ()
135-
136- def _emit (self ) -> None :
137- p = self ._pack_data
138- cell_vs , cell_bs , die_ts = [], [], []
139- for ic in range (1 , TOTAL_IC + 1 ):
140- for v , b in self ._bmbs [ic ]['cells' ]:
141- cell_vs .append (v )
142- cell_bs .append (1 if b else 0 )
143- die_ts .append (self ._bmbs [ic ]['die' ])
144-
145- self ._writer .writerow ([
146- p ['ts' ], p ['v' ],
147- p ['fl' ], p ['fla' ],
148- p ['cmin' ], p ['cmax' ], p ['cdv' ],
149- p ['tmin' ], p ['tmax' ], p ['dtmax' ],
150- p ['resp' ], p ['disc' ], p ['uv' ], p ['ov' ], p ['ot' ],
151- p ['state' ], p ['sd' ], p ['bal' ],
152- * cell_vs , * cell_bs , * die_ts ,
153- ])
154- self ._csvf .flush ()
132+ self ._pack_times .append (t_s )
133+ self ._pack_min_v .append (float (row [cmin_col ]))
134+ self ._pack_max_v .append (float (row [cmax_col ]))
135+ print (f" loaded { len (rows )} existing rows" )
136+
137+ def _t_s (self , ts_ms : int ) -> float :
138+ if self ._t0_ms is None :
139+ self ._t0_ms = ts_ms
140+ return (ts_ms - self ._t0_ms ) / 1000.0
155141
156142 def handle_line (self , line : str ) -> None :
157143 m = RE_PACK .search (line )
158144 if m :
159- if int (m .group ('state' )) == 2 :
160- # No BMB lines follow in State 2 — record min/max from pack summary directly
161- ts_ms = int (m .group ('ts' ))
162- if self ._t0_ms is None :
163- self ._t0_ms = ts_ms
164- t_s = (ts_ms - self ._t0_ms ) / 1000.0
165- with self ._lock :
166- self ._min_times .append (t_s )
167- self ._min_volts .append (float (m .group ('cmin' )))
168- self ._max_volts .append (float (m .group ('cmax' )))
169- else :
170- self ._pack_data = m .groupdict ()
171- self ._bmbs = {}
145+ ts_ms = int (m .group ('ts' ))
146+ t_s = self ._t_s (ts_ms )
147+
148+ # Always write a CSV row and update pack-level plot data
149+ p = m .groupdict ()
150+ self ._writer .writerow ([
151+ p ['ts' ], p ['v' ],
152+ p ['fl' ], p ['fla' ],
153+ p ['cmin' ], p ['cmax' ], p ['cdv' ],
154+ p ['tmin' ], p ['tmax' ], p ['dtmax' ],
155+ p ['resp' ], p ['disc' ], p ['uv' ], p ['ov' ], p ['ot' ],
156+ p ['state' ], p ['sd' ], p ['bal' ],
157+ ])
158+ self ._csvf .flush ()
159+
160+ with self ._lock :
161+ self ._pack_times .append (t_s )
162+ self ._pack_min_v .append (float (m .group ('cmin' )))
163+ self ._pack_max_v .append (float (m .group ('cmax' )))
164+
165+ self ._pending_pack = m .groupdict ()
166+ self ._bmbs = {}
172167 return
173168
174169 m = RE_BMB .search (line )
@@ -177,26 +172,20 @@ def handle_line(self, line: str) -> None:
177172
178173 ic = int (m .group ('ic' ))
179174 ts_ms = int (m .group ('ts' ))
180- cells = [(float (v ), bool (star )) for v , star in RE_CELL .findall (m .group ('cells' )) if 2.5 <= float (v ) <= 4.3 ]
175+ t_s = self ._t_s (ts_ms )
176+ cells = [(float (v ), bool (star )) for v , star in RE_CELL .findall (m .group ('cells' ))
177+ if 2.5 <= float (v ) <= 4.3 ]
181178 self ._bmbs [ic ] = {'die' : float (m .group ('die' )), 'cells' : cells }
182179
183- if self ._t0_ms is None :
184- self ._t0_ms = ts_ms
185- t_s = (ts_ms - self ._t0_ms ) / 1000.0
186180 with self ._lock :
187181 for v , b in cells :
188- self ._times .append (t_s )
189- self ._volts .append (v )
190- self ._bals .append (b )
191-
192- if self ._pack_data is not None and len (self ._bmbs ) == TOTAL_IC :
193- self ._emit ()
194- self ._pack_data = None
195- self ._bmbs = {}
182+ self ._cell_times .append (t_s )
183+ self ._cell_volts .append (v )
184+ self ._cell_bals .append (b )
196185
197186 def run (self ) -> None :
198187 print ("Streaming serial... (close the plot or Ctrl+C to stop)" )
199- snaps = 0
188+ rows = 0
200189 try :
201190 while True :
202191 raw = self .ser .readline ()
@@ -207,25 +196,25 @@ def run(self) -> None:
207196 continue
208197 if self ._debug :
209198 print (f"RX: { repr (line )} " )
210- prev_snaps = snaps
199+ prev = rows
211200 self .handle_line (line )
212201 with self ._lock :
213- snaps = len (self ._times ) // ( TOTAL_IC * CELLS_PER_IC ) + len ( self . _min_times )
214- if snaps != prev_snaps :
215- print (f" snapshot #{ snaps } " , flush = True )
202+ rows = len (self ._pack_times )
203+ if rows != prev :
204+ print (f" row #{ rows } " , flush = True )
216205 except KeyboardInterrupt :
217206 pass
218207 finally :
219208 self ._csvf .close ()
220209 self .ser .close ()
221210
222- def plot_snapshot (self ) -> tuple [list , list , list ]:
211+ def pack_snapshot (self ) -> tuple [list , list , list ]:
223212 with self ._lock :
224- return list (self ._times ), list (self ._volts ), list (self ._bals )
213+ return list (self ._pack_times ), list (self ._pack_min_v ), list (self ._pack_max_v )
225214
226- def minmax_snapshot (self ) -> tuple [list , list , list ]:
215+ def cell_snapshot (self ) -> tuple [list , list , list ]:
227216 with self ._lock :
228- return list (self ._min_times ), list (self ._min_volts ), list (self ._max_volts )
217+ return list (self ._cell_times ), list (self ._cell_volts ), list (self ._cell_bals )
229218
230219
231220def main () -> None :
@@ -243,37 +232,36 @@ def main() -> None:
243232 ax .set_xlabel ('Time (s)' )
244233 ax .set_ylabel ('Cell Voltage (V)' )
245234 ax .set_title ('Live Cell Voltages' )
246- sc_idle = ax .scatter ([], [], s = 2 , c = 'steelblue' , linewidths = 0 , label = 'idle' )
247- sc_bal = ax .scatter ([], [], s = 2 , c = 'tomato' , linewidths = 0 , label = 'balancing' )
248- ln_min , = ax .plot ([], [], '-' , color = 'royalblue' , lw = 1.5 , label = 'cell min (State 2) ' )
249- ln_max , = ax .plot ([], [], '-' , color = 'orangered' , lw = 1.5 , label = 'cell max (State 2) ' )
235+ sc_idle = ax .scatter ([], [], s = 2 , c = 'steelblue' , linewidths = 0 , label = 'idle' )
236+ sc_bal = ax .scatter ([], [], s = 2 , c = 'tomato' , linewidths = 0 , label = 'balancing' )
237+ ln_min , = ax .plot ([], [], '-' , color = 'royalblue' , lw = 1.5 , label = 'cell min' )
238+ ln_max , = ax .plot ([], [], '-' , color = 'orangered' , lw = 1.5 , label = 'cell max' )
250239 ax .legend (loc = 'upper left' , markerscale = 4 , framealpha = 0.7 )
251240 ax .grid (True , linestyle = '--' , alpha = 0.5 )
252241
253242 def update (_frame ):
254- times , volts , bals = logger .plot_snapshot ()
255- mt , mv_min , mv_max = logger .minmax_snapshot ()
243+ pt , pmin , pmax = logger .pack_snapshot ()
244+ ct , cv , cb = logger .cell_snapshot ()
256245
257246 all_t : list [float ] = []
258247 all_v : list [float ] = []
259248
260- if times :
261- t = np .asarray (times )
262- v = np .asarray (volts )
263- b = np .asarray (bals , dtype = bool )
264- idle , bal = ~ b , b
265- sc_idle .set_offsets (np .column_stack ([t [idle ], v [idle ]]) if idle .any () else np .empty ((0 , 2 )))
266- sc_bal .set_offsets ( np .column_stack ([t [bal ], v [bal ]]) if bal .any () else np .empty ((0 , 2 )))
267- all_t .extend (times )
268- all_v .extend (volts )
269-
270- if mt :
271- mt_arr = np .asarray (mt )
272- ln_min .set_data (mt_arr , np .asarray (mv_min ))
273- ln_max .set_data (mt_arr , np .asarray (mv_max ))
274- all_t .extend (mt )
275- all_v .extend (mv_min )
276- all_v .extend (mv_max )
249+ if ct :
250+ t = np .asarray (ct )
251+ v = np .asarray (cv )
252+ b = np .asarray (cb , dtype = bool )
253+ sc_idle .set_offsets (np .column_stack ([t [~ b ], v [~ b ]]) if (~ b ).any () else np .empty ((0 , 2 )))
254+ sc_bal .set_offsets ( np .column_stack ([t [b ], v [b ]]) if b .any () else np .empty ((0 , 2 )))
255+ all_t .extend (ct )
256+ all_v .extend (cv )
257+
258+ if pt :
259+ t_arr = np .asarray (pt )
260+ ln_min .set_data (t_arr , np .asarray (pmin ))
261+ ln_max .set_data (t_arr , np .asarray (pmax ))
262+ all_t .extend (pt )
263+ all_v .extend (pmin )
264+ all_v .extend (pmax )
277265
278266 if all_t :
279267 ax .set_xlim (0 , max (all_t ) + 1 )
0 commit comments