284) -> Tuple[Dict[int, Tuple[float, float, float, float]], Optional[str]]:
287 if isinstance(csv_paths, str):
288 csv_paths = [csv_paths]
if csv_paths.strip()
else []
289 elif not isinstance(csv_paths, list):
292 csv_paths = [PT_CALIBRATION_CSV]
294 for csv_path
in csv_paths:
295 if not csv_path
or not str(csv_path).strip():
297 csv_path = str(csv_path).strip()
298 if not os.path.isfile(csv_path):
301 with open(csv_path, newline=
"", encoding=
"utf-8")
as f:
302 reader = csv.DictReader(f)
304 fieldnames = reader.fieldnames
or []
308 for col
in fieldnames:
309 m = re.match(
r"PT(\d+)\s+Coefficient\s+0", col, re.IGNORECASE)
311 pt_nums.add(int(m.group(1)))
312 csv_pt_map[csv_path] = pt_nums
314 for pt_num
in sorted(pt_nums):
316 if pt_num
not in duplicates:
317 duplicates[pt_num] = []
318 for prev_path, prev_pt_nums
in csv_pt_map.items():
319 if prev_path != csv_path
and pt_num
in prev_pt_nums:
320 duplicates[pt_num].append(os.path.basename(prev_path))
321 duplicates[pt_num].append(os.path.basename(csv_path))
323 a = float(last.get(f
"PT{pt_num} Coefficient 0", 0))
324 b = float(last.get(f
"PT{pt_num} Coefficient 1", 0))
325 c = float(last.get(f
"PT{pt_num} Coefficient 2", 0))
326 d = float(last.get(f
"PT{pt_num} Coefficient 3", 0))
327 result[pt_num] = (a, b, c, d)
328 except Exception
as e:
329 return result, f
"Error loading {os.path.basename(csv_path)}: {str(e)}"
331 error_parts = [
"Duplicate PT numbers found:"]
332 for pt_num, files
in sorted(duplicates.items()):
333 error_parts.append(f
" PT{pt_num} appears in: {', '.join(files)}")
334 return result,
"\n".join(error_parts)
345 smooth_window_sec: float = 0.020,
346 derivative_threshold_psi_s: float = 50.0,
347 settle_threshold_psi_s: float = 20.0,
348 pre_window_sec: float = 0.5,
349 guard_sec: float = 0.005,
350 min_step_psi: float = 5.0,
351 sustain_samples: int = 10,
352 search_max_sec: float = 5.0,
353 open_is_drop: bool =
True,
356 Extract solenoid valve timing from pressure data using derivative-based edge detection.
359 - t_open_start: When valve starts opening (pressure begins changing after OPEN cmd)
360 - t_open_end: When valve is fully open (pressure stabilizes at low point)
361 - t_close_start: When valve starts closing (pressure begins recovering)
362 - t_close_end: When valve is fully closed (pressure returns to baseline)
364 This algorithm handles overlapping command/response timing where the physical valve
365 response lags behind electrical commands by hundreds of milliseconds.
368 t: Time array in seconds
369 P: Pressure array in psi
370 t_open_cmd: Time of OPEN command
371 t_close_cmd: Time of CLOSE command
372 smooth_window_sec: Window for smoothing pressure (default: 20ms)
373 derivative_threshold_psi_s: Rate of change to detect transition start (default: 50 psi/s)
374 settle_threshold_psi_s: Rate of change threshold for "settled" state (default: 20 psi/s)
375 pre_window_sec: Time before command to calculate baseline (default: 0.5s)
376 guard_sec: Minimum time after command before looking for response (default: 5ms)
377 min_step_psi: Minimum pressure change to consider valid (default: 5 psi)
378 sustain_samples: Number of consecutive samples to confirm a state (default: 10)
379 search_max_sec: Maximum time after command to search for events (default: 5s)
380 open_is_drop: If True, opening causes pressure drop; if False, opening causes pressure rise
383 Dictionary with timing results including t_open_start, t_open_end, t_close_start, t_close_end
385 t = np.asarray(t, dtype=float)
386 P = np.asarray(P, dtype=float)
387 if t.ndim != 1
or P.ndim != 1
or len(t) != len(P):
388 raise ValueError(
"t and P must be 1D arrays of the same length")
390 raise ValueError(
"Need at least 20 samples")
391 if not np.all(np.diff(t) > 0):
392 raise ValueError(
"t must be strictly increasing")
393 if t_open_cmd >= t_close_cmd:
394 raise ValueError(
"t_open_cmd must be < t_close_cmd")
395 if t[0] > t_open_cmd
or t[-1] < t_close_cmd:
396 raise ValueError(
"t must cover both commands")
398 dt_med = float(np.median(np.diff(t)))
402 n = int(round(smooth_window_sec * fs))
406 n =
min(n, len(P) - 2)
409 P_s = np.convolve(P, k, mode=
"same")
414 dP_dt_raw = np.gradient(P_s, t)
415 n_deriv =
max(3, int(round(0.010 * fs)))
418 n_deriv =
min(n_deriv, len(dP_dt_raw) - 2)
420 k_deriv = np.ones(n_deriv) / n_deriv
421 dP_dt = np.convolve(dP_dt_raw, k_deriv, mode=
"same")
425 def _median_in_window(t0: float, t1: float) -> float:
426 idx = np.nonzero((t >= t0) & (t <= t1))[0]
429 i_center = int(np.searchsorted(t, (t0 + t1) / 2))
430 i_start =
max(0, i_center - 10)
431 i_end =
min(len(P_s), i_center + 10)
432 return float(np.median(P_s[i_start:i_end]))
433 return float(np.median(P_s[idx]))
435 def _first_sustained_idx(
436 cond: np.ndarray, start_idx: int, end_idx: Optional[int] =
None
438 """Find first index where condition is sustained for sustain_samples consecutive samples."""
439 s =
max(1, int(sustain_samples))
442 for i
in range(start_idx,
min(end_idx, len(cond)) - s + 1):
443 if np.all(cond[i : i + s]):
448 P_baseline = _median_in_window(
449 max(t[0], t_open_cmd - pre_window_sec), t_open_cmd - 0.001
453 i_open_cmd = int(np.searchsorted(t, t_open_cmd + guard_sec))
454 i_search_end = int(np.searchsorted(t, t_open_cmd + search_max_sec))
455 i_search_end =
min(i_search_end, len(t) - 1)
461 cond_open_start = dP_dt < -derivative_threshold_psi_s
462 cond_open_settled = np.abs(dP_dt) < settle_threshold_psi_s
465 cond_open_start = dP_dt > derivative_threshold_psi_s
466 cond_open_settled = np.abs(dP_dt) < settle_threshold_psi_s
475 search_from = i_open_cmd
476 while search_from < i_search_end:
477 candidate = _first_sustained_idx(cond_open_start, search_from, i_search_end)
478 if candidate
is None:
483 i_look_ahead =
min(candidate + int(0.1 * fs), i_search_end - 1)
484 P_at_candidate = float(P_s[candidate])
485 P_ahead = float(P_s[i_look_ahead])
488 pressure_change = P_at_candidate - P_ahead
490 pressure_change = P_ahead - P_at_candidate
492 if pressure_change >= min_step_psi:
494 i_open_start = candidate
495 t_open_start = float(t[i_open_start])
500 search_peak_end_idx =
min(len(dP_dt), i_search_end)
501 if search_peak_end_idx > i_open_start:
502 peak_search_region = slice(i_open_start, search_peak_end_idx)
505 i_peak = i_open_start + np.argmin(dP_dt[peak_search_region])
508 i_peak = i_open_start + np.argmax(dP_dt[peak_search_region])
510 t_open_end = float(t[i_peak])
511 P_open_post = float(P_s[i_peak])
513 t_open_end = t_open_start
514 P_open_post = float(P_s[i_open_start])
520 search_from = candidate + sustain_samples
525 if i_open_start
is not None:
526 search_start_for_close = i_open_start + sustain_samples
528 search_start_for_close = i_open_cmd
531 search_region = slice(search_start_for_close, i_search_end)
532 if search_region.stop > search_region.start:
533 i_min_local = search_start_for_close + int(np.argmin(P_s[search_region]))
534 P_min = float(P_s[i_min_local])
535 t_min = float(t[i_min_local])
542 if t_open_end
is None and t_min
is not None:
549 cond_close_start = dP_dt > derivative_threshold_psi_s
552 cond_close_start = dP_dt < -derivative_threshold_psi_s
553 cond_close_settled = np.abs(dP_dt) < settle_threshold_psi_s
561 if i_min_local
is not None:
562 i_close_start = _first_sustained_idx(
563 cond_close_start, i_min_local, i_search_end
565 if i_close_start
is not None:
566 t_close_start = float(t[i_close_start])
569 i_close_end = _first_sustained_idx(
570 cond_close_settled, i_close_start + sustain_samples, i_search_end
572 if i_close_end
is not None:
573 t_close_end = float(t[i_close_end])
574 P_close_post = float(
575 np.median(P_s[i_close_end :
min(i_close_end + 50, len(P_s))])
578 def _maybe_delay(t_cmd: float, t_evt: Optional[float]) -> Optional[float]:
579 return None if t_evt
is None else float(t_evt - t_cmd)
581 def _maybe_dt(t0: Optional[float], t1: Optional[float]) -> Optional[float]:
582 return None if (t0
is None or t1
is None)
else float(t1 - t0)
585 "t_open_cmd": t_open_cmd,
586 "t_close_cmd": t_close_cmd,
587 "open_delay": _maybe_delay(t_open_cmd, t_open_start),
588 "open_time": _maybe_dt(t_open_start, t_open_end),
589 "close_delay": _maybe_delay(t_close_cmd, t_close_start),
590 "close_time": _maybe_dt(t_close_start, t_close_end),
591 "t_open_start": t_open_start,
592 "t_open_end": t_open_end,
593 "t_close_start": t_close_start,
594 "t_close_end": t_close_end,
595 "P_open_pre": P_baseline,
596 "P_open_post": P_open_post,
597 "P_close_pre": P_close_pre,
598 "P_close_post": P_close_post,
605 time_col: str =
"time_sec",
606 pressure_col: str =
"pressure_psi",
607 actuation_col: str =
"ACTUATION",
608 open_token: str =
"OPEN",
609 close_token: str =
"CLOSED",
610 smooth_window_sec: float = 0.020,
611 derivative_threshold_psi_s: float = 50.0,
612 settle_threshold_psi_s: float = 20.0,
613 pre_window_sec: float = 0.5,
614 guard_sec: float = 0.005,
615 min_step_psi: float = 5.0,
616 sustain_samples: int = 10,
617 search_max_sec: float = 5.0,
618 open_is_drop: bool =
True,
621 Pressure-based timing from a DataFrame with ACTUATION column.
622 Parses df to (t, P, t_open_cmd, t_close_cmd) then calls extract_solenoid_timing_live.
623 Use this for CSV/offline; use extract_solenoid_timing_live for live GUI.
625 if time_col
not in df
or pressure_col
not in df
or actuation_col
not in df:
627 f
"df must contain columns: {time_col}, {pressure_col}, {actuation_col}"
629 t = df[time_col].to_numpy(dtype=float)
630 P_raw = df[pressure_col].to_numpy(dtype=float)
631 finite = np.isfinite(P_raw)
632 if finite.sum() < 10:
633 raise ValueError(
"Not enough finite pressure samples")
634 P = np.interp(t, t[finite], P_raw[finite])
635 act = df[actuation_col].astype(str)
636 open_rows = df.index[act == open_token].to_list()
637 close_rows = df.index[act == close_token].to_list()
638 if len(open_rows) != 1
or len(close_rows) != 1:
640 f
"Expected exactly 1 '{open_token}' and 1 '{close_token}' row in {actuation_col}"
642 t_open_cmd = float(t[open_rows[0]])
643 t_close_cmd = float(t[close_rows[0]])
644 if t_open_cmd >= t_close_cmd:
645 raise ValueError(
"OPEN command must occur before CLOSED command")
651 smooth_window_sec=smooth_window_sec,
652 derivative_threshold_psi_s=derivative_threshold_psi_s,
653 settle_threshold_psi_s=settle_threshold_psi_s,
654 pre_window_sec=pre_window_sec,
656 min_step_psi=min_step_psi,
657 sustain_samples=sustain_samples,
658 search_max_sec=search_max_sec,
659 open_is_drop=open_is_drop,
677 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
680 if header
is None or header[0] != PacketType.SENSOR_DATA:
682 packet_type, version, timestamp = header
683 offset = PACKET_HEADER_SIZE
685 num_chunks, num_sensors = struct.unpack(
686 SENSOR_DATA_PACKET_FORMAT, data[offset : offset + SENSOR_DATA_PACKET_SIZE]
690 offset += SENSOR_DATA_PACKET_SIZE
691 per_chunk_size = SENSOR_DATA_CHUNK_SIZE + (num_sensors * SENSOR_DATAPOINT_SIZE)
693 PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + (num_chunks * per_chunk_size)
695 if len(data) < expected_size:
698 for chunk_idx
in range(num_chunks):
700 (chunk_timestamp,) = struct.unpack(
701 SENSOR_DATA_CHUNK_FORMAT, data[offset : offset + SENSOR_DATA_CHUNK_SIZE]
705 offset += SENSOR_DATA_CHUNK_SIZE
707 for sensor_idx
in range(num_sensors):
709 sensor_id, sensor_data = struct.unpack(
710 SENSOR_DATAPOINT_FORMAT,
711 data[offset : offset + SENSOR_DATAPOINT_SIZE],
713 datapoints.append({
"sensor_id": sensor_id,
"data": sensor_data})
714 offset += SENSOR_DATAPOINT_SIZE
717 chunks.append({
"timestamp": chunk_timestamp,
"datapoints": datapoints})
719 "packet_type": packet_type,
721 "timestamp": timestamp,
723 return (header_dict, chunks)
727 if len(commands) == 0
or len(commands) > 255:
729 header_size = PACKET_HEADER_SIZE
730 body_size = ACTUATOR_COMMAND_PACKET_SIZE
731 commands_size = len(commands) * ACTUATOR_COMMAND_SIZE
732 total_size = header_size + body_size + commands_size
733 if total_size > MAX_PACKET_SIZE:
735 packet = bytearray(total_size)
738 PACKET_HEADER_FORMAT,
741 PacketType.ACTUATOR_COMMAND,
742 DIABLO_COMMS_VERSION,
745 offset += PACKET_HEADER_SIZE
746 struct.pack_into(ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
747 offset += ACTUATOR_COMMAND_PACKET_SIZE
748 for actuator_id, actuator_state
in commands:
750 ACTUATOR_COMMAND_FORMAT, packet, offset, actuator_id, actuator_state
752 offset += ACTUATOR_COMMAND_SIZE
757 commands: List[Tuple[int, int, float, float]],
760 Creates a PWM Actuator Command packet.
761 Packet layout: PacketHeader + PWMActuatorCommandPacket + N PWMActuatorCommand.
763 commands: List of tuples (actuator_id, duration_ms, duty_cycle, frequency)
764 Note: duration is in ms (uint32), duty_cycle is float (0.0-1.0), frequency is float (Hz)
766 if len(commands) == 0
or len(commands) > 255:
769 header_size = PACKET_HEADER_SIZE
770 body_size = PWM_ACTUATOR_COMMAND_PACKET_SIZE
771 commands_size = len(commands) * PWM_ACTUATOR_COMMAND_SIZE
772 total_size = header_size + body_size + commands_size
774 if total_size > MAX_PACKET_SIZE:
777 packet = bytearray(total_size)
782 PACKET_HEADER_FORMAT,
785 PacketType.PWM_ACTUATOR_COMMAND,
786 DIABLO_COMMS_VERSION,
789 offset += PACKET_HEADER_SIZE
792 struct.pack_into(PWM_ACTUATOR_COMMAND_PACKET_FORMAT, packet, offset, len(commands))
793 offset += PWM_ACTUATOR_COMMAND_PACKET_SIZE
796 for actuator_id, duration_ms, duty_cycle, frequency
in commands:
798 PWM_ACTUATOR_COMMAND_FORMAT,
806 offset += PWM_ACTUATOR_COMMAND_SIZE
1942 parser = argparse.ArgumentParser(description=
"Solenoid Characterization GUI")
1943 parser.add_argument(
1947 default=CONFIG.config[
"network"][
"actuator_ip"],
1948 help=
"Actuator board IP",
1950 parser.add_argument(
1954 default=CONFIG.config[
"network"][
"receive_port"],
1955 help=
"UDP receive port",
1957 parser.add_argument(
1961 default=CONFIG.config[
"network"][
"actuator_port"],
1962 help=
"Actuator command port",
1964 parser.add_argument(
1965 "-a",
"--address", type=str, default=
"0.0.0.0", help=
"Bind address for receiver"
1967 args = parser.parse_args()
1969 app = QtWidgets.QApplication(sys.argv)
1970 app.setStyle(
"Fusion")
1971 palette = QtGui.QPalette()
1973 QtGui.QPalette.ColorGroup.All,
1974 QtGui.QPalette.ColorRole.Window,
1975 QtGui.QColor(53, 53, 53),
1978 QtGui.QPalette.ColorGroup.All,
1979 QtGui.QPalette.ColorRole.WindowText,
1980 QtCore.Qt.GlobalColor.white,
1983 QtGui.QPalette.ColorGroup.All,
1984 QtGui.QPalette.ColorRole.Base,
1985 QtGui.QColor(25, 25, 25),
1988 QtGui.QPalette.ColorGroup.All,
1989 QtGui.QPalette.ColorRole.Text,
1990 QtCore.Qt.GlobalColor.white,
1993 QtGui.QPalette.ColorGroup.All,
1994 QtGui.QPalette.ColorRole.Button,
1995 QtGui.QColor(53, 53, 53),
1998 QtGui.QPalette.ColorGroup.All,
1999 QtGui.QPalette.ColorRole.ButtonText,
2000 QtCore.Qt.GlobalColor.white,
2002 app.setPalette(palette)
2004 receiver =
UDPReceiver(port=args.port, bind_address=args.address)
2010 device_port=args.device_port,
2011 bind_address=args.address,
2013 window.showMaximized()
2019 app.aboutToQuit.connect(cleanup)
2020 sys.exit(app.exec())