STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
pt_cali.py
Go to the documentation of this file.
1import threading
2import numpy as np
3import socket
4import struct
5from collections import deque
6import csv
7import time
8import matplotlib.pyplot as plt
9from matplotlib.animation import FuncAnimation
10from os.path import isfile
11
12# DAQv2-Comms protocol constants (from pt_multi_gui)
13MAX_PACKET_SIZE = 512
14PACKET_TYPE_SENSOR_DATA = 3
15PACKET_HEADER_FORMAT = "<BBI"
16PACKET_HEADER_SIZE = 6
17SENSOR_DATA_PACKET_FORMAT = "<BB"
18SENSOR_DATA_PACKET_SIZE = 2
19SENSOR_DATA_CHUNK_FORMAT = "<I"
20SENSOR_DATA_CHUNK_SIZE = 4
21SENSOR_DATAPOINT_FORMAT = "<BI" # uint8_t sensor_id + uint32_t data (ADC code)
22SENSOR_DATAPOINT_SIZE = 5
23
24
26 if len(data) < PACKET_HEADER_SIZE:
27 return None
28 try:
29 return struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
30 except struct.error:
31 return None
32
33
35 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
36 return None
37 header = parse_packet_header(data)
38 if header is None or header[0] != PACKET_TYPE_SENSOR_DATA:
39 return None
40 packet_type, version, timestamp = header
41 offset = PACKET_HEADER_SIZE
42 try:
43 num_chunks, num_sensors = struct.unpack(
44 SENSOR_DATA_PACKET_FORMAT,
45 data[offset : offset + SENSOR_DATA_PACKET_SIZE],
46 )
47 except struct.error:
48 return None
49 offset += SENSOR_DATA_PACKET_SIZE
50 per_chunk = SENSOR_DATA_CHUNK_SIZE + num_sensors * SENSOR_DATAPOINT_SIZE
51 if (
52 len(data)
53 < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + num_chunks * per_chunk
54 ):
55 return None
56 chunks = []
57 for _ in range(num_chunks):
58 (chunk_ts,) = struct.unpack(
59 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
60 )
61 offset += SENSOR_DATA_CHUNK_SIZE
62 datapoints = []
63 for _ in range(num_sensors):
64 sid, val = struct.unpack(
65 SENSOR_DATAPOINT_FORMAT, data[offset : offset + SENSOR_DATAPOINT_SIZE]
66 )
67 datapoints.append({"sensor_id": sid, "data": val})
68 offset += SENSOR_DATAPOINT_SIZE
69 chunks.append({"timestamp": chunk_ts, "datapoints": datapoints})
70 return (
71 {"packet_type": packet_type, "version": version, "timestamp": timestamp},
72 chunks,
73 )
74
75
76# Exponential Moving Average Filter
77def ema(data, alpha=0.5):
78 if len(data) < 2:
79 return data[-1]
80 ema_filtered = data[0]
81 for d in data[1:]:
82 ema_filtered = alpha * d + (1 - alpha) * ema_filtered
83 return ema_filtered
84
85
86# Use polynomial coefficients to calculate pressure from ADC code
87def calculate_pressure_from_polynomial(adc_code, poly_coeffs):
88 return np.polyval(poly_coeffs, adc_code)
89
90
91# Moving Average Filter (with Standard Deviation-based Outlier Removal)
92def filtered_sensor_data(data, window=5, std_threshold=2):
93 if len(data) < window:
94 return data[-1]
95 window_data = np.array(data[-window:])
96 mean = np.mean(window_data)
97 std = np.std(window_data)
98 filtered_data = [
99 d
100 for d in window_data
101 if (mean - std_threshold * std) <= d <= (mean + std_threshold * std)
102 ]
103 return np.mean(filtered_data) if filtered_data else mean
104
105
106def apply_dual_filter(data, ema_alpha=0.1, ma_window=250, std_threshold=0.001):
107 ema_filtered = ema(data, alpha=ema_alpha)
108 ma_filtered = filtered_sensor_data(
109 [ema_filtered], window=ma_window, std_threshold=std_threshold
110 )
111 return ma_filtered
112
113
114# Global variables
115instrument_deques = []
116filtered_code_deques = []
117pressure_deques = []
118calibration_polynomials = []
119instrument_to_connector = (
120 []
121) # Filled at startup: instrument_to_connector[i] = board connector (1-10) for PT i+1
122gauge_mappings = {} # Dictionary to store which PTs are connected to which gauge
123gauge_pressures = {} # Dictionary to store pressure readings for each gauge
124keep_calibrating = True
125stop_serial_thread = threading.Event()
126pause_reading = threading.Event()
127X = []
128Y = {} # Modified to store pressure readings per gauge
129
130
131# Modified read_udp function to receive DAQv2-Comms packets over UDP
132def read_udp(port=5006, bind_address="0.0.0.0"):
133 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
134 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
135 # Try to enable SO_REUSEPORT for multiple listeners (Linux/macOS)
136 try:
137 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
138 except (AttributeError, OSError):
139 pass # Not available on Windows
140 sock.settimeout(0.1)
141
142 try:
143 sock.bind((bind_address, port))
144 print(f"Listening for PT data on {bind_address}:{port}")
145 except OSError as e:
146 print(f"Error binding to port {port}: {e}")
147 print(
148 "Make sure pt_multi_gui is not already running, or that SO_REUSEPORT is supported."
149 )
150 return
151
152 try:
153 while not stop_serial_thread.is_set():
154 if not pause_reading.is_set():
155 try:
156 data, _ = sock.recvfrom(MAX_PACKET_SIZE)
157 result = parse_sensor_data_packet(data)
158 if result:
159 header, chunks = result
160 # Extract ADC codes and map connector -> instrument
161 # Stream_ADC: sensor_id = connector (1-10). PT_BOARD_Multi: sensor_id = 0-9 (connector - 1).
162 for chunk in chunks:
163 for dp in chunk["datapoints"]:
164 sid = int(dp["sensor_id"])
165 connector = sid if 1 <= sid <= 10 else (sid + 1) # 1-10
166 adc_code = int(dp["data"]) # Get ADC code as uint32
167
168 try:
169 inst_idx = instrument_to_connector.index(connector)
170 if inst_idx < len(instrument_deques):
171 instrument_deques[inst_idx].append(adc_code)
172 except ValueError:
173 pass
174
175 # Process filtered ADC codes and pressures
176 filtered_codes = []
177 pressure_readings = []
178 for j in range(len(instrument_deques)):
179 if len(instrument_deques[j]) > 0:
180 filtered_code = apply_dual_filter(
181 list(instrument_deques[j])
182 )
183 filtered_code_deques[j].append(filtered_code)
184 filtered_codes.append(filtered_code)
185
186 calculated_pressure = (
188 filtered_code, calibration_polynomials[j]
189 )
190 )
191 pressure_deques[j].append(calculated_pressure)
192 filtered_pressure = apply_dual_filter(
193 list(pressure_deques[j])
194 )
195 pressure_readings.append(filtered_pressure)
196
197 code_output = " | ".join(
198 [
199 f"PT {j+1}: {code:.0f}"
200 for j, code in enumerate(filtered_codes)
201 ]
202 )
203 print("Filtered ADC code readings:", code_output)
204
205 pressure_output = " | ".join(
206 [
207 f"PT {j+1}: {pressure:.3f} psi"
208 for j, pressure in enumerate(pressure_readings)
209 ]
210 )
211 print("Calculated pressures:", pressure_output)
212
213 except socket.timeout:
214 continue
215 except Exception as e:
216 print(f"Error processing data: {e}")
217 continue
218
219 except KeyboardInterrupt:
220 print("UDP reading interrupted.")
221 finally:
222 sock.close()
223
224
225def get_gauge_pressures(num_gauges):
226 while True:
227 try:
228 pressure_input = input(
229 f"Enter the {num_gauges} pressure gauge readings (comma-separated): "
230 )
231 pressures = [float(p.strip()) for p in pressure_input.split(",")]
232 if len(pressures) != num_gauges:
233 print(f"Please enter exactly {num_gauges} pressure readings.")
234 continue
235 return pressures
236 except ValueError:
237 print("Invalid input. Please enter valid numbers separated by commas.")
238 except KeyboardInterrupt:
239 print("\nInput interrupted. Please try again.")
240
241
243 global X, Y
244
245 # Get pressure readings for each gauge
246 pressures = get_gauge_pressures(len(gauge_pressures))
247
248 # Update pressure readings for each gauge
249 for gauge_num, pressure in enumerate(pressures, 1):
250 gauge_pressures[gauge_num] = pressure
251 if gauge_num not in Y:
252 Y[gauge_num] = []
253 Y[gauge_num].append(pressure)
254
255 # Update ADC code readings for each PT
256 for pt_num in range(len(instrument_deques)):
257 if pt_num >= len(X):
258 X.append([])
259 X[pt_num].append(apply_dual_filter(list(instrument_deques[pt_num])))
260
261 # Update calibration polynomials
262 if all(len(Y[gauge]) > 1 for gauge in Y):
263 for pt_num in range(len(instrument_deques)):
264 try:
265 gauge_num = gauge_mappings[
266 pt_num + 1
267 ] # Get the gauge number for this PT
268 epsilon = 1e-10
269 calibration_polynomials[pt_num] = np.polyfit(
270 np.array(X[pt_num]) + epsilon,
271 np.array(Y[gauge_num]) + epsilon,
272 poly_order,
273 )
274 print(
275 f"Updated polynomial coefficients for PT {pt_num + 1}: {calibration_polynomials[pt_num]}"
276 )
277 except np.RankWarning:
278 print(
279 f"Warning: The fit may be poorly conditioned for PT {pt_num + 1}."
280 )
281 except Exception as e:
282 print(f"Error fitting polynomial for PT {pt_num + 1}: {e}")
283
286 else:
287 print(
288 "Not enough data to fit a curve yet. Please enter at least one more data point."
289 )
290
291
293 with open(filename, "a", newline="") as f:
294 writer = csv.writer(f, delimiter=",")
295
296 # Create a row with current calibration point data
297 row = []
298 for pt_num in range(len(instrument_deques)):
299 gauge_num = gauge_mappings[pt_num + 1]
300 # Add ADC code, pressure, and coefficients for this PT
301 row.extend([X[pt_num][-1], Y[gauge_num][-1]]) # ADC Code # Pressure
302 row.extend(calibration_polynomials[pt_num]) # Coefficients
303 writer.writerow(row)
304
305
307 num_cols = 2
308 num_rows = (len(instrument_deques) + 1) // num_cols
309
310 fig, axs = plt.subplots(num_rows, num_cols, figsize=(12, 6), squeeze=False)
311
312 for pt_num in range(len(instrument_deques)):
313 x = np.array(X[pt_num])
314 gauge_num = gauge_mappings[pt_num + 1]
315 y = np.array(Y[gauge_num])
316
317 poly_coeffs = calibration_polynomials[pt_num]
318 trendline_y = np.polyval(poly_coeffs, x)
319
320 ss_res = np.sum((y - trendline_y) ** 2)
321 ss_tot = np.sum((y - np.mean(y)) ** 2)
322 r_squared = 1 - (ss_res / ss_tot)
323
324 row, col = divmod(pt_num, num_cols)
325 axs[row, col].scatter(x, y, color="purple", label="Data Points")
326 axs[row, col].plot(
327 x,
328 trendline_y,
329 color="blue",
330 linestyle="--",
331 label=f"Trendline (R² = {r_squared:.3f})",
332 )
333
334 axs[row, col].set_title(f"PT {pt_num + 1} (Gauge {gauge_num})")
335 axs[row, col].set_xlabel("ADC Code")
336 axs[row, col].set_ylabel("Pressure (psi)")
337 axs[row, col].legend()
338
339 plt.tight_layout()
340 plt.show()
341
342
343# Live feed function modified to plot only filtered data
344def live_feed(save_count=500):
345 num_cols = 2
346 num_rows = (len(instrument_deques) + 1) // num_cols
347
348 fig, axs = plt.subplots(num_rows, num_cols, figsize=(12, 6), squeeze=False)
349 times = deque(maxlen=save_count)
350 pressure_data = [deque(maxlen=save_count) for _ in range(len(instrument_deques))]
351
352 start_time = time.time()
353
354 def update(frame):
355 current_time = time.time() - start_time
356 times.append(current_time)
357
358 for j in range(len(instrument_deques)):
359 if instrument_deques[j]: # Check if there's data in the deque
360 filtered_value = apply_dual_filter(list(instrument_deques[j]))
361 calculated_pressure = calculate_pressure_from_polynomial(
362 filtered_value, calibration_polynomials[j]
363 )
364 pressure_data[j].append(calculated_pressure)
365
366 row, col = divmod(j, num_cols)
367 axs[row, col].clear()
368 axs[row, col].plot(
369 list(times),
370 list(pressure_data[j]),
371 label=f"Instrument {j+1} Pressure",
372 color="blue",
373 )
374
375 axs[row, col].set_xlabel("Time (seconds)")
376 axs[row, col].set_ylabel("Calibrated Pressure (psi)")
377 axs[row, col].legend(loc="upper left")
378 axs[row, col].set_title(f"Live Pressure Readings for Instrument {j+1}")
379
380 fig.tight_layout()
381
382 ani = FuncAnimation(fig, update, interval=100, frames=None, repeat=True)
383 plt.show()
384
385
386if __name__ == "__main__":
387 # Get number of PTs
388 while True:
389 try:
390 instrument_count = int(
391 input("Please input the number of PTs for calibration: ")
392 )
393 break
394 except ValueError:
395 print("Invalid input. Please enter an integer.")
396
397 # Get connector for each PT
398 print("\nFor each PT, specify which board connector (1-10) it is connected to:")
399 for pt_num in range(1, instrument_count + 1):
400 while True:
401 try:
402 conn = int(input(f"Enter board connector (1-10) for PT {pt_num}: "))
403 if 1 <= conn <= 10:
404 instrument_to_connector.append(conn)
405 break
406 else:
407 print("Please enter a valid connector between 1 and 10.")
408 except ValueError:
409 print("Invalid input. Please enter an integer.")
410
411 # Get number of gauges
412 while True:
413 try:
414 gauge_count = int(input("Please input the number of pressure gauges: "))
415 break
416 except ValueError:
417 print("Invalid input. Please enter an integer.")
418
419 # Get PT to gauge mapping
420 print("\nFor each PT, specify which gauge it's connected to:")
421 for pt_num in range(1, instrument_count + 1):
422 while True:
423 try:
424 gauge_num = int(
425 input(f"Enter gauge number (1-{gauge_count}) for PT {pt_num}: ")
426 )
427 if 1 <= gauge_num <= gauge_count:
428 gauge_mappings[pt_num] = gauge_num
429 break
430 else:
431 print(
432 f"Please enter a valid gauge number between 1 and {gauge_count}"
433 )
434 except ValueError:
435 print("Invalid input. Please enter an integer.")
436
437 # Initialize gauge pressure dictionary
438 gauge_pressures = {i: 0 for i in range(1, gauge_count + 1)}
439
440 # Get polynomial order
441 while True:
442 try:
443 poly_order = int(
444 input(
445 "Enter the polynomial order for the fit (e.g., 1 for linear, 2 for quadratic, etc.): "
446 )
447 )
448 break
449 except ValueError:
450 print("Invalid input. Please enter an integer.")
451
452 data_point_num = 6
453
454 # Initialize data structures
455 instrument_deques = [deque(maxlen=data_point_num) for _ in range(instrument_count)]
456 calibration_polynomials = [[0] * (poly_order + 1) for _ in range(instrument_count)]
457 filtered_code_deques = [
458 deque(maxlen=data_point_num) for _ in range(instrument_count)
459 ]
460 pressure_deques = [deque(maxlen=data_point_num) for _ in range(instrument_count)]
461 X = [[] for _ in range(instrument_count)]
462 Y = {i: [] for i in range(1, gauge_count + 1)}
463
464 # Set up CSV file
465 # Set up CSV file
466 file_base = f"PT Calibration Attempt {time.strftime('%Y-%m-%d', time.gmtime())}"
467 file_ext = ".csv"
468 test_num = 1
469
470 while isfile(file_base + f"_test{test_num}" + file_ext):
471 test_num += 1
472
473 filename = file_base + f"_test{test_num}" + file_ext
474
475 # Write CSV header
476 # Use connector numbers in the header so tools like combined_gui.py
477 # can map calibration data directly by connector ID.
478 with open(filename, "w", newline="") as f:
479 writer = csv.writer(f, delimiter=",")
480 header = []
481 for pt_index in range(instrument_count):
482 connector_id = instrument_to_connector[pt_index]
483 base_label = f"PT{connector_id}"
484 pt_header = [
485 f"{base_label} ADC Code",
486 f"{base_label} Pressure",
487 ]
488 pt_header.extend(
489 [f"{base_label} Coefficient {k}" for k in range(poly_order + 1)]
490 )
491 header.extend(pt_header)
492 writer.writerow(header)
493
494 # Start UDP reading thread
495 udp_thread = threading.Thread(target=read_udp, args=(5006, "0.0.0.0"))
496 udp_thread.daemon = True
497 udp_thread.start()
498
499 print("\nPress Ctrl+C to enter new pressure gauge readings.")
500 print(f"When prompted, enter {gauge_count} comma-separated pressure values.")
501
502 calibration_count = 0
503 continue_calibration = True
504 while continue_calibration:
505 try:
506 pause_reading.clear()
507 while True:
508 time.sleep(0.1)
509 except KeyboardInterrupt:
510 print("\nEntering calibration mode...")
511 pause_reading.set()
512 time.sleep(0.2)
513
514 interrupt()
515 calibration_count += 1
516
517 while True:
518 try:
519 user_input = (
520 input("Would you like to continue calibrating? (y/n): ")
521 .strip()
522 .lower()
523 )
524 if user_input == "y":
525 break
526 elif user_input == "n":
527 continue_calibration = False
528 break
529 else:
530 print("Invalid input. Please enter 'y' or 'n'.")
531 except KeyboardInterrupt:
532 print("\nInput interrupted. Please try again.")
533
534 # Stop the UDP thread
535 stop_serial_thread.set()
536 udp_thread.join()
537
538 print("Calibration complete.")
539 show_live_feed = (
540 input(
541 "Would you like to see a live feed of the calibrated pressure readings vs time? (y/n): "
542 )
543 .strip()
544 .lower()
545 )
546 if show_live_feed == "y":
547 stop_serial_thread.clear()
548 pause_reading.clear()
549 udp_thread = threading.Thread(target=read_udp, args=(5006, "0.0.0.0"))
550 udp_thread.daemon = True
551 udp_thread.start()
552
553 live_feed()
554
555 stop_serial_thread.set()
556 udp_thread.join()
557
558print("Program terminated.")
filtered_sensor_data(data, window=5, std_threshold=2)
Definition pt_cali.py:92
parse_packet_header(data)
Definition pt_cali.py:25
save_calibration_data()
Definition pt_cali.py:292
plot_data_with_trendline()
Definition pt_cali.py:306
apply_dual_filter(data, ema_alpha=0.1, ma_window=250, std_threshold=0.001)
Definition pt_cali.py:106
parse_sensor_data_packet(data)
Definition pt_cali.py:34
get_gauge_pressures(num_gauges)
Definition pt_cali.py:225
calculate_pressure_from_polynomial(adc_code, poly_coeffs)
Definition pt_cali.py:87
ema(data, alpha=0.5)
Definition pt_cali.py:77
read_udp(port=5006, bind_address="0.0.0.0")
Definition pt_cali.py:132
interrupt()
Definition pt_cali.py:242
live_feed(save_count=500)
Definition pt_cali.py:344
TOML_NODISCARD_CTOR time() noexcept=default