From 24fec5c3af61404d9a0c9544b63648e0580b00a2 Mon Sep 17 00:00:00 2001 From: zg1in Date: Wed, 20 May 2026 16:11:40 +0200 Subject: [PATCH 01/13] format --- src/hip_controller/__init__.py | 4 +++- src/hip_controller/definitions.py | 6 +++++- tests/conftest.py | 3 +-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/hip_controller/__init__.py b/src/hip_controller/__init__.py index f05a609..ea69e50 100644 --- a/src/hip_controller/__init__.py +++ b/src/hip_controller/__init__.py @@ -14,7 +14,9 @@ try: import tomli as tomllib except ImportError as err: - raise ImportError("Python 3.10 requires the 'tomli' package: pip install tomli") from err + raise ImportError( + "Python 3.10 requires the 'tomli' package: pip install tomli" + ) from err from importlib.metadata import PackageNotFoundError, version from pathlib import Path diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index 8fd05d5..f172a3d 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -1,14 +1,18 @@ """Common definitions for this module.""" -import sys +import sys from dataclasses import asdict, dataclass from enum import auto + if sys.version_info >= (3, 11): from enum import StrEnum else: from enum import Enum + class StrEnum(str, Enum): """String enum backport for Python <3.11.""" + + from math import pi from pathlib import Path diff --git a/tests/conftest.py b/tests/conftest.py index c54532a..736e420 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,9 @@ import os import sys -from hip_controller.definitions import StrEnum from pathlib import Path -from hip_controller.definitions import TESTING_DIR +from hip_controller.definitions import TESTING_DIR, StrEnum # Add the src directory to the path so that the quaternion_ekf package can be imported my_path = os.path.dirname(os.path.abspath(__file__)) From 3d4d9ab74f70709dab096ae48af7b073b37428df Mon Sep 17 00:00:00 2001 From: zg1in Date: Wed, 20 May 2026 21:32:56 +0200 Subject: [PATCH 02/13] readme and config Co-authored-by: Copilot --- scripts/readme.md | 124 ++++++++++++++ src/hip_controller/__main__.py | 6 +- src/hip_controller/control/app.py | 26 ++- .../signal_processing/drift_removal.py | 4 +- .../signal_processing/sensor_preprocessor.py | 92 +++++++++-- src/hip_controller/definitions.py | 153 ++++++++---------- tests/conftest.py | 11 +- tests/controller_test/app_test.py | 4 +- 8 files changed, 308 insertions(+), 112 deletions(-) create mode 100644 scripts/readme.md diff --git a/scripts/readme.md b/scripts/readme.md new file mode 100644 index 0000000..e4c6fef --- /dev/null +++ b/scripts/readme.md @@ -0,0 +1,124 @@ +# Scripts Folder + +This document describes the purpose of each script in `scripts/` and how they are intended to be used. + + +## Script descriptions + +### `compare_all.py` +A comparison utility for MATLAB reference data and module output data. It parses matched MATLAB/output CSV pairs, computes RMSE/MAPE metrics, and generates comparison plots. + +### `compare_matlab.py` +Same purpose as `compare_all.py`: compare Simulink/MATLAB CSV data against module output data. Use it to inspect phase and motor command agreement between MATLAB and Python results. + +### `compare_matlab_module.py` +A second comparison helper with the same MATLAB vs module output workflow. It is also intended for RMSE/MAPE calculation and plotting of matched CSV pairs. + +### `controller_simulator.py` +A GUI-focused simulation script. It can replay CSV data through the controller and display live comparison plots. Key functions: +- `simulate_controller_with_data(...)` for full controller playback +- `simulate_comparison_dynamic(...)` for comparing actual output with expected output + +### `csv_converter.py` +CSV processing utilities for walking data. Primary helpers: +- `concatenate_stairs(...)` to merge stair trial CSVs per participant +- `combine_two_files(...)` to join two CSVs while keeping the selected columns + +### `csv_player.py` +A stateful CSV player for real-time-style playback. It loads a CSV once and returns one row at a time so GUI/plotting components can simulate sensor streaming. + +### `evaluation_matplotlib.py` +Plotting utilities for evaluation output. It contains functions to draw gait phase, filtered signals, amplitude, and motor command figures from evaluation CSV files. + +### `evaluation_record.py` +The evaluation preprocessing pipeline. It reads raw sensor CSVs from `data/evaluation_raw_data/`, downsamples and converts them, and writes the resulting evaluation CSV files under `scripts/evaluation_output/`. + +### `live_comparison_plot.py` +A PyQt6 plot window for live signal comparison. Used by simulator components to show input, actual output, and expected output in real time. + +### `mat_to_csv.py` +MAT file conversion helper. It extracts `left` and `right` arrays from `.mat` files and writes them as CSV, preserving folder structure. + +### `normalize_output_time.py` +Normalizes output CSV time columns so `time (s)` starts at `0.00` and increments by `0.01`. Useful for preparing evaluation/output files for plotting or comparison. + +### `plot_scenarios.py` +A comparison plot module for preprocessing validation. It replays CSV rows through a callable, then plots actual vs expected output and residuals in a 3-panel figure. + +### `reference_versus_calculated.py` +A gait-phase comparison tool. It builds a reference phase signal from left/right cycle boundaries and compares it against calculated gait phase output, including RMSE reporting and plots. + +## Usage notes + +- Many scripts are library-style and are best used by importing their functions from Python. +- `evaluation_record.py`, `mat_to_csv.py`, and `normalize_output_time.py` include `__main__` runners for direct execution. +- Visualization scripts generally require `matplotlib`, and GUI scripts require `PyQt6`/`pyqtgraph`. + +For exact call patterns, open the corresponding script and inspect the top-level functions or the `if __name__ == '__main__'` section. + +## Examples + +### Run directly from the shell + +```bash +python scripts/evaluation_record.py +python scripts/mat_to_csv.py +python scripts/normalize_output_time.py +``` + +### Compare MATLAB vs module CSV outputs + +```python +from scripts.compare_matlab import parse_matlab_module, compute_metrics +from pathlib import Path + +matlab_csv = Path('data/evaluation_raw_data/matlab_ref.csv') +output_csv = Path('data/evaluation_output/module_out.csv') +comparison = parse_matlab_module(matlab_csv, output_csv) +metrics = compute_metrics(comparison) +``` + +### Use the CSV player for real-time-style replay + +```python +from scripts.csv_player import ScriptPlayer + +player = ScriptPlayer(Path('data/evaluation_raw_data/some_walk.csv')) +while player.has_next_line(): + row = player.get_data_from_csv('angle_right (rad)', 'gait_phase_right (rad)') +``` + +### Build a preprocessing comparison plot + +```python +from scripts.plot_scenarios import plot_preprocessor_comparison +from hip_controller.definitions import SensorSignal +from hip_controller.control.signal_processing.sensor_preprocessor import SensorPreprocessor, PreprocessorConfig + +preprocessor = SensorPreprocessor(PreprocessorConfig()) +plot_preprocessor_comparison( + csv_path='scripts/evaluation_output/normal_walk/AB01_normal_walk.csv', + time_col='time (s)', + input_col='angle_right (rad)', + expected_output_col='filtered_velocity_right (rad/s)', + build_signal=lambda t, a: SensorSignal(timestamp=t, angle_rad=a, velocity_rad_per_sec=0.0), + run_callable=preprocessor.filter, + extract_output=lambda sig: sig.velocity_rad_per_sec, +) +``` + +### Simulate controller playback + +```python +from scripts.controller_simulator import simulate_controller_with_data +from pathlib import Path + +simulate_controller_with_data(csv_path=Path('data/evaluation_raw_data/normal_walk/AB01_normal_walk.csv')) +``` + +### Normalize evaluation output time values + +```python +from scripts.normalize_output_time import normalize_output_folder +normalize_output_folder('scripts/evaluation_output', 'scripts/normalized_output') +``` diff --git a/src/hip_controller/__main__.py b/src/hip_controller/__main__.py index 519f624..4b4068c 100644 --- a/src/hip_controller/__main__.py +++ b/src/hip_controller/__main__.py @@ -41,8 +41,10 @@ def main( app = QtWidgets.QApplication([]) player = CSVPlayer(csv_path) - controller_left = WalkOnController(reverse=True, plot=True, filtered=True) - controller_right = WalkOnController(reverse=False, plot=True, filtered=True) + config = BasicConfig(filtered=True) + + controller_left = WalkOnController(left_limb=True, config=config) + controller_right = WalkOnController(left_limb=False, config=config) timer = QtCore.QTimer() def update() -> None: diff --git a/src/hip_controller/control/app.py b/src/hip_controller/control/app.py index 1d45903..74aae61 100644 --- a/src/hip_controller/control/app.py +++ b/src/hip_controller/control/app.py @@ -10,7 +10,7 @@ from hip_controller.control.signal_processing.sensor_preprocessor import ( SensorPreprocessor, ) -from hip_controller.definitions import PreprocessorConfig, SensorSignal +from hip_controller.definitions import BasicConfig, SensorSignal class WalkOnController: @@ -19,7 +19,7 @@ class WalkOnController: This controller implements a gait phase-based control strategy for a single limb, which can be used for both unilateral and bilateral hip flexion exosuits. The controller processes raw sensor signals to compute the current gait phase, applies amplitude modulation based on the sensor signals, and generates motor velocity commands for the exosuit's actuators. """ - def __init__(self, reverse: bool, plot: bool = False, filtered=False): + def __init__(self, left_limb: bool, config: BasicConfig): """Initialize the controller. :param bool reverse: Whether to reverse the motor command output (for mirrored wiring). @@ -28,20 +28,30 @@ def __init__(self, reverse: bool, plot: bool = False, filtered=False): :return: None """ - self.plot = plot - self.filtered = filtered - if plot: + self.filtered = config.filtered + if left_limb: + self.plot = config.left_limb_plot + self.amplitude_modulation = AmplitudeModulation( + reverse=config.left_limb_reverse + ) + else: + self.plot = config.right_limb_plot + self.amplitude_modulation = AmplitudeModulation( + reverse=config.right_limb_reverse + ) + + if self.plot: from hip_controller.plotter.live_phase_portrait import PortraitWindow # Execute the Qt plot application. - self.plotter = PortraitWindow(left=not reverse) + self.plotter = PortraitWindow(left=left_limb) self.plotter.show() - self.pre_processor = SensorPreprocessor(PreprocessorConfig()) + self.pre_processor = SensorPreprocessor(basic_config=config) self.gait_controller = GaitController() # due to different wire settings one of them might need to be reversed - mirrored with -1 - self.amplitude_modulation = AmplitudeModulation(reverse=reverse) + self.motion_reference_controller = MotionReferenceController() self._prev_timestamp: float | None = None diff --git a/src/hip_controller/control/signal_processing/drift_removal.py b/src/hip_controller/control/signal_processing/drift_removal.py index 46660cf..a2721b2 100644 --- a/src/hip_controller/control/signal_processing/drift_removal.py +++ b/src/hip_controller/control/signal_processing/drift_removal.py @@ -11,9 +11,7 @@ from hip_controller.definitions import LowPassFilterConfig, NotchConfig from hip_controller.filters.notch_filter import NotchFilter -from hip_controller.filters.second_order_low_pass_filter import ( - SecondOrderLowPassFilter, -) +from hip_controller.filters.second_order_low_pass_filter import SecondOrderLowPassFilter class DriftRemovalStrategy(ABC): diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index 509c856..39aa0ed 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -9,17 +9,32 @@ from __future__ import annotations +from loguru import logger + from hip_controller.control.signal_processing.drift_removal import ( DriftRemovalStrategy, + LowPassDriftRemoval, + NotchDriftRemoval, ) from hip_controller.control.signal_processing.filtering import ( FilteringStrategy, + LowPassFiltering, SogiFllFiltering, ) from hip_controller.control.signal_processing.velocity_estimation import ( + DiscreteDerivativeVelocityEstimation, + GyroscopeVelocityEstimation, + LowPassVelocityEstimation, VelocityEstimationStrategy, ) -from hip_controller.definitions import PreprocessorConfig, SensorSignal +from hip_controller.definitions import ( + BasicConfig, + DriftRemovalMethod, + FilteringMethod, + PreprocessorConfig, + SensorSignal, + VelocityEstimationMethod, +) class SensorPreprocessor: @@ -31,23 +46,22 @@ class SensorPreprocessor: """ - def __init__(self, config: PreprocessorConfig) -> None: + def __init__(self, basic_config: BasicConfig) -> None: """Initialize the sensor pre-processor. :param PreprocessorConfig config: Preprocessor configuration. :return: None """ - self.config = config - self._drift_removal: DriftRemovalStrategy = config.drift_removal_strategy - self._sogi_fll: FilteringStrategy = SogiFllFiltering( - config=config.filtering_sogifll_config - ) - self._velocity_estimation: VelocityEstimationStrategy = ( - config.velocity_estimation_strategy - ) + self._basic_config: BasicConfig = basic_config + + self._drift_removal: DriftRemovalStrategy + self._filtering: FilteringStrategy + self._velocity_estimation: VelocityEstimationStrategy self._prev_timestamp: float | None = None + self.__init_strategies__() + def filter(self, raw_signal: SensorSignal) -> SensorSignal: """Run one preprocessing step and return a :class:`SensorSignal`. @@ -65,9 +79,7 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: # check dt too big if time_difference > 1.0: - self._drift_removal = self.config.drift_removal_strategy - self._velocity_estimation = self.config.velocity_estimation_strategy - time_difference = 0.01 + self.reset() self._prev_timestamp = raw_signal.timestamp @@ -75,7 +87,7 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: raw_angle=raw_signal.angle_rad, time_difference=time_difference ) - angle_out_rad = self._sogi_fll.filter( + angle_out_rad = self._filtering.filter( angle_rad=angle_no_drift_rad, time_difference=time_difference ) @@ -91,6 +103,56 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: velocity_rad_per_sec=velocity_out_rad_per_sec, ) + def __init_strategies__(self): + """Get instance of different options of drift removal, filtering, and velocity estimation.""" + if self._basic_config.drift_removal_method == DriftRemovalMethod.LOW_PASS: + self._drift_removal = LowPassDriftRemoval( + PreprocessorConfig.drift_removal_second_order_lpf_config + ) + + elif self._basic_config.drift_removal_method == DriftRemovalMethod.NOTCH: + self._drift_removal = NotchDriftRemoval( + PreprocessorConfig.drift_removal_notch_config + ) + else: + logger.warning("Selected method does not exist.") + + if self._basic_config.filtering_method == FilteringMethod.SOGI: + self._filtering = SogiFllFiltering( + PreprocessorConfig.filtering_sogifll_config + ) + + elif self._basic_config.filtering_method == FilteringMethod.LOW_PASS: + self._filtering = LowPassFiltering( + PreprocessorConfig.filtering_lowpass_config + ) + + else: + logger.warning("Selected method does not exist.") + + if ( + self._basic_config.velocity_estimation_method + == VelocityEstimationMethod.DISCRETE_DERIVATIVE + ): + self._velocity_estimation = DiscreteDerivativeVelocityEstimation() + + elif ( + self._basic_config.velocity_estimation_method + == VelocityEstimationMethod.LOW_PASS + ): + self._velocity_estimation = LowPassVelocityEstimation( + PreprocessorConfig.velocity_estimation_low_pass_config + ) + + elif ( + self._basic_config.velocity_estimation_method + == VelocityEstimationMethod.GYROSCOPE + ): + self._velocity_estimation = GyroscopeVelocityEstimation() + + else: + logger.warning("Selected method does not exist.") + def reset(self) -> None: """Reset the Signal Preprocessor if exosuit is disconnected or timeout occured. @@ -99,5 +161,5 @@ def reset(self) -> None: self._prev_timestamp = None self._drift_removal.reset() - self._sogi_fll.reset() + self._filtering.reset() self._velocity_estimation.reset() diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index f172a3d..c9f7057 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -2,10 +2,9 @@ import sys from dataclasses import asdict, dataclass -from enum import auto if sys.version_info >= (3, 11): - from enum import StrEnum + from enum import StrEnum, auto else: from enum import Enum @@ -33,27 +32,27 @@ class StrEnum(str, Enum): LOG_DIR: Path = DATA_DIR / "logs" -@dataclass(frozen=True) -class BasicConfig: - """Basic configurations for the hip controller.""" +class DriftRemovalMethod(StrEnum): + """Drift removal strategy options.""" - # if the graph is displayed or not - left_limb_plot: bool = True - right_limb_plot: bool = True + LOW_PASS = auto() + NOTCH = auto() - # if the wiring settings are reversed or not - left_limb_reverse: bool = False - right_limb_reverse: bool = True - # either read data from imu or read data from csv file using csv player - read_from_imu: bool = False +class FilteringMethod(StrEnum): + """Filtering strategy options.""" - # the path where data is read from - read_data_from_path: Path = ( - DATA_DIR / "sensor_data" / "data_input_filtered_2026_01_09.csv" - ) + SOGI = auto() + KALMAN = auto() + LOW_PASS = auto() - frequency: int = 100 + +class VelocityEstimationMethod(StrEnum): + """Velocity estimation strategy options.""" + + DISCRETE_DERIVATIVE = auto() + LOW_PASS = auto() + GYROSCOPE = auto() class SolverType(StrEnum): @@ -69,17 +68,56 @@ class SolverType(StrEnum): Maps to Simulink continuous integrator + ode4 solver. """ - FORWARD_EULER = "forward_euler" - BACKWARD_EULER = "backward_euler" - TRAPEZOIDAL = "trapezoidal" - RUNGE_KUTTA = "rk4" + FORWARD_EULER = auto() + BACKWARD_EULER = auto() + TRAPEZOIDAL = auto() + RUNGE_KUTTA = auto() + + +@dataclass(frozen=True) +class BasicConfig: + """Basic configurations for the hip controller.""" + + # general frequency + frequency: int = 100 + + # if data is pre filtered - skip the pre processing + filtered: bool = False + + # if the graph is displayed or not + left_limb_plot: bool = False + right_limb_plot: bool = False + + # if the wiring settings are reversed or not + left_limb_reverse: bool = False + right_limb_reverse: bool = True + + # either read data from imu or read data from csv file using csv player + read_from_imu: bool = False + + # the path where data is read from + read_data_from_path: Path = ( + DATA_DIR / "sensor_data" / "data_input_filtered_2026_01_09.csv" + ) + + # select which DriftRemovalMethod, VelocityEstimationMethod + drift_removal_method: DriftRemovalMethod = DriftRemovalMethod.LOW_PASS + + filtering_method: FilteringMethod = FilteringMethod.SOGI + velocity_estimation_method: VelocityEstimationMethod = ( + VelocityEstimationMethod.DISCRETE_DERIVATIVE + ) + # cut-off frequency for the 2ndOrderLP filter + cut_off_freq_low_pass_rad_per_sec: float = 80.0 @dataclass class LowPassFilterConfig: """Settings for the second-order low-pass filter containing cut_off_frequency, damping_ratio, initial_condition, solver_type.""" - cut_off_frequency_rad_per_sec: float = 20.0 # in rad/s + cut_off_frequency_rad_per_sec: float = ( + BasicConfig.cut_off_freq_low_pass_rad_per_sec + ) # in rad/s damping_ratio: float = 1.0 # 1.0 = critically damped initial_condition: float = 0.0 solver_type: SolverType = ( @@ -94,8 +132,8 @@ class LowPassFilterConfig: class NotchConfig: """Configurations for the notch function.""" - center_freq_hz: float - bandwidth_3db_hz: float + center_freq_hz: float = 0.0 + bandwidth_3db_hz: float = 0.1 sample_rate_hz: float = BasicConfig.frequency @@ -159,74 +197,25 @@ class SogiFllConfig: numerical_safety_floor: float = 1e-9 -class DriftRemovalMethod(StrEnum): - """Drift removal strategy options.""" - - LOW_PASS = auto() - NOTCH = auto() - - -class VelocityEstimationMethod(StrEnum): - """Velocity estimation strategy options.""" - - SOGI = auto() - DISCRETE_DERIVATIVE = auto() - LOW_PASS = auto() - GYROSCOPE = auto() - - class PreprocessorConfig: """Configurations for the sensor preprocessor.""" - # Select methods for drift removal and velocity estimation filtering - drift_removal_method: DriftRemovalMethod = DriftRemovalMethod.LOW_PASS - velocity_estimation_method: VelocityEstimationMethod = ( - VelocityEstimationMethod.DISCRETE_DERIVATIVE - ) - # Configurations for the filters drift_removal_second_order_lpf_config: LowPassFilterConfig = LowPassFilterConfig( cut_off_frequency_rad_per_sec=1.25, damping_ratio=1.0, initial_condition=0.0 ) - drift_removal_notch_config: NotchConfig = NotchConfig( - center_freq_hz=0.0, bandwidth_3db_hz=0.1, sample_rate_hz=BasicConfig.frequency - ) + drift_removal_notch_config: NotchConfig = NotchConfig() + filtering_sogifll_config: SogiFllConfig = SogiFllConfig() + filtering_lowpass_config: LowPassFilterConfig = LowPassFilterConfig() + filtering_second_order_lpf_config: LowPassFilterConfig = LowPassFilterConfig( - cut_off_frequency_rad_per_sec=20.0, damping_ratio=1.0, initial_condition=0.0 + cut_off_frequency_rad_per_sec=80.0, damping_ratio=1.0, initial_condition=0.0 ) - @property - def drift_removal_strategy(self): - """Get instance of different options of drift removal.""" - from hip_controller.control.signal_processing.drift_removal import ( - LowPassDriftRemoval, - NotchDriftRemoval, - ) - - if self.drift_removal_method == DriftRemovalMethod.LOW_PASS: - return LowPassDriftRemoval(self.drift_removal_second_order_lpf_config) - else: - return NotchDriftRemoval(self.drift_removal_notch_config) - - @property - def velocity_estimation_strategy(self): - """Get instance of different options of velocity estimation.""" - from hip_controller.control.signal_processing.velocity_estimation import ( - DiscreteDerivativeVelocityEstimation, - GyroscopeVelocityEstimation, - LowPassVelocityEstimation, - ) - - if ( - self.velocity_estimation_method - == VelocityEstimationMethod.DISCRETE_DERIVATIVE - ): - return DiscreteDerivativeVelocityEstimation() - elif self.velocity_estimation_method == VelocityEstimationMethod.LOW_PASS: - return LowPassVelocityEstimation(self.filtering_second_order_lpf_config) - else: - return GyroscopeVelocityEstimation() + velocity_estimation_low_pass_config: LowPassFilterConfig = LowPassFilterConfig( + cut_off_frequency_rad_per_sec=20.0, damping_ratio=1.0, initial_condition=0.0 + ) # centering & normalization diff --git a/tests/conftest.py b/tests/conftest.py index 736e420..c5b9478 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,16 @@ import sys from pathlib import Path -from hip_controller.definitions import TESTING_DIR, StrEnum +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + from enum import Enum + + class StrEnum(str, Enum): + """String enum backport for Python <3.11.""" + + +from hip_controller.definitions import TESTING_DIR # Add the src directory to the path so that the quaternion_ekf package can be imported my_path = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/controller_test/app_test.py b/tests/controller_test/app_test.py index 90d6e3c..132e496 100644 --- a/tests/controller_test/app_test.py +++ b/tests/controller_test/app_test.py @@ -9,6 +9,7 @@ SensorSignal, WalkOnController, ) +from hip_controller.definitions import BasicConfig from tests.conftest import ( DATA_REFERENCE_MOTION_RIGHT, REL_TOL, @@ -18,7 +19,8 @@ def test_controller_right(): """Test the main function with the right lower limb data.""" - controller = WalkOnController(reverse=True, plot=False, filtered=True) + config = BasicConfig(filtered=True) + controller = WalkOnController(left_limb=False, config=config) df = read_csv(filepath_or_buffer=DATA_REFERENCE_MOTION_RIGHT) From d5ff73ba9cee813897403a1f154d8c9e5b365d73 Mon Sep 17 00:00:00 2001 From: zg1in Date: Wed, 20 May 2026 21:34:57 +0200 Subject: [PATCH 03/13] version number --- README.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a25c35a..61a3b05 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ uv publish # make sure your version in pyproject.toml is updated or -Update the version number in pyproject.toml and imu_module/__init__.py +Update the version number in pyproject.toml and hip_controller/__init__.py Commit your changes and add a git tag v Push the tag git push --tag diff --git a/pyproject.toml b/pyproject.toml index 8bc223d..6dd5ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hip_controller" -version = "0.1.1" +version = "0.1.2" description = "Lower limb exosuit hip controller." readme = "README.md" authors = [ From 0c78f172e290cd74883136b352735e00386e276d Mon Sep 17 00:00:00 2001 From: zg1in Date: Wed, 20 May 2026 21:37:02 +0200 Subject: [PATCH 04/13] new tree --- README.md | 17 +++++++++++------ uv.lock | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 61a3b05..97a4155 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,9 @@ motor_command = limb_controller.step(signal) The following tree shows the important permanent files. Run `make tree` to update. ``` +├── .claude +│ └── skills +│ └── code-review-nathalie.md ├── data │ ├── evaluation_raw_data │ │ ├── incline_walk @@ -793,7 +796,6 @@ The following tree shows the important permanent files. Run `make tree` to updat │ │ ├── AB11_turn_and_step_1_right-turn_angle.csv │ │ ├── AB12_turn_and_step_1_right-turn_angle.csv │ │ └── AB13_turn_and_step_1_right-turn_angle.csv -│ ├── logs │ └── sensor_data │ ├── arduino2_2026_03_23.csv │ ├── arduino_2026_03_23.csv @@ -809,17 +811,17 @@ The following tree shows the important permanent files. Run `make tree` to updat │ ├── compare_all.py │ ├── compare_matlab.py │ ├── compare_matlab_module.py +│ ├── controller_simulator.py │ ├── csv_converter.py -│ ├── csv_utils.py +│ ├── csv_player.py │ ├── evaluation_matplotlib.py │ ├── evaluation_record.py │ ├── live_comparison_plot.py -│ ├── main.py │ ├── mat_to_csv.py │ ├── normalize_output_time.py -│ ├── reference_versus_calculated.py -│ ├── script.py -│ └── simulator.py +│ ├── plot_scenarios.py +│ ├── readme.md +│ └── reference_versus_calculated.py ├── src │ └── hip_controller │ ├── control @@ -834,6 +836,7 @@ The following tree shows the important permanent files. Run `make tree` to updat │ │ │ └── pid_controller.py │ │ ├── signal_processing │ │ │ ├── drift_removal.py +│ │ │ ├── filtering.py │ │ │ ├── sensor_preprocessor.py │ │ │ └── velocity_estimation.py │ │ ├── __init__.py @@ -848,6 +851,7 @@ The following tree shows the important permanent files. Run `make tree` to updat │ │ ├── csv_player.py │ │ └── live_phase_portrait.py │ ├── utils +│ │ ├── csv_utils.py │ │ ├── math_utils.py │ │ ├── state_space.py │ │ └── utils.py @@ -892,6 +896,7 @@ The following tree shows the important permanent files. Run `make tree` to updat ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version +├── CLAUDE.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE diff --git a/uv.lock b/uv.lock index 75fd7f4..3823cf6 100644 --- a/uv.lock +++ b/uv.lock @@ -441,7 +441,7 @@ wheels = [ [[package]] name = "hip-controller" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "loguru" }, From a3db610367017f41ab8f6fc92b9494f6f7bc2d09 Mon Sep 17 00:00:00 2001 From: zg1in Date: Wed, 20 May 2026 21:46:11 +0200 Subject: [PATCH 05/13] docstring and readme --- README.md | 20 +++++--------------- src/hip_controller/control/app.py | 5 ++--- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 97a4155..82fcf13 100644 --- a/README.md +++ b/README.md @@ -128,18 +128,7 @@ docker build -t hip-controller . docker run hip-controller ``` -## How to Import -Import the main module: -```python -from hip_controller import definitions -``` - -Import specific components: -```python -from hip_controller.control.app import AppController -from hip_controller.control.signal_processing.kalman_filter import KalmanFilter -from hip_controller.plotter.simulator import Simulator ``` ## Architecture and Flow @@ -161,12 +150,13 @@ The main entry point is `app.py` in the control module, which orchestrates the f Run the main application: ```python from hip_controller.control.app import WalkOnController -from hip_controller.definitions import SensorSignal -logger.info("Initializing the lower limb controller.") +from hip_controller.definitions import SensorSignal, BasicConfig +logger.info("Initializing the lower limb controller.") -self.controller_left = WalkOnController(reverse=False, plot=False) -self.controller_right = WalkOnController(reverse=True, plot=False) +config = BasicConfig(filtered=False) +self.controller_left = WalkOnController(left_limb=True, config=config) +self.controller_right = WalkOnController(left_limb=False, config=config) while True: signal_left = SensorSignal(timestamp=timestamp_left, angle_rad=data_left.quat.to_euler(seq="xyz").z, velocity_rad_per_sec=data_left.device_data.gyro.z) diff --git a/src/hip_controller/control/app.py b/src/hip_controller/control/app.py index 74aae61..43d4c6a 100644 --- a/src/hip_controller/control/app.py +++ b/src/hip_controller/control/app.py @@ -22,9 +22,8 @@ class WalkOnController: def __init__(self, left_limb: bool, config: BasicConfig): """Initialize the controller. - :param bool reverse: Whether to reverse the motor command output (for mirrored wiring). - :param bool plot: Whether to enable live plotting of the controller's internal states. - :param bool filtered: Whether to use pre-filtered sensor signals instead of raw signals. + :param bool left_limb: True if the controller is for left lower limb, False if for right lower limb. + :param BasicConfig config: Configurations including whether to reverse the motor command output (for mirrored wiring), whether to enable live plotting of the controller's internal states, whether to use pre-filtered sensor signals instead of raw signals and so on. :return: None """ From b3feb9190d0a575f1e8c1bd8021b7810268975b7 Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Wed, 3 Jun 2026 14:45:42 +0200 Subject: [PATCH 06/13] baseline removal --- .../signal_processing/sensor_preprocessor.py | 15 +++++++++++++++ src/hip_controller/definitions.py | 3 +++ 2 files changed, 18 insertions(+) diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index 39aa0ed..dccfd7f 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -28,6 +28,7 @@ VelocityEstimationStrategy, ) from hip_controller.definitions import ( + BASELINE_REMOVAL_SAMPLE_NUM, BasicConfig, DriftRemovalMethod, FilteringMethod, @@ -59,6 +60,9 @@ def __init__(self, basic_config: BasicConfig) -> None: self._velocity_estimation: VelocityEstimationStrategy self._prev_timestamp: float | None = None + self._baseline: float = 0.0 + self._baseline_count: int = 0 + self._baseline_sum: float = 0.0 self.__init_strategies__() @@ -68,6 +72,17 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: :return: Preprocessed :class:`SensorSignal` with timestamp of the current sample [s], raw angle from the sensor [rad] and gyroscope angular rate [rad/s] read from sensor. :rtype: SensorSignal """ + # Baseline capture by taking avg of first N samples + if self._baseline_count < BASELINE_REMOVAL_SAMPLE_NUM: + self._baseline_count += 1 + self._baseline_sum += raw_signal.angle_rad + raw_signal.angle_rad = 0.0 + elif self._baseline_count == BASELINE_REMOVAL_SAMPLE_NUM: + self._baseline = self._baseline_sum / BASELINE_REMOVAL_SAMPLE_NUM + else: + # normal operation: baseline removal + raw_signal.angle_rad -= self._baseline + if self._prev_timestamp is None or raw_signal.timestamp is None: self._prev_timestamp = raw_signal.timestamp return raw_signal diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index c9f7057..1909e82 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -218,6 +218,9 @@ class PreprocessorConfig: ) +# baseline removal using first N samples +BASELINE_REMOVAL_SAMPLE_NUM = 10 + # centering & normalization VALUE_NEAR_ZERO = 1e-6 From f977c99b2a9760ab61369c5600e5f920258b6bd4 Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Wed, 3 Jun 2026 14:57:13 +0200 Subject: [PATCH 07/13] fix reset --- .../control/signal_processing/sensor_preprocessor.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index dccfd7f..b80de88 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -76,9 +76,11 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: if self._baseline_count < BASELINE_REMOVAL_SAMPLE_NUM: self._baseline_count += 1 self._baseline_sum += raw_signal.angle_rad + + if self._baseline_count == BASELINE_REMOVAL_SAMPLE_NUM: + self._baseline = self._baseline_sum / BASELINE_REMOVAL_SAMPLE_NUM + raw_signal.angle_rad = 0.0 - elif self._baseline_count == BASELINE_REMOVAL_SAMPLE_NUM: - self._baseline = self._baseline_sum / BASELINE_REMOVAL_SAMPLE_NUM else: # normal operation: baseline removal raw_signal.angle_rad -= self._baseline @@ -174,7 +176,9 @@ def reset(self) -> None: :return: None """ self._prev_timestamp = None - + self._baseline: float = 0.0 + self._baseline_count: int = 0 + self._baseline_sum: float = 0.0 self._drift_removal.reset() self._filtering.reset() self._velocity_estimation.reset() From 3459cd93ac8bdd32cc845374b1013f69f3b2328c Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Mon, 15 Jun 2026 10:49:59 +0200 Subject: [PATCH 08/13] add kalman to filtering strategies, minor fixes --- src/hip_controller/__main__.py | 14 ++++- .../control/signal_processing/filtering.py | 50 +++++++++++++---- .../signal_processing/sensor_preprocessor.py | 35 ++++++++---- src/hip_controller/definitions.py | 26 +++++++-- src/hip_controller/filters/kalman_filter.py | 53 ++++++++++-------- .../still_to_implement_controlling.slx | Bin 0 -> 110345 bytes .../pre_process_testing/drift_removal_test.py | 2 +- .../pre_process_testing/filtering_test.py | 4 +- .../pre_process_testing/kalman_test.py | 9 ++- 9 files changed, 138 insertions(+), 55 deletions(-) create mode 100644 src/matlab-files/still_to_implement_controlling.slx diff --git a/src/hip_controller/__main__.py b/src/hip_controller/__main__.py index 4b4068c..cd6181b 100644 --- a/src/hip_controller/__main__.py +++ b/src/hip_controller/__main__.py @@ -25,6 +25,7 @@ def main( log_level: str = DEFAULT_LOG_LEVEL, stderr_level: str = DEFAULT_LOG_LEVEL, csv_path: Path = BasicConfig.read_data_from_path, + show_plot: bool = False, ) -> None: # pragma: no cover """Run the main pipeline. @@ -41,7 +42,9 @@ def main( app = QtWidgets.QApplication([]) player = CSVPlayer(csv_path) - config = BasicConfig(filtered=True) + config = BasicConfig( + filtered=True, left_limb_plot=show_plot, right_limb_plot=show_plot + ) controller_left = WalkOnController(left_limb=True, config=config) controller_right = WalkOnController(left_limb=False, config=config) @@ -102,10 +105,19 @@ def sigint_handler(signal, frame) -> None: required=False, type=Path, ) + + parser.add_argument( + "--graph-plot", + "-g", + help="Show PyQT6 plots.", + action="store_true", + ) + args = parser.parse_args() main( log_level=args.log_level, stderr_level=args.stderr_level, csv_path=args.file_path, + show_plot=args.graph_plot, ) diff --git a/src/hip_controller/control/signal_processing/filtering.py b/src/hip_controller/control/signal_processing/filtering.py index f08a3ef..8b5f3a1 100644 --- a/src/hip_controller/control/signal_processing/filtering.py +++ b/src/hip_controller/control/signal_processing/filtering.py @@ -10,7 +10,12 @@ from abc import ABC, abstractmethod -from hip_controller.definitions import LowPassFilterConfig, SogiFllConfig +from hip_controller.definitions import ( + KalmanFilterConfig, + LowPassFilterConfig, + SogiFllConfig, +) +from hip_controller.filters.kalman_filter import KalmanFilter from hip_controller.filters.second_order_low_pass_filter import ( SecondOrderLowPassFilter, ) @@ -62,12 +67,11 @@ def __init__(self, config: SogiFllConfig) -> None: def filter(self, angle_rad: float, time_difference: float) -> float: """Estimate velocity using SOGI phase-locked structure. - :param float angle: Drift-compensated angle [rad]. + :param float angle_rad: Drift-compensated angle [rad]. :param float time_difference: Time elapsed since previous sample [s]. - :param float gyro_velocity: Unused in this implementation. - :return: (angle_surrogate, velocity_quadrature). - :rtype: tuple[float, float] + :return: angle_surrogate. + :rtype: float """ angle_surrogate, _ = self._sogi_filter.filter( raw_theta_rad=angle_rad, time_difference=time_difference @@ -87,15 +91,12 @@ class LowPassFiltering(FilteringStrategy): The filter tracks the slow drift component; subtracting its output from the raw angle acts as a high-pass and yields *angle_no_drift_low_pass*. - - :param lpf: A configured :class:`SecondOrderLowPassFilter` instance whose - cut-off frequency sits well below the motion band. """ def __init__(self, config: LowPassFilterConfig) -> None: """Create a low-pass drift removal strategy. - :param SecondOrderLowPassFilter lpf: Low-pass filter for drift estimation. + :param config: Low-pass filter configuration for drift estimation. :return: None :rtype: None """ @@ -104,7 +105,7 @@ def __init__(self, config: LowPassFilterConfig) -> None: def filter(self, angle_rad: float, time_difference: float) -> float: """Execute one drift-removal step. - :param float raw_angle: Raw angle reading [rad]. + :param float angle_rad: Raw angle reading [rad]. :param float time_difference: Difference dt between current timestamp and previous timestamp. :return: Drift-compensated angle [rad]. :rtype: float @@ -120,3 +121,32 @@ def reset(self) -> None: :return: None """ self._low_pass_filter.reset() + + +class KalmanFiltering(FilteringStrategy): + """Kalman filter.""" + + def __init__(self, config: KalmanFilterConfig): + """Initialize the Kalman filter. + + :param config: Kalman filter configuration. + """ + self._kalman_filter = KalmanFilter(config=config) + + def filter(self, angle_rad: float, time_difference: float) -> float: + """Execute one Kalman filter step. + + :param angle_rad: Raw angle in rad. + :param time_difference: Difference dt between current timestamp and previous timestamp. + :return: Drift-compensated angle in rad. + """ + return self._kalman_filter.filter( + angle_rad=angle_rad, time_difference=time_difference + ) + + def reset(self) -> None: + """Reset the filter to a known initial condition. + + :return: None + """ + self._kalman_filter.reset() diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index b80de88..60ec45b 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -1,16 +1,17 @@ -"""Two-stage sensor preprocessing pipeline: drift removal followed by velocity estimation. +"""Three-stage sensor preprocessing pipeline: drift removal, filtering, and velocity estimation. -There are two strategies for drift removal and four strategies for velocity estimation implemented in the control module, which can be selected and configured in the :class:`PreprocessorConfig` when initializing the :class:`WalkOnController`. +There are two strategies for drift removal, three strategies for filtering, and three strategies for velocity estimation +implemented in the control module, which can be selected and configured in the :class:`PreprocessorConfig` when initializing the :class:`WalkOnController`. The drift removal strategies include: ``LowPassDriftRemoval`` and ``NotchDriftRemoval``. -The velocity estimation strategies include: ``SogifllVelocityEstimation``, ``LowPassVelocityEstimation``, ``DiscreteDerivativeVelocityEstimation``, and ``GyroscopeVelocityEstimation``. +The filtering strategies include: ``LowPassFiltering``, ``SogiFllFiltering``, and ``KalmanFiltering``. + +The velocity estimation strategies include: ``LowPassVelocityEstimation``, ``DiscreteDerivativeVelocityEstimation``, and ``GyroscopeVelocityEstimation``. """ from __future__ import annotations -from loguru import logger - from hip_controller.control.signal_processing.drift_removal import ( DriftRemovalStrategy, LowPassDriftRemoval, @@ -18,6 +19,7 @@ ) from hip_controller.control.signal_processing.filtering import ( FilteringStrategy, + KalmanFiltering, LowPassFiltering, SogiFllFiltering, ) @@ -50,7 +52,7 @@ class SensorPreprocessor: def __init__(self, basic_config: BasicConfig) -> None: """Initialize the sensor pre-processor. - :param PreprocessorConfig config: Preprocessor configuration. + :param basic_config: controller configuration. :return: None """ self._basic_config: BasicConfig = basic_config @@ -64,7 +66,7 @@ def __init__(self, basic_config: BasicConfig) -> None: self._baseline_count: int = 0 self._baseline_sum: float = 0.0 - self.__init_strategies__() + self._init_strategies() def filter(self, raw_signal: SensorSignal) -> SensorSignal: """Run one preprocessing step and return a :class:`SensorSignal`. @@ -120,7 +122,7 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: velocity_rad_per_sec=velocity_out_rad_per_sec, ) - def __init_strategies__(self): + def _init_strategies(self): """Get instance of different options of drift removal, filtering, and velocity estimation.""" if self._basic_config.drift_removal_method == DriftRemovalMethod.LOW_PASS: self._drift_removal = LowPassDriftRemoval( @@ -132,7 +134,9 @@ def __init_strategies__(self): PreprocessorConfig.drift_removal_notch_config ) else: - logger.warning("Selected method does not exist.") + raise ValueError( + f"Unrecognized drift-removal method: {self._basic_config.drift_removal_method}" + ) if self._basic_config.filtering_method == FilteringMethod.SOGI: self._filtering = SogiFllFiltering( @@ -144,8 +148,15 @@ def __init_strategies__(self): PreprocessorConfig.filtering_lowpass_config ) + elif self._basic_config.filtering_method == FilteringMethod.KALMAN: + self._filtering = KalmanFiltering( + PreprocessorConfig.filtering_kalman_config + ) + else: - logger.warning("Selected method does not exist.") + raise ValueError( + f"Unrecognized filtering method: {self._basic_config.filtering_method}" + ) if ( self._basic_config.velocity_estimation_method @@ -168,7 +179,9 @@ def __init_strategies__(self): self._velocity_estimation = GyroscopeVelocityEstimation() else: - logger.warning("Selected method does not exist.") + raise ValueError( + f"Unrecognized velocity-estimation method: {self._basic_config.velocity_estimation_method}" + ) def reset(self) -> None: """Reset the Signal Preprocessor if exosuit is disconnected or timeout occured. diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index 1909e82..f4c1a57 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -1,7 +1,9 @@ """Common definitions for this module.""" import sys -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field + +from numpy.typing import NDArray if sys.version_info >= (3, 11): from enum import StrEnum, auto @@ -17,6 +19,8 @@ class StrEnum(str, Enum): import numpy as np +from hip_controller.utils.state_space import StateSpaceLinear + np.set_printoptions(precision=3, floatmode="fixed", suppress=True) @@ -125,6 +129,21 @@ class LowPassFilterConfig: ) # SolverType enum of numerical integration strategy +@dataclass(frozen=True) +class KalmanFilterConfig: + """Settings for the Kalman filter.""" + + process_noise: NDArray = field(default_factory=lambda: 2e-2 * np.eye(2)) + measurement_noise: NDArray = field(default_factory=lambda: 0.75 * np.eye(1)) + state_space: StateSpaceLinear = field( + default_factory=lambda: StateSpaceLinear( + A=np.array([[1.0, 0.01], [0.0, 1.0]]), C=np.array([[1.0, 0.0]]) + ) + ) + initial_state: NDArray = field(default_factory=lambda: np.array([0.0, 0.0])) + initial_covariance: NDArray = field(default_factory=lambda: 10 * np.eye(2)) + + # Pre processing @@ -208,6 +227,7 @@ class PreprocessorConfig: filtering_sogifll_config: SogiFllConfig = SogiFllConfig() filtering_lowpass_config: LowPassFilterConfig = LowPassFilterConfig() + filtering_kalman_config: KalmanFilterConfig = KalmanFilterConfig() filtering_second_order_lpf_config: LowPassFilterConfig = LowPassFilterConfig( cut_off_frequency_rad_per_sec=80.0, damping_ratio=1.0, initial_condition=0.0 @@ -236,10 +256,6 @@ class PreprocessorConfig: SIGMOID_POWER = 50 AMPLITUDE_GAIN = -6.5 # Motor position desidered amplitude (rad) -# Kalman filter definitions -PROCESS_NOISE = 2e-2 -MEASUREMENT_NOISE = 0.75 - # Cubic Spline Interpolation @dataclass(frozen=True) diff --git a/src/hip_controller/filters/kalman_filter.py b/src/hip_controller/filters/kalman_filter.py index d3d8e2d..d120351 100644 --- a/src/hip_controller/filters/kalman_filter.py +++ b/src/hip_controller/filters/kalman_filter.py @@ -3,9 +3,8 @@ import numpy as np from numpy.typing import NDArray -from hip_controller.definitions import MEASUREMENT_NOISE, PROCESS_NOISE +from hip_controller.definitions import KalmanFilterConfig from hip_controller.utils.math_utils import symmetrize_matrix -from hip_controller.utils.state_space import StateSpaceLinear class KalmanFilter: @@ -13,32 +12,19 @@ class KalmanFilter: def __init__( self, - state_space: StateSpaceLinear, - initial_x: np.ndarray, - initial_covariance: np.ndarray, - process_noise: NDArray | None = None, - measurement_noise: NDArray | None = None, + config: KalmanFilterConfig, ) -> None: """Initialize the Kalman Filter. - :param state_space: linear state space model - :param initial_x: Initial state estimate - :param initial_covariance: Initial error covariance - :param process_noise: Process noise covariance - :param measurement_noise: Measurement noise covariance + :param config: Kalman filter configuration. :return: None """ - self.state_space = state_space - if process_noise is None: - process_noise = PROCESS_NOISE * np.eye(len(state_space.A)) - self.Q: np.ndarray = process_noise - - if measurement_noise is None: - measurement_noise = MEASUREMENT_NOISE * np.eye(len(state_space.C)) - self.R: np.ndarray = measurement_noise - - self.x: np.ndarray = initial_x - self.cov: np.ndarray = initial_covariance + self.config = config # save initial state for resets + self.state_space = config.state_space + self.Q: NDArray = config.process_noise + self.R: NDArray = config.measurement_noise + self.x: NDArray = config.initial_state + self.cov: NDArray = config.initial_covariance def predict(self, u: NDArray | None = None) -> None: """Predict the next state and error covariance. @@ -65,3 +51,24 @@ def update(self, z: NDArray) -> NDArray: self.cov = symmetrize_matrix(cov) return z - self.state_space.C @ self.x + + def filter(self, angle_rad: float, time_difference: float) -> float: + """Execute one filter step, containing a prediction step and an update step. + + :param angle_rad: Raw angle in rad. + :param time_difference: Difference dt between current timestamp and previous timestamp. + :return: Drift-compensated angle in rad. + """ + # modify the state transition matrix with the given time step + self.state_space.A[0, 1] = time_difference + self.predict(u=None) + self.update(z=np.array([angle_rad])) + return float(self.x[0]) + + def reset(self) -> None: + """Reset the Kalman filter to its initial condition. + + :return: None + """ + self.x = self.config.initial_state + self.cov = self.config.initial_covariance diff --git a/src/matlab-files/still_to_implement_controlling.slx b/src/matlab-files/still_to_implement_controlling.slx new file mode 100644 index 0000000000000000000000000000000000000000..578967d686d8a818e7742c933473c16d11e66319 GIT binary patch literal 110345 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 literal 0 HcmV?d00001 diff --git a/tests/controller_test/pre_process_testing/drift_removal_test.py b/tests/controller_test/pre_process_testing/drift_removal_test.py index abd06aa..2f40a0b 100644 --- a/tests/controller_test/pre_process_testing/drift_removal_test.py +++ b/tests/controller_test/pre_process_testing/drift_removal_test.py @@ -51,7 +51,7 @@ def test_low_pass_drift_removal() -> None: def test_notch_drift_removal() -> None: - """Test LowPassDriftRemoval against expected outputs.""" + """Test NotchDriftRemoval against expected outputs.""" # Load test data df = pd.read_csv(DATA_PRE_PROCESSING) diff --git a/tests/controller_test/pre_process_testing/filtering_test.py b/tests/controller_test/pre_process_testing/filtering_test.py index 99d0f8a..9af5bdf 100644 --- a/tests/controller_test/pre_process_testing/filtering_test.py +++ b/tests/controller_test/pre_process_testing/filtering_test.py @@ -3,7 +3,9 @@ from numpy import testing from pandas import read_csv -from hip_controller.control.signal_processing.filtering import SogiFllFiltering +from hip_controller.control.signal_processing.filtering import ( + SogiFllFiltering, +) from hip_controller.control.signal_processing.velocity_estimation import ( DiscreteDerivativeVelocityEstimation, ) diff --git a/tests/controller_test/pre_process_testing/kalman_test.py b/tests/controller_test/pre_process_testing/kalman_test.py index 7b21029..a836dc9 100644 --- a/tests/controller_test/pre_process_testing/kalman_test.py +++ b/tests/controller_test/pre_process_testing/kalman_test.py @@ -2,6 +2,7 @@ import numpy as np +from hip_controller.definitions import KalmanFilterConfig from hip_controller.filters.kalman_filter import KalmanFilter from hip_controller.utils.state_space import StateSpaceLinear @@ -22,12 +23,14 @@ def test_kalman_filter_initialization() -> None: C = np.eye(2) ss = StateSpaceLinear(A=A, C=C) - # Act - kf = KalmanFilter( + config = KalmanFilterConfig( state_space=ss, - initial_x=np.zeros((2, 1)), + initial_state=np.zeros((2, 1)), initial_covariance=np.eye(2), ) + + # Act + kf = KalmanFilter(config=config) for _i in range(10): kf.predict() _ = kf.update(z=np.array([[0.0]])) From 7a983996f90fea85dcc68496d424a5646809649a Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Mon, 15 Jun 2026 11:05:21 +0200 Subject: [PATCH 09/13] return preprocessor upon reset --- .../control/signal_processing/sensor_preprocessor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index 60ec45b..5421df6 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -99,6 +99,7 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: # check dt too big if time_difference > 1.0: self.reset() + return raw_signal self._prev_timestamp = raw_signal.timestamp From 00cf89d6e02704f130d962f22d9afece81c73889 Mon Sep 17 00:00:00 2001 From: NGierden Date: Mon, 8 Jun 2026 16:55:35 +0200 Subject: [PATCH 10/13] Wire per-sample locomotion mode classification; add CSV inspector Integrates the locomotion-mode pipeline end-to-end and adds supporting diagnostics + offline-replay tooling. Control logic: - __main__: read classification_left/right per row and call amplitude_modulation.set_mode each sample; respect main_switch with controller reset on the falling edge; add --fast batch mode that dumps every internal intermediate to _output.csv and opens the result in the new CSV inspector. - WalkOnController: expose last_filtered_signal and last_gait_phase_rad; safety-gate the motor command to 0 when the filtered angle is negative. - AmplitudeModulation: cache per-stage values in a new AmplitudeIntermediates dataclass; retune Ascend/Descend stair-mode scale/power parameters. - MotorReferenceController / SogiFllFiltering / SensorPreprocessor: expose last_* attributes for offline logging of internal signals. - PreprocessorConfig: new VelocityInputAngle option (RAW / DRIFT_REMOVED / FILTERED); default keeps the previous behavior. - definitions: retune SOGI cadence bounds, initial frequency guess, LPF cutoffs, AMPLITUDE_GAIN (-6.5 -> -7), PID P gain (14 -> 8). Add main_switch column constant. Tooling: - CSVPlayer: tolerate semicolon delimiters, European decimals, alternate header names, missing timestamp/velocity/main_switch/ classification columns. Returns a new PlayerStep bundle. - plotter: new Simulink-Data-Inspector-style csv_inspector module with linked-axis stacked subplots and per-subplot pan/zoom/pick tools. Runnable via 'python -m hip_controller.plotter'. - __init__: fall back to '0.0.0+unknown' when neither installed package metadata nor pyproject.toml is reachable. - Tests updated for the PlayerStep return type; new csv_inspector tests added. Also includes a small ruff-format-driven consolidation of duplicate imports in tests/conftest.py. Co-Authored-By: Claude Opus 4.7 --- scripts/controller_simulator.py | 29 +- src/hip_controller/__init__.py | 11 +- src/hip_controller/__main__.py | 298 +++++- src/hip_controller/control/app.py | 22 + .../amplitude_modulation.py | 53 +- .../motor_reference_controller.py | 6 + .../control/signal_processing/filtering.py | 9 +- .../signal_processing/sensor_preprocessor.py | 56 +- src/hip_controller/definitions.py | 54 +- src/hip_controller/plotter/__init__.py | 5 + src/hip_controller/plotter/__main__.py | 70 ++ src/hip_controller/plotter/csv_inspector.py | 955 ++++++++++++++++++ src/hip_controller/plotter/csv_player.py | 156 ++- tests/conftest.py | 11 +- tests/utils_test/csv_inspector_test.py | 69 ++ tests/utils_test/csv_player_test.py | 13 +- 16 files changed, 1718 insertions(+), 99 deletions(-) create mode 100644 src/hip_controller/plotter/__init__.py create mode 100644 src/hip_controller/plotter/__main__.py create mode 100644 src/hip_controller/plotter/csv_inspector.py create mode 100644 tests/utils_test/csv_inspector_test.py diff --git a/scripts/controller_simulator.py b/scripts/controller_simulator.py index 88f877c..b636cf6 100644 --- a/scripts/controller_simulator.py +++ b/scripts/controller_simulator.py @@ -12,25 +12,20 @@ from matplotlib import ticker from pyqtgraph import QtCore, QtWidgets # pragma: no cover - -from hip_controller.filters.second_order_low_pass_filter import ( - SecondOrderLowPassFilter, -) +from hip_controller.control.app import WalkOnController from hip_controller.definitions import ( DEFAULT_LOG_LEVEL, BasicConfig, LowPassFilterConfig, SolverType, - ExosuitData ) - -from src.hip_controller.plotter.csv_player import CSVPlayer -from dataclasses import dataclass -from scripts.csv_player import ScriptPlayer, ComparisonData -from hip_controller.control.app import WalkOnController -from scripts.live_comparison_plot import TimePlotterComparisonWindow +from hip_controller.filters.second_order_low_pass_filter import ( + SecondOrderLowPassFilter, +) from hip_controller.utils.utils import setup_logger - +from scripts.csv_player import ComparisonData, ScriptPlayer +from scripts.live_comparison_plot import TimePlotterComparisonWindow +from src.hip_controller.plotter.csv_player import CSVPlayer def simulate_comparison_dynamic( @@ -125,9 +120,9 @@ def update() -> None: - sensor_data : ExosuitData = player.get_sensor_data_from_csv() - controller_left.step(sensor_data.left) - controller_right.step(sensor_data.right) + step = player.get_sensor_data_from_csv() + controller_left.step(step.sensor_data.left) + controller_right.step(step.sensor_data.right) # setInterval in miliseconds. Update each 10ms timer.setInterval(10) @@ -275,7 +270,9 @@ def plot_notch_filter_debug( if __name__ == "__main__": simulate(input_name="x", expected_output_name="y", func=lpf.step, path=TESTING_DIR / "controller_test/low_level_testing/low_level_testing_data/second_order_lpf_2026_03_06.csv") """ - from hip_controller.control.signal_processing.sensor_preprocessor import SensorPreprocessor + from hip_controller.control.signal_processing.sensor_preprocessor import ( + SensorPreprocessor, + ) from hip_controller.definitions import PreprocessorConfig preprocessor = SensorPreprocessor(PreprocessorConfig()) diff --git a/src/hip_controller/__init__.py b/src/hip_controller/__init__.py index ea69e50..cbb7a9b 100644 --- a/src/hip_controller/__init__.py +++ b/src/hip_controller/__init__.py @@ -23,7 +23,10 @@ try: __version__ = version("hip-controller") except PackageNotFoundError: - # this path leads to: src/hip_controller/__init__.py → src/ → repo_root/ → pyproject.toml - pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml" - with open(pyproject, "rb") as f: - __version__ = dict(tomllib.load(f))["project"]["version"] + try: + # this path leads to: src/hip_controller/__init__.py → src/ → repo_root/ → pyproject.toml + pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml" + with open(pyproject, "rb") as f: + __version__ = dict(tomllib.load(f))["project"]["version"] + except FileNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/hip_controller/__main__.py b/src/hip_controller/__main__.py index cd6181b..98cda79 100644 --- a/src/hip_controller/__main__.py +++ b/src/hip_controller/__main__.py @@ -8,30 +8,83 @@ from pathlib import Path # pragma: no cover from loguru import logger +from pandas import DataFrame from pyqtgraph import QtCore, QtWidgets # pragma: no cover from hip_controller.control.app import WalkOnController +from hip_controller.control.motor_reference_control.amplitude_modulation import ( + AscendStairsMode, + DescendStairsMode, + LevelGroundMode, + ModeStrategy, +) from hip_controller.definitions import ( DEFAULT_LOG_LEVEL, BasicConfig, - ExosuitData, LogLevel, + RecordedSensorData, ) # pragma: no cover +from hip_controller.plotter.csv_inspector import plot as csv_inspector_plot from hip_controller.plotter.csv_player import CSVPlayer from hip_controller.utils.utils import setup_logger +MOTOR_LEFT_COLUMN = "motor_command_left (rad)" +MOTOR_RIGHT_COLUMN = "motor_command_right (rad)" +FILTERED_ANG_LEFT_COLUMN = "filtered_angle_left (rad)" +FILTERED_ANG_RIGHT_COLUMN = "filtered_angle_right (rad)" +FILTERED_VEL_LEFT_COLUMN = "filtered_vel_left (rad/s)" +FILTERED_VEL_RIGHT_COLUMN = "filtered_vel_right (rad/s)" +PORTRAIT_RADIUS_LEFT_COLUMN = "Portrait Radius Left" +PORTRAIT_RADIUS_RIGHT_COLUMN = "Portrait Radius Right" +SCALED_PORTRAIT_RADIUS_LEFT_COLUMN = "Scaled Portrait Radius Left" +SCALED_PORTRAIT_RADIUS_RIGHT_COLUMN = "Scaled Portrait Radius Right" +SIGMOID_SCALING_LEFT_COLUMN = "Sigmoid Scaling Left" +SIGMOID_SCALING_RIGHT_COLUMN = "Sigmoid Scaling Right" +SCALED_SIGMOID_SCALING_LEFT_COLUMN = "Scaled Sigmoid Scaling Left" +SCALED_SIGMOID_SCALING_RIGHT_COLUMN = "Scaled Sigmoid Scaling Right" +AMPLITUDE_LEFT_COLUMN = "Amplitude Left" +AMPLITUDE_RIGHT_COLUMN = "Amplitude Right" +GAIT_PHASE_LEFT_COLUMN = "Gait Phase Left (rad)" +GAIT_PHASE_RIGHT_COLUMN = "Gait Phase Right (rad)" +MOTION_MAPPING_LEFT_COLUMN = "Motion Mapping Left" +MOTION_MAPPING_RIGHT_COLUMN = "Motion Mapping Right" +VELOCITY_SURROGATE_LEFT_COLUMN = "Velocity Surrogate Left (rad/s)" +VELOCITY_SURROGATE_RIGHT_COLUMN = "Velocity Surrogate Right (rad/s)" +VELOCITY_LPF_ANGLE_LEFT_COLUMN = "Velocity-LPF Angle Left (rad)" +VELOCITY_LPF_ANGLE_RIGHT_COLUMN = "Velocity-LPF Angle Right (rad)" +DRIFT_REMOVED_ANGLE_LEFT_COLUMN = "Drift-Removed Angle Left (rad)" +DRIFT_REMOVED_ANGLE_RIGHT_COLUMN = "Drift-Removed Angle Right (rad)" -def main( +# Integer classification -> locomotion mode. Instances are cached so we don't +# rebuild a ModeStrategy on every sample. Unknown values fall back to Level +# Ground (the safe default that matches pre-classification behavior). +_MODES_BY_CLASSIFICATION: dict[int, ModeStrategy] = { + 0: LevelGroundMode(), + 1: AscendStairsMode(), + 2: DescendStairsMode(), +} + + +def _mode_for(classification: int) -> ModeStrategy: + """Map a classification integer to its locomotion-mode strategy.""" + return _MODES_BY_CLASSIFICATION.get(classification, _MODES_BY_CLASSIFICATION[0]) + + +def main( # noqa: PLR0915, C901 log_level: str = DEFAULT_LOG_LEVEL, stderr_level: str = DEFAULT_LOG_LEVEL, csv_path: Path = BasicConfig.read_data_from_path, - show_plot: bool = False, + fast: bool = False, ) -> None: # pragma: no cover """Run the main pipeline. :param log_level: The log level to use. :param stderr_level: The std err level to use. :param str csv_path: Path to the CSV file used for simulated real-time playback. The user could pass in the path of a file as well. + :param bool fast: When True, skip the live phase-portrait plots, process every CSV + row as fast as Python can, then open the resulting output CSV in the + :func:`hip_controller.plotter.csv_inspector.plot` window. When False + (default), runs in real-time with the live plot windows. :return: None # Example @@ -39,27 +92,206 @@ def main( """ setup_logger(log_level=log_level, stderr_level=stderr_level) + # QApplication is created unconditionally: fast mode still needs one for the + # csv_inspector_plot() call at the end, and live mode needs one for the + # phase-portrait windows. Reusing a single instance avoids a second + # QApplication construction inside csv_inspector_plot. app = QtWidgets.QApplication([]) player = CSVPlayer(csv_path) + plot = not fast config = BasicConfig( - filtered=True, left_limb_plot=show_plot, right_limb_plot=show_plot + filtered=False, left_limb_plot=plot, right_limb_plot=plot ) controller_left = WalkOnController(left_limb=True, config=config) controller_right = WalkOnController(left_limb=False, config=config) + timer = QtCore.QTimer() - def update() -> None: - """Update the controller with the next line of CSV data.""" - if not player.has_next_line(): - timer.stop() + # Track the previous main-switch state so we can reset the controllers on a + # falling edge (1 -> 0). The reset puts the preprocessor back into its + # "first call" state so that, on the next rising edge, velocity derivation + # starts fresh from the raw angle. + state = {"prev_switch": False} + + # Buffer of per-sample rows (inputs + motor commands) written to disk when + # playback finishes or the user interrupts with Ctrl+C. Values are mostly + # floats; main_switch and classification_* are ints, hence the wider type. + output_rows: list[dict[str, float | int]] = [] + output_path = csv_path.with_name(f"{csv_path.stem}_output.csv").resolve() + logger.info(f"Simulation results will be written to '{output_path}'.") + + def save_results() -> None: + """Persist the accumulated input/output rows to a CSV next to the input file.""" + if not output_rows: return + DataFrame(output_rows).to_csv(output_path, index=False) + logger.success(f"Saved {len(output_rows)} simulation rows to '{output_path}'.") + + def process_step() -> bool: + """Pull one row from the CSV, run the controllers, append to ``output_rows``. + + :return: ``True`` if a row was processed, ``False`` at end-of-file. + :rtype: bool + """ + if not player.has_next_line(): + return False + + step = player.get_sensor_data_from_csv() + sensor_data = step.sensor_data + main_switch = step.main_switch + + controller_left.amplitude_modulation.set_mode( + _mode_for(step.classification_left) + ) + controller_right.amplitude_modulation.set_mode( + _mode_for(step.classification_right) + ) - sensor_data: ExosuitData = player.get_sensor_data_from_csv() - controller_left.step(sensor_data.left) - controller_right.step(sensor_data.right) + if main_switch: + motor_command_left = controller_left.step(sensor_data.left) + motor_command_right = controller_right.step(sensor_data.right) + else: + if state["prev_switch"]: + controller_left.reset() + controller_right.reset() + motor_command_left = 0.0 + motor_command_right = 0.0 + state["prev_switch"] = main_switch + + # last_filtered_signal / last_intermediates / etc. are None before the + # first step or after a reset; write NaN in those cases via float('nan') + # so downstream consumers can distinguish "no value" from a real zero. + # Locals (rather than chained attribute access) so pyright can narrow + # the Optionals reliably when building the row dict below. + filt_left = controller_left.last_filtered_signal + filt_right = controller_right.last_filtered_signal + amp_left = controller_left.amplitude_modulation.last_intermediates + amp_right = controller_right.amplitude_modulation.last_intermediates + pre_left = controller_left.pre_processor + pre_right = controller_right.pre_processor + vel_surrogate_left = pre_left.last_velocity_surrogate_rad_per_sec + vel_surrogate_right = pre_right.last_velocity_surrogate_rad_per_sec + vel_lpf_left = pre_left.last_velocity_lpf_angle_rad + vel_lpf_right = pre_right.last_velocity_lpf_angle_rad + drift_left = pre_left.last_drift_removed_angle_rad + drift_right = pre_right.last_drift_removed_angle_rad + gait_phase_left = controller_left.last_gait_phase_rad + gait_phase_right = controller_right.last_gait_phase_rad + mapping_left = controller_left.motion_reference_controller.last_mapping_value + mapping_right = controller_right.motion_reference_controller.last_mapping_value + nan = float("nan") + # SensorSignal.timestamp is Optional in the dataclass; CSVPlayer always + # synthesizes one if the column is absent, so this is effectively never + # None in practice — but pyright can't see that. + timestamp_value = ( + sensor_data.left.timestamp + if sensor_data.left.timestamp is not None + else nan + ) + + output_rows.append( + { + RecordedSensorData.timestamp: timestamp_value, + RecordedSensorData.ang_left: sensor_data.left.angle_rad, + RecordedSensorData.ang_right: sensor_data.right.angle_rad, + RecordedSensorData.vel_left: sensor_data.left.velocity_rad_per_sec, + RecordedSensorData.vel_right: sensor_data.right.velocity_rad_per_sec, + RecordedSensorData.main_switch: int(main_switch), + "classification_left": step.classification_left, + "classification_right": step.classification_right, + FILTERED_ANG_LEFT_COLUMN: filt_left.angle_rad if filt_left else nan, + FILTERED_VEL_LEFT_COLUMN: ( + filt_left.velocity_rad_per_sec if filt_left else nan + ), + FILTERED_ANG_RIGHT_COLUMN: ( + filt_right.angle_rad if filt_right else nan + ), + FILTERED_VEL_RIGHT_COLUMN: ( + filt_right.velocity_rad_per_sec if filt_right else nan + ), + VELOCITY_SURROGATE_LEFT_COLUMN: ( + vel_surrogate_left if vel_surrogate_left is not None else nan + ), + VELOCITY_SURROGATE_RIGHT_COLUMN: ( + vel_surrogate_right if vel_surrogate_right is not None else nan + ), + VELOCITY_LPF_ANGLE_LEFT_COLUMN: ( + vel_lpf_left if vel_lpf_left is not None else nan + ), + VELOCITY_LPF_ANGLE_RIGHT_COLUMN: ( + vel_lpf_right if vel_lpf_right is not None else nan + ), + DRIFT_REMOVED_ANGLE_LEFT_COLUMN: ( + drift_left if drift_left is not None else nan + ), + DRIFT_REMOVED_ANGLE_RIGHT_COLUMN: ( + drift_right if drift_right is not None else nan + ), + PORTRAIT_RADIUS_LEFT_COLUMN: ( + amp_left.portrait_radius if amp_left else nan + ), + PORTRAIT_RADIUS_RIGHT_COLUMN: ( + amp_right.portrait_radius if amp_right else nan + ), + SCALED_PORTRAIT_RADIUS_LEFT_COLUMN: ( + amp_left.scaled_portrait_radius if amp_left else nan + ), + SCALED_PORTRAIT_RADIUS_RIGHT_COLUMN: ( + amp_right.scaled_portrait_radius if amp_right else nan + ), + SIGMOID_SCALING_LEFT_COLUMN: ( + amp_left.sigmoid_scaling if amp_left else nan + ), + SIGMOID_SCALING_RIGHT_COLUMN: ( + amp_right.sigmoid_scaling if amp_right else nan + ), + SCALED_SIGMOID_SCALING_LEFT_COLUMN: ( + amp_left.scaled_sigmoid_scaling if amp_left else nan + ), + SCALED_SIGMOID_SCALING_RIGHT_COLUMN: ( + amp_right.scaled_sigmoid_scaling if amp_right else nan + ), + AMPLITUDE_LEFT_COLUMN: amp_left.amplitude if amp_left else nan, + AMPLITUDE_RIGHT_COLUMN: amp_right.amplitude if amp_right else nan, + GAIT_PHASE_LEFT_COLUMN: ( + gait_phase_left if gait_phase_left is not None else nan + ), + GAIT_PHASE_RIGHT_COLUMN: ( + gait_phase_right if gait_phase_right is not None else nan + ), + MOTION_MAPPING_LEFT_COLUMN: ( + mapping_left if mapping_left is not None else nan + ), + MOTION_MAPPING_RIGHT_COLUMN: ( + mapping_right if mapping_right is not None else nan + ), + MOTOR_LEFT_COLUMN: motor_command_left, + MOTOR_RIGHT_COLUMN: motor_command_right, + } + ) + return True + + if fast: + # Process every row as fast as Python allows, save once, then hand + # off the result file to the CSV inspector for visual inspection. + while process_step(): + pass + save_results() + logger.info("Opening result in CSV inspector.") + csv_inspector_plot(output_path) + return + + # Live mode: drive the controllers from a Qt timer so the plot windows + # update in real time. + def update() -> None: + """Qt timer slot: process one row and reschedule the timer.""" + if not process_step(): + timer.stop() + save_results() + return # setInterval in miliseconds. Update each 10ms timer.setInterval(10) @@ -67,10 +299,27 @@ def sigint_handler(signal, frame) -> None: """Handle SIGINT (Ctrl+C) gracefully.""" logger.success("Keyboard interrupted with ^C.") timer.stop() + save_results() app.quit() timer.timeout.connect(slot=update) signal.signal(signal.SIGINT, sigint_handler) + + # Save results no matter how the app exits: end-of-CSV in update(), + # Ctrl+C in sigint_handler, or the user closing the plot windows. The + # aboutToQuit signal fires once at shutdown for all of these paths; + # save_results is idempotent (returns early when output_rows is empty), + # so duplicate calls from the EOF/Ctrl+C paths are harmless. + app.aboutToQuit.connect(save_results) + + # PyQt's event loop is implemented in C and doesn't yield to the Python + # interpreter often enough for signal handlers (Ctrl+C) to be delivered. + # A no-op QTimer firing every 200 ms forces a return to Python so the + # SIGINT handler installed above actually runs. + keepalive = QtCore.QTimer() + keepalive.timeout.connect(lambda: None) + keepalive.start(200) + timer.start(0) app.exec() @@ -100,24 +349,35 @@ def sigint_handler(signal, frame) -> None: "--file-path", "-p", default=Path(BasicConfig.read_data_from_path), - choices=list(LogLevel()), - help="Path to the CSV file used for simulated real-time playback. The file has to contain columns name 'angle_left (rad)', 'vel_left (rad/s)', 'angle_right (rad)', 'vel_right (rad/s)', additinally 'time (s)'.", + help=( + "Path to the CSV file used for simulated real-time playback. " + "Required columns: 'angle_left (rad)', 'angle_right (rad)'. " + "Optional columns: 'time (s)' (else synthesized from sample index), " + "'main_switch' (0/1 per row; defaults to 1 when absent), " + "'vel_left (rad/s)', 'vel_right (rad/s)' (else velocity is derived " + "from the raw angle by the controller's preprocessor), " + "'classification_left' / 'classification_right' (0=Level Ground, " + "1=Ascend Stairs, 2=Descend Stairs; defaults to 0 when absent). " + "Alternative header names are accepted, see CSVPlayer.COLUMN_ALIASES." + ), required=False, type=Path, ) - parser.add_argument( - "--graph-plot", - "-g", - help="Show PyQT6 plots.", + "--fast", + "-f", action="store_true", + help=( + "Run the simulation as fast as possible without the live phase-" + "portrait plot windows, then open the result CSV in the inspector. " + "Default (omitted) is live mode with real-time plots." + ), ) - args = parser.parse_args() main( log_level=args.log_level, stderr_level=args.stderr_level, csv_path=args.file_path, - show_plot=args.graph_plot, + fast=args.fast, ) diff --git a/src/hip_controller/control/app.py b/src/hip_controller/control/app.py index 43d4c6a..c64cf33 100644 --- a/src/hip_controller/control/app.py +++ b/src/hip_controller/control/app.py @@ -55,6 +55,15 @@ def __init__(self, left_limb: bool, config: BasicConfig): self._prev_timestamp: float | None = None + # Most recent signal passed downstream from the preprocessor (raw input + # when filtered=True, otherwise the filtered angle + derived velocity). + # Exposed so external code (e.g. the simulator) can log it. + self.last_filtered_signal: SensorSignal | None = None + + # Most recent gait phase produced by the gait controller (rad). None + # until the first step or after a reset. + self.last_gait_phase_rad: float | None = None + def step(self, curr_signal: SensorSignal) -> float: """Step the controller ahead. @@ -68,11 +77,13 @@ def step(self, curr_signal: SensorSignal) -> float: filtered_signal = curr_signal else: filtered_signal = self.pre_processor.filter(raw_signal=curr_signal) + self.last_filtered_signal = filtered_signal # Gait phase calculation gait_phase = self.gait_controller.update_and_compute( curr_signal=filtered_signal ) + self.last_gait_phase_rad = gait_phase # Apply amplitude modulation amplitude = self.amplitude_modulation.compute_amplitude(signal=filtered_signal) @@ -82,6 +93,13 @@ def step(self, curr_signal: SensorSignal) -> float: gait_phase=gait_phase, amplitude=amplitude ) + # Safety gate: only assist during hip flexion (positive angle). + # Negative filtered angle indicates extension / unclean signal -- in + # both cases driving the tendon further would be wrong, so cut the + # command to zero. + if filtered_signal.angle_rad < 0: + motor_command = 0.0 + # Plotting if self.plot and curr_signal.timestamp is not None: steady = self.gait_controller.get_signal_steady_state() @@ -100,3 +118,7 @@ def reset(self) -> None: """ # TODO add reset functions for gait controller, motor controller and so on.. self.pre_processor.reset() + self.last_filtered_signal = None + self.last_gait_phase_rad = None + self.amplitude_modulation.last_intermediates = None + self.motion_reference_controller.last_mapping_value = None diff --git a/src/hip_controller/control/motor_reference_control/amplitude_modulation.py b/src/hip_controller/control/motor_reference_control/amplitude_modulation.py index fa48c69..68b9e13 100644 --- a/src/hip_controller/control/motor_reference_control/amplitude_modulation.py +++ b/src/hip_controller/control/motor_reference_control/amplitude_modulation.py @@ -23,6 +23,28 @@ class ModeParameters: gain: float +@dataclass +class AmplitudeIntermediates: + """Per-sample intermediate values produced inside ``compute_amplitude``. + + Exposed so external code (e.g. the simulator) can log the full pipeline: + portrait radius -> scaled portrait radius -> sigmoid -> scaled sigmoid -> + final amplitude. + + :portrait_radius: ``sqrt(angle**2 + velocity**2)`` of the input signal. + :scaled_portrait_radius: ``portrait_radius * mode.scale``. + :sigmoid_scaling: Sigmoid output in [0, 1] (before gain/reverse). + :scaled_sigmoid_scaling: ``sigmoid_scaling * mode.gain`` (before reverse). + :amplitude: Final amplitude (``scaled_sigmoid_scaling * reverse``). + """ + + portrait_radius: float + scaled_portrait_radius: float + sigmoid_scaling: float + scaled_sigmoid_scaling: float + amplitude: float + + class ModeStrategy(ABC): """Abstract mode class.""" @@ -49,8 +71,8 @@ class AscendStairsMode(ModeStrategy): def get_parameters(self) -> ModeParameters: """Get parameters for ascending stairs.""" return ModeParameters( - scale=SCALE_LEVEL_MODE - 0.6, - sigmoid_power=SIGMOID_POWER + 100, + scale=SCALE_LEVEL_MODE - 0, # -0.6 + sigmoid_power=SIGMOID_POWER + 50, # +100 gain=AMPLITUDE_GAIN - 2, ) @@ -61,8 +83,8 @@ class DescendStairsMode(ModeStrategy): def get_parameters(self) -> ModeParameters: """Get parameters for descending stairs.""" return ModeParameters( - scale=SCALE_LEVEL_MODE - 0.5, - sigmoid_power=SIGMOID_POWER + 100, + scale=SCALE_LEVEL_MODE + 2.0, # -0.5 + sigmoid_power=SIGMOID_POWER + 50, # +100 gain=AMPLITUDE_GAIN + 0.5, ) @@ -80,6 +102,10 @@ def __init__(self, reverse: bool): else: self.reverse_amplitude: int = 1 + # Most recent per-stage values from compute_amplitude(); None until the + # first call. Exposed for logging by external code. + self.last_intermediates: AmplitudeIntermediates | None = None + def set_mode(self, mode: ModeStrategy): """Switch mode at runtime.""" self._mode = mode @@ -104,14 +130,23 @@ def compute_amplitude(self, signal: SensorSignal) -> float: """ params = self._mode.get_parameters() - scaled_portrait_radius = ( - self._compute_portrait_radius(signal=signal) * params.scale - ) + portrait_radius = self._compute_portrait_radius(signal=signal) + scaled_portrait_radius = portrait_radius * params.scale - amplitude = self.apply_sigmoid_scaling( + sigmoid_scaling = self.apply_sigmoid_scaling( value=scaled_portrait_radius, power=params.sigmoid_power ) - return (amplitude * params.gain) * self.reverse_amplitude + scaled_sigmoid_scaling = sigmoid_scaling * params.gain + amplitude = scaled_sigmoid_scaling * self.reverse_amplitude + + self.last_intermediates = AmplitudeIntermediates( + portrait_radius=portrait_radius, + scaled_portrait_radius=scaled_portrait_radius, + sigmoid_scaling=sigmoid_scaling, + scaled_sigmoid_scaling=scaled_sigmoid_scaling, + amplitude=amplitude, + ) + return amplitude @staticmethod def apply_sigmoid_scaling(value: float, power: int) -> float: diff --git a/src/hip_controller/control/motor_reference_control/motor_reference_controller.py b/src/hip_controller/control/motor_reference_control/motor_reference_controller.py index 63d4848..b9d334a 100644 --- a/src/hip_controller/control/motor_reference_control/motor_reference_controller.py +++ b/src/hip_controller/control/motor_reference_control/motor_reference_controller.py @@ -15,6 +15,11 @@ def __init__(self) -> None: # Initialize the mid-level controller with a 1-D Lookup Table for motion mapping. self.motion_mapping = MotionMapping() + # Most recent motion-mapping (cubic-spline) output, before amplitude + # scaling and saturation. None until the first compute_motor_command + # call or after a reset. Exposed for logging by external code. + self.last_mapping_value: float | None = None + def compute_motor_command(self, gait_phase: float, amplitude: float) -> float: """Compute the motor command based on the gait phase and amplitude. @@ -29,6 +34,7 @@ def compute_motor_command(self, gait_phase: float, amplitude: float) -> float: ) mapping_value = self.motion_mapping.spline(value=sinusoidal_behavior_gait_phase) + self.last_mapping_value = float(mapping_value) motor_command = mapping_value * amplitude diff --git a/src/hip_controller/control/signal_processing/filtering.py b/src/hip_controller/control/signal_processing/filtering.py index 8b5f3a1..4c5d5a9 100644 --- a/src/hip_controller/control/signal_processing/filtering.py +++ b/src/hip_controller/control/signal_processing/filtering.py @@ -64,6 +64,11 @@ def __init__(self, config: SogiFllConfig) -> None: """ self._sogi_filter: SogiFllFilter = SogiFllFilter(config=config) + # Quadrature output of the inner SOGI on the most recent call. This is + # a smoothed proxy for velocity (90 deg phase-shifted from + # angle_surrogate). Exposed so external code can log or gate on it. + self.last_quadrature: float = 0.0 + def filter(self, angle_rad: float, time_difference: float) -> float: """Estimate velocity using SOGI phase-locked structure. @@ -73,9 +78,10 @@ def filter(self, angle_rad: float, time_difference: float) -> float: :return: angle_surrogate. :rtype: float """ - angle_surrogate, _ = self._sogi_filter.filter( + angle_surrogate, quadrature = self._sogi_filter.filter( raw_theta_rad=angle_rad, time_difference=time_difference ) + self.last_quadrature = quadrature return angle_surrogate def reset(self) -> None: @@ -84,6 +90,7 @@ def reset(self) -> None: :return: None """ self._sogi_filter.reset() + self.last_quadrature = 0.0 class LowPassFiltering(FilteringStrategy): diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index 5421df6..9a1398d 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -37,6 +37,7 @@ PreprocessorConfig, SensorSignal, VelocityEstimationMethod, + VelocityInputAngle, ) @@ -56,6 +57,7 @@ def __init__(self, basic_config: BasicConfig) -> None: :return: None """ self._basic_config: BasicConfig = basic_config + self._velocity_input_angle = PreprocessorConfig.velocity_input_angle self._drift_removal: DriftRemovalStrategy self._filtering: FilteringStrategy @@ -68,6 +70,26 @@ def __init__(self, basic_config: BasicConfig) -> None: self._init_strategies() + # SOGI-FLL quadrature output from the most recent filter() call. + # Reflects a smoothed velocity-like signal (90 deg phase-shifted from + # the SOGI in-phase angle). None until the first non-trivial filter() + # call. Exposed for logging by external code. + self.last_velocity_surrogate_rad_per_sec: float | None = None + + # Angle as seen *inside* the velocity-estimation LPF, i.e. the LPF's + # smoothed output that is then differentiated to produce the velocity. + # For LowPassVelocityEstimation this is the second-order-LPF-filtered + # version of velocity_input_angle_rad; for other strategies it's the + # first element of their (angle, velocity) return tuple. Useful for + # diagnosing where velocity spikes come from. None on first call / + # after reset. + self.last_velocity_lpf_angle_rad: float | None = None + + # Output of the drift-removal stage (LPF subtraction or notch), + # measured between drift removal and SOGI. None on first call / + # after reset. + self.last_drift_removed_angle_rad: float | None = None + def filter(self, raw_signal: SensorSignal) -> SensorSignal: """Run one preprocessing step and return a :class:`SensorSignal`. @@ -106,16 +128,36 @@ def filter(self, raw_signal: SensorSignal) -> SensorSignal: angle_no_drift_rad = self._drift_removal.filter( raw_angle=raw_signal.angle_rad, time_difference=time_difference ) + self.last_drift_removed_angle_rad = angle_no_drift_rad angle_out_rad = self._filtering.filter( angle_rad=angle_no_drift_rad, time_difference=time_difference ) + # Surface the SOGI quadrature for downstream logging / experimentation. + # The SogiFllFiltering wrapper caches it on every filter() call; other + # FilteringStrategy implementations (none yet) would need to expose the + # same attribute. + self.last_velocity_surrogate_rad_per_sec = getattr( + self._filtering, "last_quadrature", None + ) - _, velocity_out_rad_per_sec = self._velocity_estimation.filter( - angle_rad=angle_out_rad, - time_difference=time_difference, - gyro_velocity_rad_per_sec=raw_signal.velocity_rad_per_sec, + # See PreprocessorConfig.velocity_input_angle for the trade-off between + # latency / smoothness (more filtering) and freshness (less filtering). + if self._velocity_input_angle == VelocityInputAngle.RAW: + velocity_input_angle_rad = raw_signal.angle_rad + elif self._velocity_input_angle == VelocityInputAngle.DRIFT_REMOVED: + velocity_input_angle_rad = angle_no_drift_rad + else: + velocity_input_angle_rad = angle_out_rad + + velocity_lpf_angle_rad, velocity_out_rad_per_sec = ( + self._velocity_estimation.filter( + angle_rad=velocity_input_angle_rad, + time_difference=time_difference, + gyro_velocity_rad_per_sec=raw_signal.velocity_rad_per_sec, + ) ) + self.last_velocity_lpf_angle_rad = velocity_lpf_angle_rad return SensorSignal( timestamp=raw_signal.timestamp, @@ -192,7 +234,11 @@ def reset(self) -> None: self._prev_timestamp = None self._baseline: float = 0.0 self._baseline_count: int = 0 - self._baseline_sum: float = 0.0 + self._baseline_sum: float = 0.0 + self.last_velocity_surrogate_rad_per_sec = None + self.last_velocity_lpf_angle_rad = None + self.last_drift_removed_angle_rad = None + self._drift_removal.reset() self._filtering.reset() self._velocity_estimation.reset() diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index f4c1a57..2d37878 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -119,9 +119,7 @@ class BasicConfig: class LowPassFilterConfig: """Settings for the second-order low-pass filter containing cut_off_frequency, damping_ratio, initial_condition, solver_type.""" - cut_off_frequency_rad_per_sec: float = ( - BasicConfig.cut_off_freq_low_pass_rad_per_sec - ) # in rad/s + cut_off_frequency_rad_per_sec: float = 60.0 # in rad/s damping_ratio: float = 1.0 # 1.0 = critically damped initial_condition: float = 0.0 solver_type: SolverType = ( @@ -174,36 +172,36 @@ class SogiFllConfig: """ # cadence bounds (walking/running range) - lower_cadence_bound: float = 0.2 - upper_cadence_bound: float = 4.0 + lower_cadence_bound: float = 0.5 # 0.2 -> extremely slow walking + upper_cadence_bound: float = 1.8 # 4.0 -> very fast running - # Tune only if the portrait is ringy or too sluggish: + # Tune only if the portrait is ringy or too sluggish:s # - increase to 1.2-1.4 if theta/theta_quad look underdamped / not tracking well # - decrease to 0.8-0.9 if very noisy and jitter is observed - sogi_adaptation_gain: float = 1.0 + sogi_adaptation_gain: float = 1.0 # 0.7 #1.0 # Frequency adaptation speed: # - increase to track speed changes faster # - decrease if noisy/jittery (sensor/noise dependent) - fll_adaptation_gain: float = 1.0 + fll_adaptation_gain: float = 1.0 # 1.0 # lock thresholds (amplitude/noise dependent) - lower_energy_threshold: float = 1e-4 - upper_energy_threshold: float = 1e-2 + lower_energy_threshold: float = 1e-4 # 5e-4 #1e-4 + upper_energy_threshold: float = 1e-2 # 5e-2 #1e-2 # Tune only if internal frequency becomes jittery or too laggy: # - decrease to 0.2 for smoother (more lag) # - increase to 0.5 for faster (more jitter) - frequency_estimate_smoother_bandwidth: float = 0.30 + frequency_estimate_smoother_bandwidth: float = 0.30 # 0.20 #0.30 # Tune only if lock flickers or reacts too slowly: # - decrease (0.3) to reduce flicker # - increase (0.8-1.0) for faster start/stop response - lock_state_smoother_bandwidth: float = 0.50 + lock_state_smoother_bandwidth: float = 0.50 # 0.30 #0.50 # [Hz] initial guess (walking/running general default) # Tune only if you want faster lock at startup: # - set near typical cadence in your trials (walk ~1-2 Hz, run ~2-3 Hz) - initial_frequency_guess: float = 1.4 + initial_frequency_guess: float = 1.0 # 1.0 # % state decay when standing # Tune only if oscillator rings too long after stopping: @@ -216,9 +214,28 @@ class SogiFllConfig: numerical_safety_floor: float = 1e-9 + +class VelocityInputAngle(StrEnum): + """Which angle is fed to the velocity-estimation stage. + + RAW -- ``raw_signal.angle_rad`` straight from the sensor. + DRIFT_REMOVED -- output of the drift-removal stage (LPF or notch). + FILTERED -- output of the SOGI-FLL stage (current default). + """ + + RAW = auto() + DRIFT_REMOVED = auto() + FILTERED = auto() + + class PreprocessorConfig: """Configurations for the sensor preprocessor.""" + # Selects which angle is fed into the velocity-estimation stage. + # See VelocityInputAngle for the options. Default keeps the historical + # behavior (use the SOGI-FLL filtered angle). + velocity_input_angle: VelocityInputAngle = VelocityInputAngle.FILTERED + # Configurations for the filters drift_removal_second_order_lpf_config: LowPassFilterConfig = LowPassFilterConfig( cut_off_frequency_rad_per_sec=1.25, damping_ratio=1.0, initial_condition=0.0 @@ -230,9 +247,8 @@ class PreprocessorConfig: filtering_kalman_config: KalmanFilterConfig = KalmanFilterConfig() filtering_second_order_lpf_config: LowPassFilterConfig = LowPassFilterConfig( - cut_off_frequency_rad_per_sec=80.0, damping_ratio=1.0, initial_condition=0.0 + cut_off_frequency_rad_per_sec=90.0, damping_ratio=1.0, initial_condition=0.0 ) - velocity_estimation_low_pass_config: LowPassFilterConfig = LowPassFilterConfig( cut_off_frequency_rad_per_sec=20.0, damping_ratio=1.0, initial_condition=0.0 ) @@ -253,8 +269,9 @@ class PreprocessorConfig: # Amplitude modulation SCALE_LEVEL_MODE = 1 -SIGMOID_POWER = 50 -AMPLITUDE_GAIN = -6.5 # Motor position desidered amplitude (rad) +SIGMOID_POWER = 50 # 50 +AMPLITUDE_GAIN = -7 # Motor position desidered amplitude (rad) + # Cubic Spline Interpolation @@ -333,6 +350,7 @@ class RecordedSensorData: vel_left: str = "vel_left (rad/s)" ang_right: str = "angle_right (rad)" vel_right: str = "vel_right (rad/s)" + main_switch: str = "main_switch" fake_frequency_hz: int = BasicConfig.frequency @@ -341,7 +359,7 @@ class RecordedSensorData: class PIDConfig: """Configurations for PID controller.""" - proportional_gain: float = 14.0 + proportional_gain: float = 8.0 integral_gain: float = 0.0 derivative_gain: float = 0.02 output_limits: tuple[float, float] | None = None diff --git a/src/hip_controller/plotter/__init__.py b/src/hip_controller/plotter/__init__.py new file mode 100644 index 0000000..9757353 --- /dev/null +++ b/src/hip_controller/plotter/__init__.py @@ -0,0 +1,5 @@ +"""Plotting utilities for the hip controller package.""" + +from hip_controller.plotter.csv_inspector import plot + +__all__ = ["plot"] diff --git a/src/hip_controller/plotter/__main__.py b/src/hip_controller/plotter/__main__.py new file mode 100644 index 0000000..30761c2 --- /dev/null +++ b/src/hip_controller/plotter/__main__.py @@ -0,0 +1,70 @@ +"""Command-line entry point for the modular CSV plotter. + +Usage:: + + python -m hip_controller.plotter path/to/file.csv [--frequency 100] + python -m hip_controller.plotter path/to/file.csv --no-time-only-zoom +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from hip_controller.definitions import BasicConfig +from hip_controller.plotter.csv_inspector import plot + + +def main(argv: list[str] | None = None) -> int: + """Parse CLI arguments and launch the CSV inspector. + + :param argv: optional argument list (defaults to ``sys.argv[1:]``); exposed + to make the entry point easy to drive from tests. + :type argv: list[str] or None + :return: process exit code (always 0 once the GUI window closes). + :rtype: int + """ + parser = argparse.ArgumentParser( + prog="python -m hip_controller.plotter", + description=( + "Modular CSV plotter (Simulink-Data-Inspector-style) for the " + "hip-controller package. The X axis is synthesized from " + "--frequency; no time column is required in the CSV." + ), + ) + parser.add_argument( + "csv", + type=Path, + help="Path to a CSV file with a header row.", + ) + parser.add_argument( + "--frequency", + type=int, + default=BasicConfig.frequency, + help=( + "Sampling frequency in Hz used to synthesize the time axis. " + f"Defaults to BasicConfig.frequency ({BasicConfig.frequency})." + ), + ) + parser.add_argument( + "--no-time-only-zoom", + dest="time_only_zoom", + action="store_false", + help=( + "Start with both X and Y zoom enabled. By default the Y axis is " + "locked and only the time axis responds to the mouse wheel." + ), + ) + parser.set_defaults(time_only_zoom=True) + args = parser.parse_args(argv) + + plot( + csv_path=args.csv, + frequency_hz=args.frequency, + time_only_zoom=args.time_only_zoom, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/hip_controller/plotter/csv_inspector.py b/src/hip_controller/plotter/csv_inspector.py new file mode 100644 index 0000000..1d06f17 --- /dev/null +++ b/src/hip_controller/plotter/csv_inspector.py @@ -0,0 +1,955 @@ +"""Modular CSV plotter for the hip controller. + +Provides a Simulink-Data-Inspector-style GUI for inspecting CSV recordings: + +- Vertically stacked subplots with a linked (shared) time axis. +- A *single* signal panel on the left: click a subplot to make it "active", + then tick which CSV columns appear in that subplot. +- Each subplot has a small overlaid toolbar in its upper-right corner that + switches mouse-interaction modes: + + * Pan -- left-drag translates the view + * T-Zoom -- left-drag pans; wheel zooms X only (default) + * Zoom -- left-drag draws a zoom rectangle; wheel zooms X and Y + * Pick -- click a data point to read its value in the status bar + +- The X axis is synthesized from the sampling frequency + (``BasicConfig.frequency`` by default), so the CSV need not carry its own + time column. + +Public entry point: :func:`plot`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, ClassVar + +import numpy as np +import pandas as pd +import pyqtgraph as pg +from loguru import logger +from pandas.api.types import is_numeric_dtype +from PyQt6 import QtCore, QtGui, QtWidgets + +from hip_controller.definitions import BasicConfig + +# Column names matched case-insensitively against these prefixes are treated +# as timestamp columns and excluded from the plottable signal list, because +# the X axis is synthesized from the sample frequency. +_TIME_COLUMN_PREFIXES: tuple[str, ...] = ("time", "timestamp", "t (") + + +def discover_plottable_columns(dataframe: pd.DataFrame) -> list[str]: + """Return the subset of CSV columns that should appear as selectable signals. + + Keeps only numeric columns and drops anything whose header looks like a + timestamp, because the time axis is synthesized from the sample frequency + rather than read from the file. + + :param pandas.DataFrame dataframe: parsed CSV. + :return: ordered list of plottable column names. + :rtype: list[str] + """ + plottable: list[str] = [] + for col in dataframe.columns: + if not is_numeric_dtype(dataframe[col]): + continue + lowered = str(col).lower().strip() + if any(lowered.startswith(prefix) for prefix in _TIME_COLUMN_PREFIXES): + continue + plottable.append(str(col)) + return plottable + + +def synthesize_time_vector(n_samples: int, frequency_hz: int) -> np.ndarray: + """Synthesize a uniform time vector (seconds) from a sample count and frequency. + + :param int n_samples: number of rows in the CSV. + :param int frequency_hz: sampling frequency (samples per second). + :return: 1-D array of timestamps in seconds, length ``n_samples``. + :rtype: numpy.ndarray + :raises ValueError: if ``frequency_hz`` is non-positive. + """ + if frequency_hz <= 0: + raise ValueError(f"frequency_hz must be positive, got {frequency_hz}.") + return np.arange(n_samples, dtype=np.float64) / float(frequency_hz) + + +class _ColorSwatch(QtWidgets.QPushButton): # pragma: no cover + """Small color square that opens a color picker when clicked.""" + + color_changed = QtCore.pyqtSignal(QtGui.QColor) + + def __init__( + self, initial: QtGui.QColor, parent: QtWidgets.QWidget | None = None + ) -> None: + """Build a swatch displaying ``initial`` and emitting on user changes.""" + super().__init__(parent) + self._color: QtGui.QColor = QtGui.QColor(initial) + self.setFixedSize(18, 18) + self.setToolTip("Click to change this signal's line color.") + self._refresh_style() + self.clicked.connect(self._on_clicked) + + def color(self) -> QtGui.QColor: + """Return the swatch's current color.""" + return QtGui.QColor(self._color) + + def set_color(self, color: QtGui.QColor) -> None: + """Set the swatch color without emitting ``color_changed``.""" + self._color = QtGui.QColor(color) + self._refresh_style() + + def _refresh_style(self) -> None: + rgba = self._color + self.setStyleSheet( + f"background-color: rgba({rgba.red()}, {rgba.green()}, " + f"{rgba.blue()}, {rgba.alpha()});" + "border: 1px solid #555; border-radius: 2px;", + ) + + def _on_clicked(self) -> None: + picked = QtWidgets.QColorDialog.getColor( + self._color, + self, + "Pick line color", + ) + if picked.isValid(): + self._color = picked + self._refresh_style() + self.color_changed.emit(picked) + + +class _SubplotWidget(QtWidgets.QFrame): # pragma: no cover + """One subplot: a ``pyqtgraph.PlotWidget`` plus an overlaid mode toolbar. + + Owns its curves and legend so the parent window only has to manage + high-level layout (how many subplots and which columns go where). + + Signals: + + * ``activated()`` -- emitted on any user interaction inside this subplot; + the parent uses it to know which subplot the side-panel checkboxes + should target. + * ``point_picked(time_sec, value, name)`` -- emitted in Pick mode when the + user clicks near a data point; the parent displays the readout. + """ + + MODE_PAN: str = "pan" + MODE_TIME_ZOOM: str = "time_zoom" + MODE_GENERAL_ZOOM: str = "general_zoom" + MODE_PICKER: str = "picker" + + activated = QtCore.pyqtSignal() + point_picked = QtCore.pyqtSignal(float, float, str) + + def __init__( + self, + index: int, + initial_mode: str = MODE_TIME_ZOOM, + parent: QtWidgets.QWidget | None = None, + ) -> None: + """Build one subplot with its own plot widget and mode toolbar. + + :param int index: zero-based subplot index, used for the title. + :param str initial_mode: starting mouse-interaction mode. + :param QtWidgets.QWidget parent: optional Qt parent. + """ + super().__init__(parent) + self.setObjectName("subplotFrame") + self.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + + self._index: int = index + self._mode: str = initial_mode + + self.plot_widget: pg.PlotWidget = pg.PlotWidget() + self.plot_widget.showGrid(x=True, y=True, alpha=0.3) + self.plot_widget.setLabel("bottom", "time", units="s") + self.plot_widget.setTitle(f"Subplot {index + 1}") + self._legend: pg.LegendItem = self.plot_widget.addLegend(offset=(10, 10)) + + # Curves currently displayed: column name → PlotDataItem. + self.curves: dict[str, pg.PlotDataItem] = {} + + # Marker shown in Pick mode. + self._pick_marker: pg.ScatterPlotItem | None = None + self._pick_label: pg.TextItem | None = None + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.plot_widget) + + self._toolbar, self._mode_buttons = self._build_mode_toolbar() + self._toolbar.setParent(self) + self._toolbar.raise_() + + self.set_active(False) + # scene() is typed as Optional in PyQt stubs and sigMouseClicked is a + # pyqtgraph-specific signal that PyQt's stubs don't know about. + scene = self.plot_widget.scene() + assert scene is not None + scene.sigMouseClicked.connect(self._on_scene_clicked) # pyright: ignore[reportAttributeAccessIssue] + self.set_mode(self._mode) + + # --- toolbar construction ---------------------------------------- + + def _build_mode_toolbar( + self, + ) -> tuple[QtWidgets.QFrame, dict[str, QtWidgets.QToolButton]]: + """Build the floating mode toolbar shown in the plot's upper-right corner.""" + bar = QtWidgets.QFrame() + bar.setObjectName("modeBar") + bar.setStyleSheet( + "#modeBar { background: rgba(255, 255, 255, 220); " + "border: 1px solid #888; border-radius: 4px; }" + "QToolButton { padding: 2px 6px; }" + "QToolButton:checked { background: #cfe1f7; border: 1px solid #4a90e2; " + "border-radius: 3px; }", + ) + row = QtWidgets.QHBoxLayout(bar) + row.setContentsMargins(3, 3, 3, 3) + row.setSpacing(2) + + buttons: dict[str, QtWidgets.QToolButton] = {} + group = QtWidgets.QButtonGroup(bar) + group.setExclusive(True) + + entries: list[tuple[str, str, str]] = [ + (self.MODE_PAN, "Pan", "Pan: left-drag translates the view."), + ( + self.MODE_TIME_ZOOM, + "T-Zoom", + "Time-only zoom: wheel zooms the X axis; Y auto-fits (default).", + ), + ( + self.MODE_GENERAL_ZOOM, + "Zoom", + "General zoom: left-drag draws a zoom rectangle; wheel zooms X and Y.", + ), + ( + self.MODE_PICKER, + "Pick", + "Data cursor: click near a curve point to read its value.", + ), + ] + for mode, label, tooltip in entries: + btn = QtWidgets.QToolButton() + btn.setText(label) + btn.setToolTip(tooltip) + btn.setCheckable(True) + btn.setAutoRaise(True) + btn.clicked.connect(self.activated.emit) + btn.clicked.connect(lambda _checked, m=mode: self.set_mode(m)) + group.addButton(btn) + row.addWidget(btn) + buttons[mode] = btn + return bar, buttons + + # --- curve management -------------------------------------------- + + def add_curve( + self, + name: str, + time_sec: np.ndarray, + y_values: np.ndarray, + pen: QtGui.QPen, + ) -> None: + """Plot one column on this subplot (no-op if already present).""" + if name in self.curves: + return + item = self.plot_widget.plot(time_sec, y_values, pen=pen, name=name) + self.curves[name] = item + self._update_left_label() + + def remove_curve(self, name: str) -> None: + """Remove one column from this subplot (no-op if absent).""" + item = self.curves.pop(name, None) + if item is None: + return + self.plot_widget.removeItem(item) + try: + self._legend.removeItem(name) + except (KeyError, AttributeError): + # Older pyqtgraph builds may raise if the entry is already gone. + pass + self._clear_pick_marker() + self._update_left_label() + + def remove_all_curves(self) -> None: + """Remove every curve currently shown on this subplot.""" + for name in list(self.curves.keys()): + self.remove_curve(name) + + def set_curve_pen(self, name: str, pen: QtGui.QPen) -> None: + """Update an existing curve's pen (color/width) in place.""" + item = self.curves.get(name) + if item is None: + return + item.setPen(pen) + # Re-stamp the legend sample so its swatch reflects the new pen. + try: + self._legend.removeItem(name) + except (KeyError, AttributeError): + pass + self._legend.addItem(item, name) + + def _update_left_label(self) -> None: + """Show the column name on the Y axis when exactly one curve is plotted.""" + if len(self.curves) == 1: + self.plot_widget.setLabel("left", next(iter(self.curves))) + else: + self.plot_widget.setLabel("left", "") + + # --- mode handling ----------------------------------------------- + + def set_mode(self, mode: str) -> None: + """Switch this subplot's mouse-interaction mode. + + :param str mode: one of the ``MODE_*`` class constants. + """ + self._mode = mode + btn = self._mode_buttons.get(mode) + if btn is not None and not btn.isChecked(): + btn.setChecked(True) + self._apply_mode() + if mode != self.MODE_PICKER: + self._clear_pick_marker() + + def _apply_mode(self) -> None: + """Configure ViewBox and cursor to match ``self._mode``.""" + plot_item = self.plot_widget.getPlotItem() + assert plot_item is not None + view_box = plot_item.getViewBox() + assert view_box is not None + viewport = self.plot_widget.viewport() + assert viewport is not None + if self._mode == self.MODE_PAN: + view_box.setMouseMode(pg.ViewBox.PanMode) + view_box.setMouseEnabled(x=True, y=True) + viewport.setCursor(QtCore.Qt.CursorShape.OpenHandCursor) + elif self._mode == self.MODE_TIME_ZOOM: + view_box.setMouseMode(pg.ViewBox.PanMode) + view_box.setMouseEnabled(x=True, y=False) + # Intentionally do NOT re-enable Y auto-range here: switching INTO + # T-Zoom should preserve whatever Y range the user has set in + # another mode. Y is just locked from mouse input, not refit. + viewport.setCursor(QtCore.Qt.CursorShape.SizeHorCursor) + elif self._mode == self.MODE_GENERAL_ZOOM: + view_box.setMouseMode(pg.ViewBox.RectMode) + view_box.setMouseEnabled(x=True, y=True) + viewport.setCursor(QtCore.Qt.CursorShape.CrossCursor) + elif self._mode == self.MODE_PICKER: + view_box.setMouseMode(pg.ViewBox.PanMode) + view_box.setMouseEnabled(x=False, y=False) + viewport.setCursor(QtCore.Qt.CursorShape.CrossCursor) + + # --- active styling ---------------------------------------------- + + def set_active(self, active: bool) -> None: + """Toggle the visual highlight that marks the active subplot.""" + if active: + self.setStyleSheet( + "#subplotFrame { border: 2px solid #4a90e2; border-radius: 3px; }", + ) + else: + self.setStyleSheet( + "#subplotFrame { border: 1px solid #cccccc; border-radius: 3px; }", + ) + + # --- click handling (activate + picker) -------------------------- + + def _on_scene_clicked(self, event: Any) -> None: + """Activate this subplot on any click; in Pick mode, report the nearest point. + + ``event`` is a ``pg.GraphicsScene.mouseEvents.MouseClickEvent`` at + runtime, but that internal pyqtgraph type isn't exposed via stubs, so + we accept ``Any`` rather than chasing a private import. + """ + self.activated.emit() + if self._mode != self.MODE_PICKER: + return + if event.button() != QtCore.Qt.MouseButton.LeftButton: + return + plot_item = self.plot_widget.getPlotItem() + assert plot_item is not None + view_box = plot_item.getViewBox() + assert view_box is not None + scene_pos = event.scenePos() + if not self.plot_widget.sceneBoundingRect().contains(scene_pos): + return + view_point = view_box.mapSceneToView(scene_pos) + nearest = self._find_nearest_point( + click_x=float(view_point.x()), + click_y=float(view_point.y()), + ) + if nearest is None: + return + time_sec, value, name = nearest + self._show_pick_marker(time_sec=time_sec, value=value, name=name) + self.point_picked.emit(time_sec, value, name) + + def _find_nearest_point( + self, + click_x: float, + click_y: float, + ) -> tuple[float, float, str] | None: + """Return ``(x, y, curve_name)`` for the data point closest to the click.""" + plot_item = self.plot_widget.getPlotItem() + assert plot_item is not None + view_box = plot_item.getViewBox() + assert view_box is not None + pixel_w, pixel_h = view_box.viewPixelSize() + pixel_w = pixel_w or 1.0 + pixel_h = pixel_h or 1.0 + + best: tuple[float, float, str] | None = None + best_dist = float("inf") + for name, item in self.curves.items(): + data = item.getData() + if data is None: + continue + x_data, y_data = data + if x_data is None or y_data is None or len(x_data) == 0: + continue + idx = int(np.argmin(np.abs(x_data - click_x))) + x_val = float(x_data[idx]) + y_val = float(y_data[idx]) + dx = (x_val - click_x) / pixel_w + dy = (y_val - click_y) / pixel_h + dist = (dx * dx + dy * dy) ** 0.5 + if dist < best_dist: + best_dist = dist + best = (x_val, y_val, name) + return best + + def _show_pick_marker(self, time_sec: float, value: float, name: str) -> None: + """Draw / move the picker marker and its text label at the given point.""" + if self._pick_marker is None: + self._pick_marker = pg.ScatterPlotItem( + size=12, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush(255, 80, 80, 220), + ) + self.plot_widget.addItem(self._pick_marker) + self._pick_marker.setData([time_sec], [value]) + + if self._pick_label is None: + self._pick_label = pg.TextItem(anchor=(0.0, 1.0), color="k") + self.plot_widget.addItem(self._pick_label) + self._pick_label.setText(f"{name}\nt={time_sec:.3f}s, y={value:.4g}") + self._pick_label.setPos(time_sec, value) + + def _clear_pick_marker(self) -> None: + """Remove the picker marker / label from this subplot if present.""" + if self._pick_marker is not None: + self.plot_widget.removeItem(self._pick_marker) + self._pick_marker = None + if self._pick_label is not None: + self.plot_widget.removeItem(self._pick_label) + self._pick_label = None + + # --- layout ------------------------------------------------------ + + def resizeEvent(self, a0: QtGui.QResizeEvent | None) -> None: # noqa: N802 + """Keep the mode toolbar anchored to the upper-right corner. + + ``a0`` is named to match the PyQt6 base-class signature so the + override is recognized by the type checker. + """ + super().resizeEvent(a0) + self._toolbar.adjustSize() + margin = 6 + x = self.width() - self._toolbar.width() - margin + self._toolbar.move(max(0, x), margin) + + +class CSVInspectorWindow(QtWidgets.QMainWindow): # pragma: no cover + """Main window for the modular CSV plotter. + + The user picks the number of subplots from the left panel, clicks a + subplot to make it active, then ticks which CSV columns appear in it. + All subplots share their X axis, so panning / zooming time stays in + sync. + """ + + _MAX_SUBPLOTS: int = 8 + + # Class-level reference list that keeps every open inspector window alive + # so the garbage collector doesn't reap one when the "Open in New Window" + # handler returns. Cleared per-window in closeEvent. + _open_windows: ClassVar[list[CSVInspectorWindow]] = [] + + def __init__( + self, + csv_path: Path, + frequency_hz: int = BasicConfig.frequency, + time_only_zoom: bool = True, + ) -> None: + """Build the GUI for one CSV file. + + :param pathlib.Path csv_path: path to a CSV file with a header row. + :param int frequency_hz: sampling frequency in Hz used to synthesize + the time axis. Defaults to ``BasicConfig.frequency``. + :param bool time_only_zoom: starting mouse-interaction mode for every + subplot. ``True`` (default) selects the time-only zoom mode, which + matches the Simulink Data Inspector feel. ``False`` selects the + general (X+Y) zoom mode. + """ + super().__init__() + + pg.setConfigOption("background", "w") + pg.setConfigOption("foreground", "k") + pg.setConfigOption("antialias", True) + + self._csv_path: Path = Path(csv_path) + self._frequency_hz: int = int(frequency_hz) + self._initial_mode: str = ( + _SubplotWidget.MODE_TIME_ZOOM + if time_only_zoom + else _SubplotWidget.MODE_GENERAL_ZOOM + ) + + self._dataframe: pd.DataFrame = pd.DataFrame() + self._columns: list[str] = [] + self._time_sec: np.ndarray = np.empty(0, dtype=np.float64) + self._column_colors: dict[str, QtGui.QColor] = {} + self._subplot_signals: list[set[str]] = [] + self._subplots: list[_SubplotWidget] = [] + self._signal_checkboxes: dict[str, QtWidgets.QCheckBox] = {} + self._signal_swatches: dict[str, _ColorSwatch] = {} + self._active_index: int = 0 + + self._load_csv(self._csv_path) + self._init_column_colors() + self._subplot_signals = [set(self._columns[: min(2, len(self._columns))])] + + self.resize(1200, 800) + self.setWindowTitle(f"CSV Inspector — {self._csv_path.name}") + status_bar = self.statusBar() + assert status_bar is not None + status_bar.showMessage("Ready.") + + self._build_menu() + self._build_layout() + self._apply_layout() + + # Register so a strong reference outlives the constructing scope. + CSVInspectorWindow._open_windows.append(self) + + # --- data loading ------------------------------------------------- + + def _init_column_colors(self) -> None: + """Assign a stable default color to each column from pyqtgraph's palette.""" + self._column_colors = {} + hues = max(len(self._columns), 6) + for idx, col in enumerate(self._columns): + self._column_colors[col] = pg.intColor(idx, hues=hues) + + def _load_csv(self, csv_path: Path) -> None: + """Read a CSV from disk and refresh ``_columns`` / ``_time_sec``. + + :raises ValueError: if the CSV exposes no numeric, non-time columns. + """ + logger.info(f"Loading CSV '{csv_path}'.") + self._dataframe = pd.read_csv(csv_path) + self._columns = discover_plottable_columns(self._dataframe) + if not self._columns: + raise ValueError( + f"CSV '{csv_path}' has no numeric (non-time) columns to plot.", + ) + self._time_sec = synthesize_time_vector( + n_samples=len(self._dataframe), + frequency_hz=self._frequency_hz, + ) + + # --- UI construction ---------------------------------------------- + + def _build_menu(self) -> None: + """Construct the File and View menus. + + QMainWindow.menuBar(), QMenuBar.addMenu(), and QMenu.addAction() are + all typed as Optional in the PyQt stubs even though they always return + a real object on a QMainWindow that owns a menu bar. Asserts narrow + the types for the checker without changing runtime behavior. + """ + menu = self.menuBar() + assert menu is not None + file_menu = menu.addMenu("&File") + assert file_menu is not None + + open_action = file_menu.addAction("&Open CSV…") + assert open_action is not None + open_action.setShortcut("Ctrl+O") + open_action.triggered.connect(self._on_open_csv) + + open_new_action = file_menu.addAction("Open CSV in &New Window…") + assert open_new_action is not None + open_new_action.setShortcut("Ctrl+Shift+O") + open_new_action.triggered.connect(self._on_open_csv_new_window) + + file_menu.addSeparator() + quit_action = file_menu.addAction("&Quit") + assert quit_action is not None + quit_action.setShortcut("Ctrl+Q") + quit_action.triggered.connect(self.close) + + view_menu = menu.addMenu("&View") + assert view_menu is not None + reset_action = view_menu.addAction("Reset view (auto-range)") + assert reset_action is not None + reset_action.setShortcut("Ctrl+R") + reset_action.triggered.connect(self._on_reset_view) + + def _build_layout(self) -> None: + """Build the central plot area and the left-side signal panel.""" + self._plot_area = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + self.setCentralWidget(self._plot_area) + + dock = QtWidgets.QDockWidget("Signals", self) + dock.setAllowedAreas( + QtCore.Qt.DockWidgetArea.LeftDockWidgetArea + | QtCore.Qt.DockWidgetArea.RightDockWidgetArea, + ) + + panel = QtWidgets.QWidget(dock) + outer = QtWidgets.QVBoxLayout(panel) + outer.setContentsMargins(8, 8, 8, 8) + + outer.addWidget(QtWidgets.QLabel(f"File: {self._csv_path.name}")) + outer.addWidget( + QtWidgets.QLabel( + f"X axis: time (s) synthesized at {self._frequency_hz} Hz", + ), + ) + + count_row = QtWidgets.QHBoxLayout() + count_row.addWidget(QtWidgets.QLabel("Number of subplots:")) + self._count_spin = QtWidgets.QSpinBox() + self._count_spin.setRange(1, self._MAX_SUBPLOTS) + self._count_spin.setValue(len(self._subplot_signals)) + self._count_spin.valueChanged.connect(self._on_subplot_count_changed) + count_row.addWidget(self._count_spin) + count_row.addStretch(1) + outer.addLayout(count_row) + + outer.addWidget(_make_separator()) + + self._active_label = QtWidgets.QLabel() + self._active_label.setStyleSheet("font-weight: bold;") + outer.addWidget(self._active_label) + + outer.addWidget( + QtWidgets.QLabel("Tick a column to add it to the active subplot:"), + ) + + signals_box = QtWidgets.QGroupBox("Signals") + signals_layout = QtWidgets.QVBoxLayout(signals_box) + self._populate_signal_rows(signals_layout) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(signals_box) + outer.addWidget(scroll, stretch=1) + + dock.setWidget(panel) + self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, dock) + + # --- layout sync -------------------------------------------------- + + def _apply_layout(self) -> None: + """Reconcile the GUI with the current ``_subplot_signals`` state.""" + n_subplots = len(self._subplot_signals) + self._sync_subplots(n_subplots) + self._active_index = min(self._active_index, n_subplots - 1) + self._refresh_curves() + self._refresh_signal_checkboxes() + self._refresh_active_indicator() + for subplot in self._subplots: + subplot.plot_widget.enableAutoRange(axis="x", enable=True) + + def _sync_subplots(self, n_subplots: int) -> None: + """Add or remove subplot widgets to match ``n_subplots`` and re-link X axes.""" + while len(self._subplots) > n_subplots: + subplot = self._subplots.pop() + subplot.remove_all_curves() + subplot.setParent(None) + subplot.deleteLater() + + while len(self._subplots) < n_subplots: + idx = len(self._subplots) + subplot = _SubplotWidget(index=idx, initial_mode=self._initial_mode) + subplot.activated.connect( + lambda i=idx: self._on_subplot_activated(i), + ) + subplot.point_picked.connect(self._on_point_picked) + self._plot_area.addWidget(subplot) + self._subplots.append(subplot) + + if self._subplots: + base_view = self._subplots[0].plot_widget + for subplot in self._subplots[1:]: + subplot.plot_widget.setXLink(base_view) + + def _refresh_curves(self) -> None: + """Reconcile curves on every subplot against ``_subplot_signals``.""" + for i, subplot in enumerate(self._subplots): + desired = self._subplot_signals[i] + for stale in set(subplot.curves.keys()) - desired: + subplot.remove_curve(stale) + for col in sorted(desired): + if col in subplot.curves: + continue + y_values = self._dataframe[col].to_numpy(dtype=np.float64) + subplot.add_curve( + name=col, + time_sec=self._time_sec, + y_values=y_values, + pen=self._pen_for_column(col), + ) + + def _refresh_signal_checkboxes(self) -> None: + """Sync the side-panel checkboxes to the active subplot's signal set.""" + if not self._subplots: + return + active_set = self._subplot_signals[self._active_index] + for col, cb in self._signal_checkboxes.items(): + cb.blockSignals(True) + cb.setChecked(col in active_set) + cb.blockSignals(False) + + def _refresh_active_indicator(self) -> None: + """Update the 'Editing: Subplot N' label and per-subplot border highlight.""" + for i, subplot in enumerate(self._subplots): + subplot.set_active(i == self._active_index) + if self._subplots: + self._active_label.setText(f"Editing: Subplot {self._active_index + 1}") + else: + self._active_label.setText("") + + def _pen_for_column(self, column: str) -> QtGui.QPen: + """Return the pen for ``column`` using its currently-selected color.""" + color = self._column_colors.get(column) or QtGui.QColor("#888888") + return pg.mkPen(color=color, width=2) + + def _populate_signal_rows(self, layout: QtWidgets.QVBoxLayout) -> None: + """Build one [color-swatch][checkbox] row per column into ``layout``.""" + for col in self._columns: + row = QtWidgets.QWidget() + row_layout = QtWidgets.QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(6) + + swatch = _ColorSwatch(self._column_colors[col]) + swatch.color_changed.connect( + lambda color, name=col: self._on_color_changed(name, color), + ) + row_layout.addWidget(swatch) + + cb = QtWidgets.QCheckBox(col) + cb.toggled.connect( + lambda checked, name=col: self._on_signal_toggled(name, checked), + ) + row_layout.addWidget(cb, stretch=1) + + layout.addWidget(row) + self._signal_checkboxes[col] = cb + self._signal_swatches[col] = swatch + layout.addStretch(1) + + # --- slots -------------------------------------------------------- + + def _on_subplot_count_changed(self, value: int) -> None: + """Handle the subplot-count spin box: grow or shrink the model.""" + current = len(self._subplot_signals) + if value > current: + for _ in range(value - current): + self._subplot_signals.append(set()) + else: + self._subplot_signals = self._subplot_signals[:value] + self._apply_layout() + + def _on_signal_toggled(self, column: str, checked: bool) -> None: + """Add or remove ``column`` from the active subplot.""" + if not self._subplots: + return + target = self._subplot_signals[self._active_index] + if checked: + target.add(column) + else: + target.discard(column) + self._refresh_curves() + + def _on_subplot_activated(self, index: int) -> None: + """Make ``index`` the active subplot (the one the side panel edits).""" + if index == self._active_index: + return + self._active_index = index + self._refresh_signal_checkboxes() + self._refresh_active_indicator() + + def _on_point_picked(self, time_sec: float, value: float, name: str) -> None: + """Display the picked data point in the status bar.""" + status_bar = self.statusBar() + assert status_bar is not None + status_bar.showMessage( + f"{name} t = {time_sec:.4f} s y = {value:.6g}", + ) + + def _on_color_changed(self, column: str, color: QtGui.QColor) -> None: + """Update the stored color for ``column`` and restyle every curve using it.""" + self._column_colors[column] = QtGui.QColor(color) + pen = self._pen_for_column(column) + for subplot in self._subplots: + subplot.set_curve_pen(column, pen) + swatch = self._signal_swatches.get(column) + if swatch is not None: + swatch.set_color(color) + + def _on_reset_view(self) -> None: + """Auto-range both axes on every subplot.""" + for subplot in self._subplots: + subplot.plot_widget.enableAutoRange(axis="x", enable=True) + subplot.plot_widget.enableAutoRange(axis="y", enable=True) + + def _on_open_csv_new_window(self) -> None: + """Open a CSV in a *new* inspector window (this one stays open). + + Useful for comparing two or more recordings side-by-side. The new + window is appended to ``CSVInspectorWindow._open_windows`` so it + survives past the end of this method. + """ + path_str, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Open CSV in New Window", + str(self._csv_path.parent), + "CSV files (*.csv)", + ) + if not path_str: + return + try: + new_window = CSVInspectorWindow( + csv_path=Path(path_str), + frequency_hz=self._frequency_hz, + time_only_zoom=self._initial_mode == _SubplotWidget.MODE_TIME_ZOOM, + ) + except (ValueError, OSError, pd.errors.ParserError) as exc: + QtWidgets.QMessageBox.critical(self, "Failed to load CSV", str(exc)) + return + new_window.show() + + def closeEvent(self, a0: QtGui.QCloseEvent | None) -> None: # noqa: N802 + """Drop ourselves from the global open-windows list on close. + + ``a0`` is named to match the PyQt6 base-class signature so the + override is recognized by the type checker. + """ + try: + CSVInspectorWindow._open_windows.remove(self) + except ValueError: + pass + super().closeEvent(a0) + + def _on_open_csv(self) -> None: + """Open a new CSV in the running window via a file dialog.""" + path_str, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Open CSV", + str(self._csv_path.parent), + "CSV files (*.csv)", + ) + if not path_str: + return + new_path = Path(path_str) + try: + self._load_csv(new_path) + except (ValueError, OSError, pd.errors.ParserError) as exc: + QtWidgets.QMessageBox.critical(self, "Failed to load CSV", str(exc)) + return + + self._csv_path = new_path + self.setWindowTitle(f"CSV Inspector — {new_path.name}") + + for subplot in self._subplots: + subplot.remove_all_curves() + subplot.setParent(None) + subplot.deleteLater() + self._subplots.clear() + + self._init_column_colors() + self._subplot_signals = [set(self._columns[: min(2, len(self._columns))])] + self._active_index = 0 + self._count_spin.blockSignals(True) + self._count_spin.setValue(1) + self._count_spin.blockSignals(False) + self._rebuild_signal_checkboxes() + self._apply_layout() + + def _rebuild_signal_checkboxes(self) -> None: + """Rebuild the side-panel signal rows against the current columns.""" + signals_box = self._find_signals_groupbox() + if signals_box is None: + return + layout = signals_box.layout() + # _build_layout() always installs a QVBoxLayout here, but QGroupBox.layout() + # is typed as Optional[QLayout]. Narrow it for both pyright and runtime. + if not isinstance(layout, QtWidgets.QVBoxLayout): + return + while layout.count(): + item = layout.takeAt(0) + if item is None: + break + widget = item.widget() + if widget is not None: + widget.setParent(None) + widget.deleteLater() + self._signal_checkboxes.clear() + self._signal_swatches.clear() + self._populate_signal_rows(layout) + + def _find_signals_groupbox(self) -> QtWidgets.QGroupBox | None: + """Locate the 'Signals' group box inside the side-panel dock.""" + for dock in self.findChildren(QtWidgets.QDockWidget): + for box in dock.findChildren(QtWidgets.QGroupBox): + if box.title() == "Signals": + return box + return None + + +def _make_separator() -> QtWidgets.QFrame: # pragma: no cover + """Return a thin horizontal divider for use in the side panel.""" + line = QtWidgets.QFrame() + line.setFrameShape(QtWidgets.QFrame.Shape.HLine) + line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + return line + + +def plot( + csv_path: str | Path, + frequency_hz: int = BasicConfig.frequency, + time_only_zoom: bool = True, +) -> None: + """Open the modular CSV inspector for the given file. + + The time axis is synthesized as ``numpy.arange(n_samples) / frequency_hz``; + no time column is required in the CSV. + + :param csv_path: path to a CSV file with a header row. + :type csv_path: str or pathlib.Path + :param int frequency_hz: sampling frequency in Hz used to synthesize the + time axis. Defaults to ``BasicConfig.frequency``. + :param bool time_only_zoom: starting interaction mode for every subplot. + ``True`` (default) is the Simulink-Data-Inspector-style time-only + zoom; ``False`` is general (X + Y) zoom. Either mode can also be + switched per subplot from its in-plot toolbar. + """ + path = Path(csv_path) + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + window = CSVInspectorWindow( + csv_path=path, + frequency_hz=frequency_hz, + time_only_zoom=time_only_zoom, + ) + window.show() + app.exec() diff --git a/src/hip_controller/plotter/csv_player.py b/src/hip_controller/plotter/csv_player.py index ce18b2c..6d31aaf 100644 --- a/src/hip_controller/plotter/csv_player.py +++ b/src/hip_controller/plotter/csv_player.py @@ -1,5 +1,6 @@ """Stateful CSV player that simulates real-time data arrival.""" +from dataclasses import dataclass from pathlib import Path from loguru import logger @@ -7,6 +8,65 @@ from hip_controller.definitions import ExosuitData, RecordedSensorData, SensorSignal +# Default classification value used when the Classification Left / Right +# columns are absent (or contain NaN / unmappable values). Maps to Level +# Ground mode in the application-level dispatch. +DEFAULT_CLASSIFICATION = 0 + +# Accepted header names for each logical column. The first entry is the +# canonical name (matches RecordedSensorData where applicable) and is what +# the tests / data pipeline write out; the others are tolerated on input so +# externally produced CSVs (e.g. MATLAB exports) don't need to be renamed +# before playback. +COLUMN_ALIASES: dict[str, tuple[str, ...]] = { + "timestamp": (RecordedSensorData.timestamp, "Time [s]", "time"), + "ang_left": (RecordedSensorData.ang_left, "Angle Left Raw [rad]"), + "ang_right": (RecordedSensorData.ang_right, "Angle Right Raw [rad]"), + "vel_left": (RecordedSensorData.vel_left, "Vel Left Raw [rad]"), + "vel_right": (RecordedSensorData.vel_right, "Vel Right Raw [rad]"), + "main_switch": (RecordedSensorData.main_switch, "Main Switch"), + "classification_left": ("classification_left", "Classification Left"), + "classification_right": ("classification_right", "Classification Right"), +} + + +@dataclass +class PlayerStep: + """One row of CSV playback: sensor signals + per-sample control inputs. + + :sensor_data: Raw (or recorded) left/right angle and velocity signals + plus the timestamp shared by both legs. + :main_switch: Whether the controller should run this sample (``True``) or + be held idle (``False``). Defaults to ``True`` when the column is absent. + :classification_left: Locomotion-mode classification for the left leg + (0 = Level Ground, 1 = Ascend Stairs, 2 = Descend Stairs). Defaults to + :data:`DEFAULT_CLASSIFICATION` when the column is absent. + :classification_right: Locomotion-mode classification for the right leg. + """ + + sensor_data: ExosuitData + main_switch: bool + classification_left: int + classification_right: int + + +def _resolve_column( + available_columns: list[str], candidates: tuple[str, ...] +) -> str | None: + """Return the first candidate header present in ``available_columns``. + + :param list[str] available_columns: Column names found in the loaded CSV. + :param tuple[str, ...] candidates: Accepted header names for one logical + column, ordered by preference (canonical first). + :return: The matching column name, or ``None`` if none of the candidates + are present. + :rtype: str | None + """ + for name in candidates: + if name in available_columns: + return name + return None + class CSVPlayer: """The CSV file is loaded fully once using pandas. @@ -25,13 +85,44 @@ def __init__(self, csv_path: Path) -> None: :param str csv_path: Path to the CSV file containing time, angle, and velocity columns. Default takes the file path from RecordedSensorData setup in definitions. """ logger.info(f"Loading CSV file '{csv_path}'.") - self.dataframe = read_csv(csv_path) + # sep=None + engine='python' lets pandas sniff the delimiter, so both + # comma- and semicolon-separated files load without manual configuration. + # decimal=',' covers European-locale exports (e.g. MATLAB on German + # systems) that write "3,14" instead of "3.14". + self.dataframe = read_csv(csv_path, sep=None, engine="python", decimal=",") + # Strip incidental whitespace from headers so " angle_left (rad)" still + # matches "angle_left (rad)". + self.dataframe.columns = [str(c).strip() for c in self.dataframe.columns] self.counter = 0 - self.has_timestamp: bool = ( - RecordedSensorData.timestamp in self.dataframe.columns + available = list(self.dataframe.columns) + self._col_timestamp = _resolve_column(available, COLUMN_ALIASES["timestamp"]) + self._col_ang_left = _resolve_column(available, COLUMN_ALIASES["ang_left"]) + self._col_ang_right = _resolve_column(available, COLUMN_ALIASES["ang_right"]) + self._col_vel_left = _resolve_column(available, COLUMN_ALIASES["vel_left"]) + self._col_vel_right = _resolve_column(available, COLUMN_ALIASES["vel_right"]) + self._col_main_switch = _resolve_column( + available, COLUMN_ALIASES["main_switch"] + ) + self._col_classification_left = _resolve_column( + available, COLUMN_ALIASES["classification_left"] + ) + self._col_classification_right = _resolve_column( + available, COLUMN_ALIASES["classification_right"] ) + if self._col_ang_left is None or self._col_ang_right is None: + raise KeyError( + "CSV is missing a left/right hip-angle column. Expected one of " + f"{COLUMN_ALIASES['ang_left']} and one of " + f"{COLUMN_ALIASES['ang_right']}. Found columns: {available}" + ) + + @property + def has_timestamp(self) -> bool: + """Whether a timestamp column was found in the CSV.""" + return self._col_timestamp is not None + def has_next_line(self) -> bool: """Check whether more data is available. @@ -40,28 +131,67 @@ def has_next_line(self) -> bool: """ return self.counter < len(self.dataframe) - def get_sensor_data_from_csv(self) -> ExosuitData: + def get_sensor_data_from_csv(self) -> PlayerStep: """Get the recorded data from csv line by line. - :return: timestamp, angle_left, velocity_left, angle_right, velocity_right packed together as an Exosuit dataclass - :rtype: ExosuitData + Velocity columns are optional: when missing, ``velocity_rad_per_sec`` is + set to 0.0 and the controller is expected to derive velocity from the + raw angle internally (run with ``filtered=False``). + + The main switch column is optional: when missing it defaults to ``True`` + (controller always active). + + The classification columns are optional: when missing they default to + :data:`DEFAULT_CLASSIFICATION` (Level Ground). + + :return: :class:`PlayerStep` bundling sensor signals, main switch and + per-leg locomotion classifications for this sample. + :rtype: PlayerStep """ row = self.dataframe.iloc[self.counter] self.counter += 1 - if self.has_timestamp: - timestamp = float(row[RecordedSensorData.timestamp]) + if self._col_timestamp is not None: + timestamp = float(row[self._col_timestamp]) else: timestamp = self.counter / RecordedSensorData.fake_frequency_hz - return ExosuitData( + vel_left = ( + float(row[self._col_vel_left]) if self._col_vel_left is not None else 0.0 + ) + vel_right = ( + float(row[self._col_vel_right]) if self._col_vel_right is not None else 0.0 + ) + main_switch = ( + bool(row[self._col_main_switch]) + if self._col_main_switch is not None + else True + ) + classification_left = ( + int(row[self._col_classification_left]) + if self._col_classification_left is not None + else DEFAULT_CLASSIFICATION + ) + classification_right = ( + int(row[self._col_classification_right]) + if self._col_classification_right is not None + else DEFAULT_CLASSIFICATION + ) + + exosuit_data = ExosuitData( left=SensorSignal( timestamp=timestamp, - angle_rad=float(row[RecordedSensorData.ang_left]), - velocity_rad_per_sec=float(row[RecordedSensorData.vel_left]), + angle_rad=float(row[self._col_ang_left]), + velocity_rad_per_sec=vel_left, ), right=SensorSignal( timestamp=timestamp, - angle_rad=float(row[RecordedSensorData.ang_right]), - velocity_rad_per_sec=float(row[RecordedSensorData.vel_right]), + angle_rad=float(row[self._col_ang_right]), + velocity_rad_per_sec=vel_right, ), ) + return PlayerStep( + sensor_data=exosuit_data, + main_switch=main_switch, + classification_left=classification_left, + classification_right=classification_right, + ) diff --git a/tests/conftest.py b/tests/conftest.py index c5b9478..736e420 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,16 +4,7 @@ import sys from pathlib import Path -if sys.version_info >= (3, 11): - from enum import StrEnum -else: - from enum import Enum - - class StrEnum(str, Enum): - """String enum backport for Python <3.11.""" - - -from hip_controller.definitions import TESTING_DIR +from hip_controller.definitions import TESTING_DIR, StrEnum # Add the src directory to the path so that the quaternion_ekf package can be imported my_path = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/utils_test/csv_inspector_test.py b/tests/utils_test/csv_inspector_test.py new file mode 100644 index 0000000..84f7ab2 --- /dev/null +++ b/tests/utils_test/csv_inspector_test.py @@ -0,0 +1,69 @@ +"""Tests for the pure helpers in :mod:`hip_controller.plotter.csv_inspector`. + +The GUI itself (``CSVInspectorWindow``) is not unit-tested because it requires +a Qt event loop and a display; it is annotated ``# pragma: no cover`` for the +same reason as ``live_phase_portrait.py``. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +from pytest import raises + +from hip_controller.plotter.csv_inspector import ( + discover_plottable_columns, + synthesize_time_vector, +) + + +def test_discover_plottable_columns_filters_time_and_strings() -> None: + """Time-like and non-numeric columns must be excluded.""" + df = pd.DataFrame( + { + "time (s)": [0.0, 0.01, 0.02], + "angle_left (rad)": [0.1, 0.2, 0.3], + "vel_left (rad/s)": [1.0, 1.1, 1.2], + "label": ["a", "b", "c"], + }, + ) + assert discover_plottable_columns(df) == [ + "angle_left (rad)", + "vel_left (rad/s)", + ] + + +def test_discover_plottable_columns_preserves_csv_column_order() -> None: + """The output order must follow the CSV's column order, not be re-sorted.""" + df = pd.DataFrame( + { + "b_signal": [1.0, 2.0], + "a_signal": [3.0, 4.0], + "Timestamp": [0.0, 0.1], + }, + ) + assert discover_plottable_columns(df) == ["b_signal", "a_signal"] + + +def test_discover_plottable_columns_handles_empty_dataframe() -> None: + """An empty CSV yields an empty signal list without raising.""" + assert discover_plottable_columns(pd.DataFrame()) == [] + + +def test_synthesize_time_vector_uses_inverse_frequency() -> None: + """t[i] must equal i / frequency_hz.""" + time_sec = synthesize_time_vector(n_samples=4, frequency_hz=100) + np.testing.assert_allclose(time_sec, [0.0, 0.01, 0.02, 0.03]) + + +def test_synthesize_time_vector_length_matches_n_samples() -> None: + """The returned vector must have exactly n_samples entries.""" + assert synthesize_time_vector(n_samples=250, frequency_hz=50).shape == (250,) + + +def test_synthesize_time_vector_rejects_non_positive_frequency() -> None: + """Zero or negative frequency must raise ValueError.""" + with raises(ValueError): + synthesize_time_vector(n_samples=10, frequency_hz=0) + with raises(ValueError): + synthesize_time_vector(n_samples=10, frequency_hz=-100) diff --git a/tests/utils_test/csv_player_test.py b/tests/utils_test/csv_player_test.py index 560cf02..2593130 100644 --- a/tests/utils_test/csv_player_test.py +++ b/tests/utils_test/csv_player_test.py @@ -48,17 +48,22 @@ def test_csv_player_reads_rows_in_order(tmp_path): player = CSVPlayer(csv_path) - t0 = player.get_sensor_data_from_csv() - t1 = player.get_sensor_data_from_csv() + step0 = player.get_sensor_data_from_csv() + step1 = player.get_sensor_data_from_csv() - assert t0 == ExosuitData( + assert step0.sensor_data == ExosuitData( left=SensorSignal(timestamp=0.0, angle_rad=1.0, velocity_rad_per_sec=0.1), right=SensorSignal(timestamp=0.0, angle_rad=4.0, velocity_rad_per_sec=0.4), ) - assert t1 == ExosuitData( + assert step1.sensor_data == ExosuitData( left=SensorSignal(timestamp=0.1, angle_rad=2.0, velocity_rad_per_sec=0.2), right=SensorSignal(timestamp=0.1, angle_rad=5.0, velocity_rad_per_sec=0.5), ) + # Optional columns absent in fixture -> defaults applied. + assert step0.main_switch is True + assert step1.main_switch is True + assert step0.classification_left == 0 + assert step0.classification_right == 0 def test_csv_player_index_increments(tmp_path): From 49dd18114b7d2f53ded32aaa48e8c2008bd75714 Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Wed, 17 Jun 2026 11:28:15 +0200 Subject: [PATCH 11/13] format --- src/hip_controller/__main__.py | 6 ++++-- .../control/signal_processing/sensor_preprocessor.py | 2 +- src/hip_controller/definitions.py | 2 -- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hip_controller/__main__.py b/src/hip_controller/__main__.py index 98cda79..80dd8d2 100644 --- a/src/hip_controller/__main__.py +++ b/src/hip_controller/__main__.py @@ -101,12 +101,14 @@ def main( # noqa: PLR0915, C901 player = CSVPlayer(csv_path) plot = not fast config = BasicConfig( - filtered=False, left_limb_plot=plot, right_limb_plot=plot + filtered=False, + left_limb_plot=plot, + right_limb_plot=plot, ) controller_left = WalkOnController(left_limb=True, config=config) controller_right = WalkOnController(left_limb=False, config=config) - + timer = QtCore.QTimer() # Track the previous main-switch state so we can reset the controllers on a diff --git a/src/hip_controller/control/signal_processing/sensor_preprocessor.py b/src/hip_controller/control/signal_processing/sensor_preprocessor.py index 9a1398d..5eb81ca 100644 --- a/src/hip_controller/control/signal_processing/sensor_preprocessor.py +++ b/src/hip_controller/control/signal_processing/sensor_preprocessor.py @@ -234,7 +234,7 @@ def reset(self) -> None: self._prev_timestamp = None self._baseline: float = 0.0 self._baseline_count: int = 0 - self._baseline_sum: float = 0.0 + self._baseline_sum: float = 0.0 self.last_velocity_surrogate_rad_per_sec = None self.last_velocity_lpf_angle_rad = None self.last_drift_removed_angle_rad = None diff --git a/src/hip_controller/definitions.py b/src/hip_controller/definitions.py index 2d37878..c9c9fda 100644 --- a/src/hip_controller/definitions.py +++ b/src/hip_controller/definitions.py @@ -214,7 +214,6 @@ class SogiFllConfig: numerical_safety_floor: float = 1e-9 - class VelocityInputAngle(StrEnum): """Which angle is fed to the velocity-estimation stage. @@ -273,7 +272,6 @@ class PreprocessorConfig: AMPLITUDE_GAIN = -7 # Motor position desidered amplitude (rad) - # Cubic Spline Interpolation @dataclass(frozen=True) class LookUpTable: From e4f2ac045cc720c5a8621a3b9a0cb82b91d9e554 Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Wed, 17 Jun 2026 11:54:47 +0200 Subject: [PATCH 12/13] skip plot tests in CI --- tests/utils_test/csv_inspector_test.py | 9 +++++++++ tests/utils_test/csv_player_test.py | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/utils_test/csv_inspector_test.py b/tests/utils_test/csv_inspector_test.py index 84f7ab2..0c6c4fb 100644 --- a/tests/utils_test/csv_inspector_test.py +++ b/tests/utils_test/csv_inspector_test.py @@ -7,8 +7,12 @@ from __future__ import annotations +import os +import sys + import numpy as np import pandas as pd +import pytest from pytest import raises from hip_controller.plotter.csv_inspector import ( @@ -16,6 +20,11 @@ synthesize_time_vector, ) +pytestmark = pytest.mark.skipif( + sys.platform == "linux" and not os.getenv("DISPLAY"), + reason="GUI tests require display server", +) + def test_discover_plottable_columns_filters_time_and_strings() -> None: """Time-like and non-numeric columns must be excluded.""" diff --git a/tests/utils_test/csv_player_test.py b/tests/utils_test/csv_player_test.py index 2593130..1c901d8 100644 --- a/tests/utils_test/csv_player_test.py +++ b/tests/utils_test/csv_player_test.py @@ -1,8 +1,11 @@ """Test for the csv player module.""" +import os +import sys from pathlib import Path import pandas as pd +import pytest from pandas import DataFrame, ExcelWriter, read_csv, testing from pytest import raises @@ -10,6 +13,11 @@ from hip_controller.plotter.csv_player import CSVPlayer from hip_controller.utils.csv_utils import convert_xlsx_to_csv +pytestmark = pytest.mark.skipif( + sys.platform == "linux" and not os.getenv("DISPLAY"), + reason="GUI tests require display server", +) + def create_test_csv(path: Path): """Create a data frame to test the CSV player.""" From 12e2c46bdd61e162ffa6e53591f2746b9054cedc Mon Sep 17 00:00:00 2001 From: CatYang3 Date: Wed, 17 Jun 2026 12:11:30 +0200 Subject: [PATCH 13/13] try fix CI --- .github/workflows/ci.yml | 2 +- tests/utils_test/csv_inspector_test.py | 9 --------- tests/utils_test/csv_player_test.py | 8 -------- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cce668..623fb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: run: make init - name: Run Pytest - run: make test + run: uv run pytest --cov=src --cov-report=term-missing --no-cov-on-fail --cov-report=xml --cov-fail-under=80 --ignore=tests/utils_test/csv_inspector_test.py --ignore=tests/utils_test/csv_player_test.py - name: Upload coverage XML artifact if: ${{ matrix.python-version == '3.12' }} diff --git a/tests/utils_test/csv_inspector_test.py b/tests/utils_test/csv_inspector_test.py index 0c6c4fb..84f7ab2 100644 --- a/tests/utils_test/csv_inspector_test.py +++ b/tests/utils_test/csv_inspector_test.py @@ -7,12 +7,8 @@ from __future__ import annotations -import os -import sys - import numpy as np import pandas as pd -import pytest from pytest import raises from hip_controller.plotter.csv_inspector import ( @@ -20,11 +16,6 @@ synthesize_time_vector, ) -pytestmark = pytest.mark.skipif( - sys.platform == "linux" and not os.getenv("DISPLAY"), - reason="GUI tests require display server", -) - def test_discover_plottable_columns_filters_time_and_strings() -> None: """Time-like and non-numeric columns must be excluded.""" diff --git a/tests/utils_test/csv_player_test.py b/tests/utils_test/csv_player_test.py index 1c901d8..2593130 100644 --- a/tests/utils_test/csv_player_test.py +++ b/tests/utils_test/csv_player_test.py @@ -1,11 +1,8 @@ """Test for the csv player module.""" -import os -import sys from pathlib import Path import pandas as pd -import pytest from pandas import DataFrame, ExcelWriter, read_csv, testing from pytest import raises @@ -13,11 +10,6 @@ from hip_controller.plotter.csv_player import CSVPlayer from hip_controller.utils.csv_utils import convert_xlsx_to_csv -pytestmark = pytest.mark.skipif( - sys.platform == "linux" and not os.getenv("DISPLAY"), - reason="GUI tests require display server", -) - def create_test_csv(path: Path): """Create a data frame to test the CSV player."""