diff --git a/Arduino/platformio.ini b/Arduino/platformio.ini index 67e8cb2..d827df4 100644 --- a/Arduino/platformio.ini +++ b/Arduino/platformio.ini @@ -12,3 +12,4 @@ platform = atmelavr board = megaatmega2560 framework = arduino +monitor_speed = 921600 diff --git a/Arduino/src/main.cpp b/Arduino/src/main.cpp index 819c0fe..781490c 100644 --- a/Arduino/src/main.cpp +++ b/Arduino/src/main.cpp @@ -1,54 +1,228 @@ -// Copyright (C) 2026 Pierre Barbier -// Copyright (C) 2026 Association Exergie -// SPDX-License-Identifier: GPL-3.0-or-later - #include -#define PIN_REG 2 -#define PIN_SYNC 3 -#define REG_SIZE 256 -#define SYNC_SIZE 256 +#ifndef ARDUINO_ISR_ATTR +#define ARDUINO_ISR_ATTR +#endif -uint32_t REG_BUFF[REG_SIZE]; -uint32_t SYNC_BUFF[SYNC_SIZE]; +constexpr uint8_t TURN_PIN = 27; +constexpr uint8_t PULSE_PIN = 26; +constexpr uint8_t GP_PINS[] = {32, 33}; -volatile uint8_t REG_IR = 0; -volatile uint8_t REG_IW = 0; -volatile uint8_t SYNC_IR = 0; -volatile uint8_t SYNC_IW = 0; +constexpr uint32_t BAUD_RATE = 921600; +constexpr size_t EVENT_BUFFER_SIZE = 256; +constexpr size_t GP_INPUT_COUNT = sizeof(GP_PINS) / sizeof(GP_PINS[0]); +constexpr size_t PACKET_PULSE_COUNT = 16; +constexpr size_t INPUT_PULSES_PER_TURN = 1024; +constexpr size_t PULSE_DECIMATION = 4; +constexpr size_t OUTPUT_PULSES_PER_TURN = INPUT_PULSES_PER_TURN / PULSE_DECIMATION; +constexpr uint32_t UINT16_DELTA_MAX = UINT16_MAX; -void append_buff_reg(); -void append_buff_sync(); +static_assert(INPUT_PULSES_PER_TURN % PULSE_DECIMATION == 0); -void setup() { - Serial.begin(115200); +constexpr uint8_t TURN_MAGIC[] = {0xE7, 0x54, 0xC3, 0xA1}; +constexpr uint8_t PULSE16_MAGIC[] = {0xE7, 0x50, 0xC3, 0xA1}; +constexpr uint8_t PULSE32_MAGIC[] = {0xE7, 0x70, 0xC3, 0xA1}; +constexpr uint8_t GP_MAGIC[] = {0xE7, 0x47, 0xC3, 0xA1}; - attachInterrupt(digitalPinToInterrupt(PIN_REG), append_buff_reg, FALLING); - attachInterrupt(digitalPinToInterrupt(PIN_SYNC), append_buff_sync, FALLING); +volatile uint32_t PULSE_REG[EVENT_BUFFER_SIZE]; +volatile uint8_t PULSE_W_HEAD = 0; +uint8_t PULSE_R_HEAD = 0; + +volatile uint32_t TURN_REG[EVENT_BUFFER_SIZE]; +volatile uint8_t TURN_W_HEAD = 0; +uint8_t TURN_R_HEAD = 0; + +volatile uint32_t GP_REG[GP_INPUT_COUNT][EVENT_BUFFER_SIZE]; +volatile uint8_t GP_W_HEAD[GP_INPUT_COUNT] = {}; +uint8_t GP_R_HEAD[GP_INPUT_COUNT] = {}; + +uint32_t packet_deltas[PACKET_PULSE_COUNT]; +size_t packet_count = 0; +size_t raw_turn_pulse_count = 0; +size_t output_turn_pulse_count = 0; +uint32_t previous_timestamp = 0; +bool have_active_turn = false; + +void ARDUINO_ISR_ATTR handle_pulse_isr() { + PULSE_REG[PULSE_W_HEAD] = micros(); + PULSE_W_HEAD = PULSE_W_HEAD + 1; } -void loop() { - if (Serial.availableForWrite() >= 5) { - if (REG_IW != REG_IR) { - Serial.write('R'); - Serial.write((uint8_t *)®_BUFF[REG_IR], sizeof(uint32_t)); - REG_IR++; +void ARDUINO_ISR_ATTR handle_turn_isr() { + TURN_REG[TURN_W_HEAD] = micros(); + TURN_W_HEAD = TURN_W_HEAD + 1; +} + +void ARDUINO_ISR_ATTR handle_gp_falling(size_t input) { + GP_REG[input][GP_W_HEAD[input]] = micros(); + GP_W_HEAD[input] = GP_W_HEAD[input] + 1; +} + +void ARDUINO_ISR_ATTR handle_gp0_isr() { + handle_gp_falling(0); +} + +void ARDUINO_ISR_ATTR handle_gp1_isr() { + handle_gp_falling(1); +} + +bool timestamp_is_before(uint32_t lhs, uint32_t rhs) { + return static_cast(lhs - rhs) < 0; +} + +void write_magic(const uint8_t magic[4]) { + Serial.write(magic, 4); +} + +void write_u16(uint16_t value) { + Serial.write(reinterpret_cast(&value), sizeof(value)); +} + +void write_u32(uint32_t value) { + Serial.write(reinterpret_cast(&value), sizeof(value)); +} + +void send_packet(size_t used_count) { + bool needs_u32 = false; + + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + if (index >= used_count) { + packet_deltas[index] = 0; } - if (SYNC_IW != SYNC_IR) { - Serial.write('S'); - Serial.write((uint8_t *)&SYNC_BUFF[SYNC_IR], sizeof(uint32_t)); - SYNC_IR++; + if (packet_deltas[index] > UINT16_DELTA_MAX) { + needs_u32 = true; + } + } + + write_magic(needs_u32 ? PULSE32_MAGIC : PULSE16_MAGIC); + + if (needs_u32) { + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + write_u32(packet_deltas[index]); + } + } else { + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + write_u16(static_cast(packet_deltas[index])); } } } -void append_buff_reg() { - REG_BUFF[REG_IW] = micros(); - REG_IW++; +void flush_partial_packet() { + if (packet_count == 0) { + return; + } + + send_packet(packet_count); + packet_count = 0; } -void append_buff_sync() { - SYNC_BUFF[SYNC_IW] = micros(); - SYNC_IW++; +void start_turn(uint32_t timestamp) { + if (have_active_turn) { + flush_partial_packet(); + } + + write_magic(TURN_MAGIC); + write_u32(timestamp); + + have_active_turn = true; + previous_timestamp = timestamp; + packet_count = 0; + raw_turn_pulse_count = 0; + output_turn_pulse_count = 0; +} + +void handle_pulse(uint32_t timestamp) { + if (!have_active_turn || raw_turn_pulse_count >= INPUT_PULSES_PER_TURN) { + return; + } + + const bool should_emit = raw_turn_pulse_count % PULSE_DECIMATION == 0; + raw_turn_pulse_count++; + + if (!should_emit || output_turn_pulse_count >= OUTPUT_PULSES_PER_TURN) { + return; + } + + packet_deltas[packet_count] = timestamp - previous_timestamp; + previous_timestamp = timestamp; + packet_count++; + output_turn_pulse_count++; + + if (packet_count == PACKET_PULSE_COUNT) { + send_packet(PACKET_PULSE_COUNT); + packet_count = 0; + } +} + +void handle_gp(size_t channel, uint32_t timestamp) { + if (!have_active_turn) { + return; + } + + write_magic(GP_MAGIC); + Serial.write(static_cast(channel)); + write_u32(timestamp); +} + +void process_next_event() { + enum EventType { NONE, TURN, PULSE, GP }; + + EventType next_type = NONE; + uint32_t next_timestamp = 0; + size_t next_gp_channel = 0; + + if (TURN_R_HEAD != TURN_W_HEAD) { + next_type = TURN; + next_timestamp = TURN_REG[TURN_R_HEAD]; + } + + if (PULSE_R_HEAD != PULSE_W_HEAD && + (next_type == NONE || + timestamp_is_before(PULSE_REG[PULSE_R_HEAD], next_timestamp))) { + next_type = PULSE; + next_timestamp = PULSE_REG[PULSE_R_HEAD]; + } + + for (size_t channel = 0; channel < GP_INPUT_COUNT; channel++) { + if (GP_R_HEAD[channel] == GP_W_HEAD[channel]) { + continue; + } + + const uint32_t timestamp = GP_REG[channel][GP_R_HEAD[channel]]; + if (next_type == NONE || timestamp_is_before(timestamp, next_timestamp)) { + next_type = GP; + next_timestamp = timestamp; + next_gp_channel = channel; + } + } + + switch (next_type) { + case TURN: + start_turn(next_timestamp); + TURN_R_HEAD++; + break; + case PULSE: + handle_pulse(next_timestamp); + PULSE_R_HEAD++; + break; + case GP: + handle_gp(next_gp_channel, next_timestamp); + GP_R_HEAD[next_gp_channel]++; + break; + case NONE: + break; + } +} + +void setup() { + Serial.begin(BAUD_RATE); + attachInterrupt(digitalPinToInterrupt(TURN_PIN), handle_turn_isr, RISING); + attachInterrupt(digitalPinToInterrupt(PULSE_PIN), handle_pulse_isr, FALLING); + attachInterrupt(digitalPinToInterrupt(GP_PINS[0]), handle_gp0_isr, FALLING); + attachInterrupt(digitalPinToInterrupt(GP_PINS[1]), handle_gp1_isr, FALLING); +} + +void loop() { + process_next_event(); } diff --git a/ESP32/.gitignore b/ESP32/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/ESP32/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/ESP32/include/README b/ESP32/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/ESP32/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/ESP32/lib/README b/ESP32/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/ESP32/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/ESP32/platformio.ini b/ESP32/platformio.ini new file mode 100644 index 0000000..0d7c5a3 --- /dev/null +++ b/ESP32/platformio.ini @@ -0,0 +1,15 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 921600 diff --git a/ESP32/src/main.cpp b/ESP32/src/main.cpp new file mode 100644 index 0000000..fd96327 --- /dev/null +++ b/ESP32/src/main.cpp @@ -0,0 +1,224 @@ +#include + +constexpr uint8_t TURN_PIN = 27; +constexpr uint8_t PULSE_PIN = 26; +constexpr uint8_t GP_PINS[] = {32, 33}; + +constexpr uint32_t BAUD_RATE = 921600; +constexpr size_t EVENT_BUFFER_SIZE = 256; +constexpr size_t GP_INPUT_COUNT = sizeof(GP_PINS) / sizeof(GP_PINS[0]); +constexpr size_t PACKET_PULSE_COUNT = 16; +constexpr size_t INPUT_PULSES_PER_TURN = 1024; +constexpr size_t PULSE_DECIMATION = 4; +constexpr size_t OUTPUT_PULSES_PER_TURN = INPUT_PULSES_PER_TURN / PULSE_DECIMATION; +constexpr uint32_t UINT16_DELTA_MAX = UINT16_MAX; + +static_assert(INPUT_PULSES_PER_TURN % PULSE_DECIMATION == 0); + +constexpr uint8_t TURN_MAGIC[] = {0xE7, 0x54, 0xC3, 0xA1}; +constexpr uint8_t PULSE16_MAGIC[] = {0xE7, 0x50, 0xC3, 0xA1}; +constexpr uint8_t PULSE32_MAGIC[] = {0xE7, 0x70, 0xC3, 0xA1}; +constexpr uint8_t GP_MAGIC[] = {0xE7, 0x47, 0xC3, 0xA1}; + +volatile uint32_t PULSE_REG[EVENT_BUFFER_SIZE]; +volatile uint8_t PULSE_W_HEAD = 0; +uint8_t PULSE_R_HEAD = 0; + +volatile uint32_t TURN_REG[EVENT_BUFFER_SIZE]; +volatile uint8_t TURN_W_HEAD = 0; +uint8_t TURN_R_HEAD = 0; + +volatile uint32_t GP_REG[GP_INPUT_COUNT][EVENT_BUFFER_SIZE]; +volatile uint8_t GP_W_HEAD[GP_INPUT_COUNT] = {}; +uint8_t GP_R_HEAD[GP_INPUT_COUNT] = {}; + +uint32_t packet_deltas[PACKET_PULSE_COUNT]; +size_t packet_count = 0; +size_t raw_turn_pulse_count = 0; +size_t output_turn_pulse_count = 0; +uint32_t previous_timestamp = 0; +bool have_active_turn = false; + +void ARDUINO_ISR_ATTR handle_pulse_isr() { + PULSE_REG[PULSE_W_HEAD] = micros(); + PULSE_W_HEAD = PULSE_W_HEAD + 1; +} + +void ARDUINO_ISR_ATTR handle_turn_isr() { + TURN_REG[TURN_W_HEAD] = micros(); + TURN_W_HEAD = TURN_W_HEAD + 1; +} + +void ARDUINO_ISR_ATTR handle_gp_falling(size_t input) { + GP_REG[input][GP_W_HEAD[input]] = micros(); + GP_W_HEAD[input] = GP_W_HEAD[input] + 1; +} + +void ARDUINO_ISR_ATTR handle_gp0_isr() { + handle_gp_falling(0); +} + +void ARDUINO_ISR_ATTR handle_gp1_isr() { + handle_gp_falling(1); +} + +bool timestamp_is_before(uint32_t lhs, uint32_t rhs) { + return static_cast(lhs - rhs) < 0; +} + +void write_magic(const uint8_t magic[4]) { + Serial.write(magic, 4); +} + +void write_u16(uint16_t value) { + Serial.write(reinterpret_cast(&value), sizeof(value)); +} + +void write_u32(uint32_t value) { + Serial.write(reinterpret_cast(&value), sizeof(value)); +} + +void send_packet(size_t used_count) { + bool needs_u32 = false; + + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + if (index >= used_count) { + packet_deltas[index] = 0; + } + + if (packet_deltas[index] > UINT16_DELTA_MAX) { + needs_u32 = true; + } + } + + write_magic(needs_u32 ? PULSE32_MAGIC : PULSE16_MAGIC); + + if (needs_u32) { + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + write_u32(packet_deltas[index]); + } + } else { + for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) { + write_u16(static_cast(packet_deltas[index])); + } + } +} + +void flush_partial_packet() { + if (packet_count == 0) { + return; + } + + send_packet(packet_count); + packet_count = 0; +} + +void start_turn(uint32_t timestamp) { + if (have_active_turn) { + flush_partial_packet(); + } + + write_magic(TURN_MAGIC); + write_u32(timestamp); + + have_active_turn = true; + previous_timestamp = timestamp; + packet_count = 0; + raw_turn_pulse_count = 0; + output_turn_pulse_count = 0; +} + +void handle_pulse(uint32_t timestamp) { + if (!have_active_turn || raw_turn_pulse_count >= INPUT_PULSES_PER_TURN) { + return; + } + + const bool should_emit = raw_turn_pulse_count % PULSE_DECIMATION == 0; + raw_turn_pulse_count++; + + if (!should_emit || output_turn_pulse_count >= OUTPUT_PULSES_PER_TURN) { + return; + } + + packet_deltas[packet_count] = timestamp - previous_timestamp; + previous_timestamp = timestamp; + packet_count++; + output_turn_pulse_count++; + + if (packet_count == PACKET_PULSE_COUNT) { + send_packet(PACKET_PULSE_COUNT); + packet_count = 0; + } +} + +void handle_gp(size_t channel, uint32_t timestamp) { + if (!have_active_turn) { + return; + } + + write_magic(GP_MAGIC); + Serial.write(static_cast(channel)); + write_u32(timestamp); +} + +void process_next_event() { + enum EventType { NONE, TURN, PULSE, GP }; + + EventType next_type = NONE; + uint32_t next_timestamp = 0; + size_t next_gp_channel = 0; + + if (TURN_R_HEAD != TURN_W_HEAD) { + next_type = TURN; + next_timestamp = TURN_REG[TURN_R_HEAD]; + } + + if (PULSE_R_HEAD != PULSE_W_HEAD && + (next_type == NONE || + timestamp_is_before(PULSE_REG[PULSE_R_HEAD], next_timestamp))) { + next_type = PULSE; + next_timestamp = PULSE_REG[PULSE_R_HEAD]; + } + + for (size_t channel = 0; channel < GP_INPUT_COUNT; channel++) { + if (GP_R_HEAD[channel] == GP_W_HEAD[channel]) { + continue; + } + + const uint32_t timestamp = GP_REG[channel][GP_R_HEAD[channel]]; + if (next_type == NONE || timestamp_is_before(timestamp, next_timestamp)) { + next_type = GP; + next_timestamp = timestamp; + next_gp_channel = channel; + } + } + + switch (next_type) { + case TURN: + start_turn(next_timestamp); + TURN_R_HEAD++; + break; + case PULSE: + handle_pulse(next_timestamp); + PULSE_R_HEAD++; + break; + case GP: + handle_gp(next_gp_channel, next_timestamp); + GP_R_HEAD[next_gp_channel]++; + break; + case NONE: + break; + } +} + +void setup() { + Serial.begin(BAUD_RATE); + attachInterrupt(digitalPinToInterrupt(TURN_PIN), handle_turn_isr, RISING); + attachInterrupt(digitalPinToInterrupt(PULSE_PIN), handle_pulse_isr, FALLING); + attachInterrupt(digitalPinToInterrupt(GP_PINS[0]), handle_gp0_isr, FALLING); + attachInterrupt(digitalPinToInterrupt(GP_PINS[1]), handle_gp1_isr, FALLING); +} + +void loop() { + process_next_event(); +} diff --git a/ESP32/test/README b/ESP32/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/ESP32/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/Python/clean_recordings.py b/Python/clean_recordings.py new file mode 100644 index 0000000..7045ac5 --- /dev/null +++ b/Python/clean_recordings.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import argparse +import csv +import statistics +import sys +from bisect import bisect_left +from dataclasses import dataclass +from pathlib import Path + + +INPUT_COLUMNS = {"time_us", "turn", "pulse"} +GP_COLUMNS = ("gp0_falling", "gp1_falling") +OUTPUT_COLUMNS = ("time_us", "turn", "pulse", *GP_COLUMNS) +DEFAULT_PPR = 256 + + +@dataclass(frozen=True) +class Events: + turns: list[int] + pulses: list[int] + gp0_falling: list[int] + gp1_falling: list[int] + + +@dataclass(frozen=True) +class CleanResult: + turns: list[int] + pulses: list[int] + gp0_falling: list[int] + gp1_falling: list[int] + pulse_noise_removed: int + pulses_inserted: int + turn_noise_removed: int + turn_phase: int | None + + +def positive_intervals(times: list[int]) -> list[int]: + return [b - a for a, b in zip(times, times[1:]) if b > a] + + +def median_or_none(values: list[int] | list[float]) -> float | None: + if not values: + return None + return float(statistics.median(values)) + + +def robust_median_interval(times: list[int]) -> float | None: + intervals = positive_intervals(times) + if not intervals: + return None + + intervals = sorted(intervals) + if len(intervals) >= 20: + lo = len(intervals) // 20 + hi = len(intervals) - lo + intervals = intervals[lo:hi] + + return float(statistics.median(intervals)) + + +def read_events(path: Path) -> Events: + turns: list[int] = [] + pulses: list[int] = [] + gp0_falling: list[int] = [] + gp1_falling: list[int] = [] + + with path.open(newline="") as file: + reader = csv.DictReader(file) + fieldnames = set(reader.fieldnames or ()) + missing = INPUT_COLUMNS - fieldnames + if missing: + raise ValueError(f"missing columns: {', '.join(sorted(missing))}") + + for row in reader: + time_us = int(row["time_us"]) + if int(row["turn"]): + turns.append(time_us) + if int(row["pulse"]): + pulses.append(time_us) + if "gp0_falling" in fieldnames and int(row["gp0_falling"]): + gp0_falling.append(time_us) + if "gp1_falling" in fieldnames and int(row["gp1_falling"]): + gp1_falling.append(time_us) + + return Events( + turns=sorted(turns), + pulses=sorted(pulses), + gp0_falling=sorted(gp0_falling), + gp1_falling=sorted(gp1_falling), + ) + + +def remove_close_pulses(pulses: list[int]) -> tuple[list[int], int]: + if len(pulses) < 3: + return sorted(set(pulses)), 0 + + median_interval = robust_median_interval(pulses) + if median_interval is None: + return sorted(set(pulses)), 0 + + min_interval = max(2, int(median_interval * 0.35)) + cleaned: list[int] = [] + removed = 0 + + for pulse in sorted(pulses): + if cleaned and pulse <= cleaned[-1]: + removed += 1 + continue + if cleaned and pulse - cleaned[-1] < min_interval: + removed += 1 + continue + cleaned.append(pulse) + + return cleaned, removed + + +def local_interval(intervals: list[int], index: int, fallback: float) -> float: + start = max(0, index - 16) + end = min(len(intervals), index + 17) + neighbors = intervals[start:index] + intervals[index + 1 : end] + plausible = [dt for dt in neighbors if 0.25 * fallback <= dt <= 4.0 * fallback] + return median_or_none(plausible) or fallback + + +def interpolate_small_gaps( + pulses: list[int], + *, + max_missing_pulses: int, + gap_tolerance: float, +) -> tuple[list[int], int]: + if len(pulses) < 3: + return pulses, 0 + + fallback = robust_median_interval(pulses) + if fallback is None: + return pulses, 0 + + intervals = positive_intervals(pulses) + result = [pulses[0]] + inserted = 0 + + for index, (start, end) in enumerate(zip(pulses, pulses[1:])): + gap = end - start + expected = local_interval(intervals, index, fallback) + pulse_count = round(gap / expected) + + if 2 <= pulse_count <= max_missing_pulses + 1: + corrected_interval = gap / pulse_count + error = abs(corrected_interval - expected) / expected + if error <= gap_tolerance: + for step in range(1, pulse_count): + result.append(round(start + corrected_interval * step)) + inserted += 1 + + result.append(end) + + return result, inserted + + +def nearest_index(times: list[int], target: int) -> int | None: + if not times: + return None + + index = bisect_left(times, target) + candidates = [] + if index < len(times): + candidates.append(index) + if index > 0: + candidates.append(index - 1) + + return min(candidates, key=lambda candidate: abs(times[candidate] - target)) + + +def remove_close_turns(turns: list[int], min_separation_us: int) -> tuple[list[int], int]: + cleaned: list[int] = [] + removed = 0 + + for turn in sorted(turns): + if cleaned and turn - cleaned[-1] < min_separation_us: + removed += 1 + continue + cleaned.append(turn) + + return cleaned, removed + + +def choose_turn_phase(turns: list[int], pulses: list[int], ppr: int) -> int | None: + if not turns or len(pulses) < ppr: + return None + + pulse_interval = robust_median_interval(pulses) + if pulse_interval is None: + return None + + max_distance_us = max(1_000, int(pulse_interval * 10)) + scores = [0.0] * ppr + + for turn in turns: + index = nearest_index(pulses, turn) + if index is None: + continue + distance = abs(pulses[index] - turn) + if distance > max_distance_us: + continue + scores[index % ppr] += 1.0 - distance / max_distance_us + + best_score = max(scores) + if best_score <= 0: + return None + + return scores.index(best_score) + + +def synthesize_turns(turns: list[int], pulses: list[int], ppr: int) -> tuple[list[int], int, int | None]: + if len(pulses) < ppr: + return turns, 0, None + + pulse_interval = robust_median_interval(pulses) or 0.0 + expected_turn_interval = max(1, int(pulse_interval * ppr)) + debounced_turns, removed = remove_close_turns( + turns, + max(1, int(expected_turn_interval * 0.45)), + ) + phase = choose_turn_phase(debounced_turns, pulses, ppr) + if phase is None: + phase = 0 + + match_window_us = max(1_000, int(expected_turn_interval * 0.15)) + synthesized: list[int] = [] + + for pulse_index in range(phase, len(pulses), ppr): + pulse_time = pulses[pulse_index] + turn_index = nearest_index(debounced_turns, pulse_time) + if turn_index is not None and abs(debounced_turns[turn_index] - pulse_time) <= match_window_us: + synthesized.append(debounced_turns[turn_index]) + else: + synthesized.append(pulse_time) + + return synthesized, removed, phase + + +def clean_events( + events: Events, + *, + ppr: int, + max_missing_pulses: int, + gap_tolerance: float, +) -> CleanResult: + pulses, pulse_noise_removed = remove_close_pulses(events.pulses) + pulses, pulses_inserted = interpolate_small_gaps( + pulses, + max_missing_pulses=max_missing_pulses, + gap_tolerance=gap_tolerance, + ) + turns, turn_noise_removed, turn_phase = synthesize_turns(events.turns, pulses, ppr) + + return CleanResult( + turns=turns, + pulses=pulses, + gp0_falling=events.gp0_falling, + gp1_falling=events.gp1_falling, + pulse_noise_removed=pulse_noise_removed, + pulses_inserted=pulses_inserted, + turn_noise_removed=turn_noise_removed, + turn_phase=turn_phase, + ) + + +def write_events(path: Path, result: CleanResult) -> None: + rows: dict[int, list[int]] = {} + for turn in result.turns: + rows.setdefault(turn, [0, 0, 0, 0])[0] = 1 + for pulse in result.pulses: + rows.setdefault(pulse, [0, 0, 0, 0])[1] = 1 + for gp0 in result.gp0_falling: + rows.setdefault(gp0, [0, 0, 0, 0])[2] = 1 + for gp1 in result.gp1_falling: + rows.setdefault(gp1, [0, 0, 0, 0])[3] = 1 + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as file: + writer = csv.writer(file) + writer.writerow(OUTPUT_COLUMNS) + for time_us in sorted(rows): + writer.writerow((time_us, *rows[time_us])) + + +def output_path_for(input_path: Path, input_root: Path, output_root: Path) -> Path: + relative = input_path.relative_to(input_root) + return output_root / relative.with_name(f"{relative.stem}_cleaned.csv") + + +def process_file( + input_path: Path, + output_path: Path, + *, + ppr: int, + max_missing_pulses: int, + gap_tolerance: float, +) -> CleanResult: + events = read_events(input_path) + result = clean_events( + events, + ppr=ppr, + max_missing_pulses=max_missing_pulses, + gap_tolerance=gap_tolerance, + ) + write_events(output_path, result) + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Clean rotary turn and pulse event CSVs from recordings/." + ) + parser.add_argument( + "--input-root", + type=Path, + default=Path("recordings"), + help="Folder to search recursively for CSV files.", + ) + parser.add_argument( + "--output-root", + type=Path, + default=Path("recordings_cleaned"), + help="Folder where cleaned CSV files are written.", + ) + parser.add_argument("--ppr", type=int, default=DEFAULT_PPR, help="Pulse encoder pulses per turn.") + parser.add_argument( + "--max-missing-pulses", + type=int, + default=8, + help="Largest pulse gap to interpolate. Larger gaps are treated as recording breaks.", + ) + parser.add_argument( + "--gap-tolerance", + type=float, + default=0.45, + help="Allowed fractional error when deciding whether a gap is missing pulses.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + input_root = args.input_root + output_root = args.output_root + + if not input_root.exists(): + print(f"Input folder does not exist: {input_root}", file=sys.stderr) + return 1 + + csv_paths = sorted( + path + for path in input_root.rglob("*.csv") + if not path.name.endswith("_cleaned.csv") + ) + if not csv_paths: + print(f"No CSV files found under {input_root}", file=sys.stderr) + return 1 + + failures = 0 + for input_path in csv_paths: + output_path = output_path_for(input_path, input_root, output_root) + try: + result = process_file( + input_path, + output_path, + ppr=args.ppr, + max_missing_pulses=args.max_missing_pulses, + gap_tolerance=args.gap_tolerance, + ) + except (OSError, ValueError) as exc: + failures += 1 + print(f"Skipping {input_path}: {exc}", file=sys.stderr) + continue + + phase = "unknown" if result.turn_phase is None else str(result.turn_phase) + print( + f"{input_path} -> {output_path} " + f"pulses={len(result.pulses)} " + f"turns={len(result.turns)} " + f"removed_pulses={result.pulse_noise_removed} " + f"inserted_pulses={result.pulses_inserted} " + f"removed_turns={result.turn_noise_removed} " + f"phase={phase}" + ) + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Python/parse_binary_stream.py b/Python/parse_binary_stream.py new file mode 100644 index 0000000..344d9ac --- /dev/null +++ b/Python/parse_binary_stream.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import argparse +import csv +import struct +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import BinaryIO + + +BAUD_RATE = 921_600 +UINT32_MASK = 0xFFFFFFFF + +TURN_MAGIC = b"\xE7\x54\xC3\xA1" +PULSE16_MAGIC = b"\xE7\x50\xC3\xA1" +PULSE32_MAGIC = b"\xE7\x70\xC3\xA1" +GP_MAGIC = b"\xE7\x47\xC3\xA1" +MAGICS = { + TURN_MAGIC: "turn", + PULSE16_MAGIC: "pulse16", + PULSE32_MAGIC: "pulse32", + GP_MAGIC: "gp", +} + +GP_COLUMNS = ("gp0_falling", "gp1_falling") +CSV_COLUMNS = ("time_us", "turn", "pulse", *GP_COLUMNS) + + +class EventTable: + def __init__(self) -> None: + self.offset: int | None = None + self.rows: dict[int, list[int]] = {} + + def add(self, timestamp: int, column: str) -> None: + if self.offset is None: + self.offset = timestamp + + time_us = (timestamp - self.offset) & UINT32_MASK + row = self.rows.setdefault(time_us, [0] * (len(CSV_COLUMNS) - 1)) + row[CSV_COLUMNS.index(column) - 1] = 1 + + def write_csv(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as file: + writer = csv.writer(file) + writer.writerow(CSV_COLUMNS) + for time_us in sorted(self.rows): + writer.writerow((time_us, *self.rows[time_us])) + + +def choose_port() -> str | None: + from serial.tools import list_ports + + ports = list(list_ports.comports()) + if not ports: + print("No serial ports found.") + return None + + print("Available serial ports:") + for index, port in enumerate(ports, start=1): + description = port.description or "serial port" + print(f" {index}. {port.device} - {description}") + + while True: + choice = input("Choose a port number, or q to quit: ").strip().lower() + if choice == "q": + return None + + try: + index = int(choice) + except ValueError: + print("Please enter a port number.") + continue + + if 1 <= index <= len(ports): + return ports[index - 1].device + + print(f"Please choose a number between 1 and {len(ports)}.") + + +def deadline_expired(deadline: float | None) -> bool: + return deadline is not None and time.monotonic() >= deadline + + +def read_exact( + stream: BinaryIO, + size: int, + *, + deadline: float | None, + eof_on_empty: bool, +) -> bytes | None: + data = bytearray() + while len(data) < size: + chunk = stream.read(size - len(data)) + if not chunk: + if eof_on_empty or deadline_expired(deadline): + return None + continue + data.extend(chunk) + return bytes(data) + + +def read_magic( + stream: BinaryIO, + *, + deadline: float | None, + eof_on_empty: bool, +) -> str | None: + window = bytearray() + + while True: + byte = stream.read(1) + if not byte: + if eof_on_empty or deadline_expired(deadline): + return None + continue + + window.extend(byte) + if len(window) > 4: + del window[0] + + if len(window) == 4: + frame_type = MAGICS.get(bytes(window)) + if frame_type is not None: + return frame_type + + +def parse_stream( + stream: BinaryIO, + events: EventTable, + duration_s: float | None = None, + eof_on_empty: bool = True, +) -> int: + previous_pulse_timestamp: int | None = None + deadline = None if duration_s is None else time.monotonic() + duration_s + frames = 0 + + while deadline is None or time.monotonic() < deadline: + frame_type = read_magic( + stream, + deadline=deadline, + eof_on_empty=eof_on_empty, + ) + if frame_type is None: + break + + if frame_type == "turn": + payload = read_exact( + stream, + 4, + deadline=deadline, + eof_on_empty=eof_on_empty, + ) + if payload is None: + break + timestamp = struct.unpack(" Path: + started_at = datetime.now().strftime("%Y%m%d_%H%M%S") + return Path("recordings") / f"{started_at}_events.csv" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Parse the ESP32 encoder binary stream into an event CSV." + ) + source = parser.add_mutually_exclusive_group() + source.add_argument("--port", help="Serial port to read from.") + source.add_argument("--input", type=Path, help="Captured binary stream to parse.") + parser.add_argument( + "-o", + "--output", + type=Path, + default=default_output_path(), + help="Output CSV path.", + ) + parser.add_argument("--baud", type=int, default=BAUD_RATE) + parser.add_argument( + "--duration", + type=float, + help="Recording duration in seconds. Without this, serial reads until Ctrl-C.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + events = EventTable() + + try: + if args.input is not None: + with args.input.open("rb") as stream: + frames = parse_stream(stream, events) + else: + import serial + + port = args.port or choose_port() + if port is None: + return 0 + + try: + with serial.Serial(port, args.baud, timeout=0.1) as stream: + stream.reset_input_buffer() + print(f"Reading {port} at {args.baud} baud. Press Ctrl-C to stop.") + frames = parse_stream( + stream, + events, + args.duration, + eof_on_empty=False, + ) + except serial.SerialException as exc: + print(f"Serial error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print() + frames = len(events.rows) + except ImportError as exc: + print(f"Missing Python dependency: {exc}", file=sys.stderr) + return 1 + + events.write_csv(args.output) + print(f"Wrote {len(events.rows)} event times from {frames} frames to {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Python/plot_events.py b/Python/plot_events.py new file mode 100644 index 0000000..63d80d5 --- /dev/null +++ b/Python/plot_events.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import matplotlib.pyplot as plt + + +DEFAULT_CHANNELS = ( + "turn", + "pulse", + "gp0_falling", + "gp1_falling", +) + + +def read_events(path: Path) -> tuple[list[str], list[list[float]]]: + with path.open(newline="") as file: + reader = csv.DictReader(file) + fieldnames = set(reader.fieldnames or ()) + if "time_us" not in fieldnames: + raise ValueError("CSV is missing column: time_us") + + channels = [channel for channel in DEFAULT_CHANNELS if channel in fieldnames] + if not channels: + raise ValueError("CSV has no plottable event columns") + + events = [[] for _ in channels] + + for row in reader: + time_s = int(row["time_us"]) / 1_000_000 + for index, channel in enumerate(channels): + if int(row[channel]): + events[index].append(time_s) + + return channels, events + + +def iter_csv_paths(path: Path) -> list[Path]: + if path.is_dir(): + return sorted(path.rglob("*.csv")) + return [path] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot ESP32 event CSV files.") + parser.add_argument("csv_path", type=Path, help="CSV file or directory of CSV files.") + parser.add_argument("-o", "--output", type=Path, help="Optional image output path.") + return parser.parse_args() + + +def plot_events(csv_path: Path, output: Path | None) -> bool: + try: + channels, events = read_events(csv_path) + except (OSError, ValueError) as exc: + print(f"Could not read {csv_path}: {exc}", file=sys.stderr) + return False + + fig, ax = plt.subplots(figsize=(12, 5)) + ax.eventplot(events, orientation="horizontal", lineoffsets=range(len(channels))) + ax.set_yticks(range(len(channels)), channels) + ax.set_xlabel("time (s)") + ax.set_title(str(csv_path)) + ax.grid(axis="x", alpha=0.25) + fig.tight_layout() + + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=160) + print(f"Wrote {output}") + plt.close(fig) + else: + plt.show() + plt.close(fig) + + return True + + +def output_path_for(csv_path: Path, output: Path | None, multiple: bool) -> Path | None: + if output is None: + return None + if not multiple: + return output + return output / f"{csv_path.stem}.png" + + +def main() -> int: + args = parse_args() + + if not args.csv_path.exists(): + print(f"Path does not exist: {args.csv_path}", file=sys.stderr) + return 1 + + csv_paths = iter_csv_paths(args.csv_path) + if not csv_paths: + print(f"No CSV files found under {args.csv_path}", file=sys.stderr) + return 1 + + multiple = len(csv_paths) > 1 + if multiple and args.output is not None and args.output.suffix: + print("When plotting a directory, --output must be a directory.", file=sys.stderr) + return 1 + + failures = 0 + for csv_path in csv_paths: + output = output_path_for(csv_path, args.output, multiple) + if multiple and output is None: + print(f"Plotting {csv_path}") + if not plot_events(csv_path, output): + failures += 1 + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Python/plot_rpm.py b/Python/plot_rpm.py new file mode 100644 index 0000000..bf23421 --- /dev/null +++ b/Python/plot_rpm.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import argparse +import csv +import sys +from dataclasses import dataclass +from pathlib import Path + +import matplotlib.pyplot as plt +from matplotlib.ticker import MultipleLocator + + +REQUIRED_COLUMNS = {"time_us", "rpm_raw", "rpm_lpf"} +ANGLE_COLUMNS = {"revolution_index", "crank_angle_deg"} + + +@dataclass(frozen=True) +class GpEvent: + time_s: float + revolution_index: int + crank_angle_deg: float + + +@dataclass(frozen=True) +class RpmData: + time_s: list[float] + revolution_index: list[int] + crank_angle_deg: list[float] + rpm_raw: list[float] + rpm_lpf: list[float] + turn_time_s: list[float] + gp0_falling: list[GpEvent] + gp1_falling: list[GpEvent] + + +def read_rpm(path: Path) -> RpmData: + time_s: list[float] = [] + revolution_index: list[int] = [] + crank_angle_deg: list[float] = [] + rpm_raw: list[float] = [] + rpm_lpf: list[float] = [] + turn_time_s: list[float] = [] + gp0_falling: list[GpEvent] = [] + gp1_falling: list[GpEvent] = [] + + with path.open(newline="") as file: + reader = csv.DictReader(file) + fieldnames = set(reader.fieldnames or ()) + missing = REQUIRED_COLUMNS - fieldnames + if missing: + raise ValueError(f"missing columns: {', '.join(sorted(missing))}") + + has_turn = "turn" in fieldnames + has_angle = ANGLE_COLUMNS <= fieldnames + has_gp0 = "gp0_falling" in fieldnames + has_gp1 = "gp1_falling" in fieldnames + for row in reader: + sample_time_s = int(row["time_us"]) / 1_000_000 + row_revolution_index = int(row["revolution_index"]) if has_angle and row["revolution_index"] else 0 + row_crank_angle_deg = float(row["crank_angle_deg"]) if has_angle and row["crank_angle_deg"] else 0.0 + + if row["rpm_raw"] and row["rpm_lpf"]: + time_s.append(sample_time_s) + revolution_index.append(row_revolution_index) + crank_angle_deg.append(row_crank_angle_deg) + rpm_raw.append(float(row["rpm_raw"])) + rpm_lpf.append(float(row["rpm_lpf"])) + if has_turn and int(row["turn"]): + turn_time_s.append(sample_time_s) + if has_gp0 and int(row["gp0_falling"]): + gp0_falling.append( + GpEvent( + time_s=sample_time_s, + revolution_index=row_revolution_index, + crank_angle_deg=row_crank_angle_deg, + ) + ) + if has_gp1 and int(row["gp1_falling"]): + gp1_falling.append( + GpEvent( + time_s=sample_time_s, + revolution_index=row_revolution_index, + crank_angle_deg=row_crank_angle_deg, + ) + ) + + return RpmData( + time_s=time_s, + revolution_index=revolution_index, + crank_angle_deg=crank_angle_deg, + rpm_raw=rpm_raw, + rpm_lpf=rpm_lpf, + turn_time_s=turn_time_s, + gp0_falling=gp0_falling, + gp1_falling=gp1_falling, + ) + + +def iter_csv_paths(path: Path) -> list[Path]: + if path.is_dir(): + return sorted(path.rglob("*.csv")) + return [path] + + +def plot_time(data: RpmData, ax: plt.Axes) -> None: + ax.plot(data.time_s, data.rpm_raw, label="raw", linewidth=0.75, alpha=0.35) + ax.plot(data.time_s, data.rpm_lpf, label="lpf", linewidth=1.4) + + for turn_time in data.turn_time_s: + ax.axvline(turn_time, color="0.7", linewidth=0.5, alpha=0.35) + + ax.set_xlabel("time (s)") + + +def selected_gp_events(data: RpmData, gp_mode: str) -> list[tuple[str, list[GpEvent], str]]: + selected: list[tuple[str, list[GpEvent], str]] = [] + if gp_mode in {"gp0", "both"}: + selected.append(("gp0 falling", data.gp0_falling, "tab:green")) + if gp_mode in {"gp1", "both"}: + selected.append(("gp1 falling", data.gp1_falling, "tab:red")) + return selected + + +def plot_gp_bars(data: RpmData, ax: plt.Axes, *, x_axis: str, gp_mode: str) -> None: + for label, events, color in selected_gp_events(data, gp_mode): + plotted_label = False + for event in events: + if x_axis == "time": + x_value = event.time_s + elif x_axis in {"angle", "angle-overlay"}: + if event.revolution_index < 0: + continue + x_value = event.crank_angle_deg + else: + if event.revolution_index < 0: + continue + x_value = event.revolution_index * 360.0 + event.crank_angle_deg + + ax.axvline( + x_value, + color=color, + alpha=0.18, + linewidth=5.0, + label=label if not plotted_label else None, + ) + plotted_label = True + + +def has_angle_data(data: RpmData) -> bool: + return any(data.revolution_index) or any(data.crank_angle_deg) + + +def plot_angle_overlay(data: RpmData, ax: plt.Axes) -> None: + revolutions = sorted(set(data.revolution_index)) + raw_label_used = False + lpf_label_used = False + + for revolution in revolutions: + indices = [ + index + for index, value in enumerate(data.revolution_index) + if value == revolution and value >= 0 + ] + if len(indices) < 2: + continue + + angles = [data.crank_angle_deg[index] for index in indices] + raw = [data.rpm_raw[index] for index in indices] + lpf = [data.rpm_lpf[index] for index in indices] + + ax.plot( + angles, + raw, + color="0.55", + linewidth=0.5, + alpha=0.12, + label="raw" if not raw_label_used else None, + ) + ax.plot( + angles, + lpf, + linewidth=0.8, + alpha=0.28, + label="lpf per rev" if not lpf_label_used else None, + ) + raw_label_used = True + lpf_label_used = True + + ax.set_xlim(0, 360) + ax.set_xlabel("crank angle (deg)") + + +def plot_angle_continuous(data: RpmData, ax: plt.Axes) -> None: + indices = [ + index + for index, revolution in enumerate(data.revolution_index) + if revolution >= 0 + ] + if not indices: + return + + continuous_angle = [ + data.revolution_index[index] * 360.0 + data.crank_angle_deg[index] + for index in indices + ] + rpm_raw = [data.rpm_raw[index] for index in indices] + rpm_lpf = [data.rpm_lpf[index] for index in indices] + + ax.plot(continuous_angle, rpm_raw, label="raw", linewidth=0.75, alpha=0.35) + ax.plot(continuous_angle, rpm_lpf, label="lpf", linewidth=1.4) + + max_angle = max(continuous_angle) + turn_angle = 0.0 + while turn_angle <= max_angle: + ax.axvline(turn_angle, color="0.7", linewidth=0.5, alpha=0.35) + turn_angle += 360.0 + + ax.set_xlim(0, max_angle) + ax.xaxis.set_major_locator(MultipleLocator(360.0)) + ax.xaxis.set_minor_locator(MultipleLocator(180.0)) + ax.set_xlabel("continuous crank angle (deg)") + + +def plot_rpm(csv_path: Path, output: Path | None, *, x_axis: str, gp_mode: str) -> bool: + try: + data = read_rpm(csv_path) + except (OSError, ValueError) as exc: + print(f"Could not read {csv_path}: {exc}", file=sys.stderr) + return False + + if not data.time_s: + print(f"Skipping empty RPM file: {csv_path}", file=sys.stderr) + return True + if x_axis.startswith("angle") and not has_angle_data(data): + print(f"Could not plot crank angle for {csv_path}: missing angle columns", file=sys.stderr) + return False + + fig, ax = plt.subplots(figsize=(12, 5)) + if x_axis in {"angle", "angle-overlay"}: + plot_angle_overlay(data, ax) + elif x_axis == "angle-continuous": + plot_angle_continuous(data, ax) + else: + plot_time(data, ax) + plot_gp_bars(data, ax, x_axis=x_axis, gp_mode=gp_mode) + + ax.set_ylabel("RPM") + ax.set_title(str(csv_path)) + ax.grid(alpha=0.25) + if x_axis == "angle-continuous": + ax.grid(which="minor", axis="x", alpha=0.15, linewidth=0.6) + ax.legend() + fig.tight_layout() + + if output is not None: + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=160) + print(f"Wrote {output}") + plt.close(fig) + else: + plt.show() + plt.close(fig) + + return True + + +def output_path_for(csv_path: Path, output: Path | None, multiple: bool) -> Path | None: + if output is None: + return None + if not multiple: + return output + return output / f"{csv_path.stem}.png" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot raw and filtered RPM CSV files.") + parser.add_argument("csv_path", type=Path, help="RPM CSV file or directory of RPM CSV files.") + parser.add_argument("-o", "--output", type=Path, help="Optional image output path or directory.") + parser.add_argument( + "--x", + choices=("time", "angle", "angle-overlay", "angle-continuous"), + default="time", + help="Plot RPM against time, overlaid 0-360 crank angle, or continuous crank angle.", + ) + parser.add_argument( + "--gp", + choices=("none", "gp0", "gp1", "both"), + default="none", + help="Overlay GP falling events as translucent vertical bars.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if not args.csv_path.exists(): + print(f"Path does not exist: {args.csv_path}", file=sys.stderr) + return 1 + + csv_paths = iter_csv_paths(args.csv_path) + if not csv_paths: + print(f"No CSV files found under {args.csv_path}", file=sys.stderr) + return 1 + + multiple = len(csv_paths) > 1 + if multiple and args.output is not None and args.output.suffix: + print("When plotting a directory, --output must be a directory.", file=sys.stderr) + return 1 + + failures = 0 + for csv_path in csv_paths: + output = output_path_for(csv_path, args.output, multiple) + if multiple and output is None: + print(f"Plotting {csv_path}") + if not plot_rpm(csv_path, output, x_axis=args.x, gp_mode=args.gp): + failures += 1 + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Python/process_rpm.py b/Python/process_rpm.py new file mode 100644 index 0000000..adc9237 --- /dev/null +++ b/Python/process_rpm.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import argparse +import csv +import statistics +import sys +from bisect import bisect_right +from dataclasses import dataclass +from pathlib import Path + + +INPUT_COLUMNS = {"time_us", "pulse"} +GP_COLUMNS = ("gp0_falling", "gp1_falling") +OUTPUT_COLUMNS = ( + "time_us", + "pulse_index", + "revolution_index", + "crank_angle_deg", + "turn", + *GP_COLUMNS, + "rpm_raw", + "rpm_lpf", +) +DEFAULT_PPR = 256 + + +@dataclass(frozen=True) +class Events: + pulse_times: list[int] + turn_times: list[int] + gp0_falling: list[int] + gp1_falling: list[int] + + +@dataclass(frozen=True) +class RpmSample: + time_us: int + pulse_index: int + revolution_index: int + crank_angle_deg: float + turn: int + rpm_raw: float + rpm_lpf: float + + +@dataclass(frozen=True) +class OutputRow: + time_us: int + pulse_index: int | None + revolution_index: int | None + crank_angle_deg: float | None + turn: int + gp0_falling: int + gp1_falling: int + rpm_raw: float | None + rpm_lpf: float | None + + +def read_events(path: Path) -> Events: + pulse_times: list[int] = [] + turn_times: list[int] = [] + gp0_falling: list[int] = [] + gp1_falling: list[int] = [] + + with path.open(newline="") as file: + reader = csv.DictReader(file) + fieldnames = set(reader.fieldnames or ()) + missing = INPUT_COLUMNS - fieldnames + if missing: + raise ValueError(f"missing columns: {', '.join(sorted(missing))}") + + has_turn = "turn" in fieldnames + for row in reader: + time_us = int(row["time_us"]) + if int(row["pulse"]): + pulse_times.append(time_us) + if has_turn and int(row["turn"]): + turn_times.append(time_us) + if "gp0_falling" in fieldnames and int(row["gp0_falling"]): + gp0_falling.append(time_us) + if "gp1_falling" in fieldnames and int(row["gp1_falling"]): + gp1_falling.append(time_us) + + return Events( + pulse_times=sorted(pulse_times), + turn_times=sorted(turn_times), + gp0_falling=sorted(gp0_falling), + gp1_falling=sorted(gp1_falling), + ) + + +def median_or_none(values: list[float]) -> float | None: + if not values: + return None + return float(statistics.median(values)) + + +def despike(values: list[float], *, window: int, sigma: float) -> tuple[list[float], int]: + if len(values) < 3: + return values[:], 0 + + radius = max(1, window // 2) + cleaned = values[:] + replaced = 0 + + for index, value in enumerate(values): + start = max(0, index - radius) + end = min(len(values), index + radius + 1) + local = values[start:end] + median = median_or_none(local) + if median is None: + continue + + deviations = [abs(sample - median) for sample in local] + mad = median_or_none(deviations) or 0.0 + threshold = sigma * 1.4826 * mad + + if threshold <= 0.0: + threshold = max(1.0, abs(median) * 0.25) + + if abs(value - median) > threshold: + cleaned[index] = median + replaced += 1 + + return cleaned, replaced + + +def ema(values: list[float], alpha: float) -> list[float]: + if not values: + return [] + + filtered = [values[0]] + previous = values[0] + for value in values[1:]: + previous = alpha * value + (1.0 - alpha) * previous + filtered.append(previous) + + return filtered + + +def nearest_index(times: list[int], target: int) -> int | None: + if not times: + return None + + lo = 0 + hi = len(times) + while lo < hi: + mid = (lo + hi) // 2 + if times[mid] < target: + lo = mid + 1 + else: + hi = mid + + candidates = [] + if lo < len(times): + candidates.append(lo) + if lo > 0: + candidates.append(lo - 1) + + return min(candidates, key=lambda index: abs(times[index] - target)) + + +def robust_median_interval(times: list[int]) -> float | None: + intervals = [b - a for a, b in zip(times, times[1:]) if b > a] + if not intervals: + return None + + intervals = sorted(intervals) + if len(intervals) >= 20: + trim = len(intervals) // 20 + intervals = intervals[trim : len(intervals) - trim] + + return float(statistics.median(intervals)) + + +def choose_turn_phase(pulse_times: list[int], turn_times: list[int], ppr: int) -> int: + if not pulse_times or not turn_times: + return 0 + + pulse_interval = robust_median_interval(pulse_times) + if pulse_interval is None: + return 0 + + max_distance_us = max(1_000, int(pulse_interval * 10)) + scores = [0.0] * ppr + + for turn_time in turn_times: + pulse_index = nearest_index(pulse_times, turn_time) + if pulse_index is None: + continue + distance = abs(pulse_times[pulse_index] - turn_time) + if distance <= max_distance_us: + scores[pulse_index % ppr] += 1.0 - distance / max_distance_us + + best_score = max(scores) + if best_score <= 0.0: + return 0 + + return scores.index(best_score) + + +def revolution_index_for(pulse_index: int, phase: int, ppr: int) -> int: + return (pulse_index - phase) // ppr + + +def calculate_rpm( + events: Events, + *, + ppr: int, + filter_window: int, + filter_alpha: float, + hampel_sigma: float, +) -> tuple[list[RpmSample], int]: + raw_rows: list[tuple[int, int, int, float, int, float]] = [] + pulse_times = events.pulse_times + phase = choose_turn_phase(pulse_times, events.turn_times, ppr) + degrees_per_pulse = 360.0 / ppr + + for pulse_index, (previous_time, current_time) in enumerate(zip(pulse_times, pulse_times[1:]), start=1): + delta_us = current_time - previous_time + if delta_us <= 0: + continue + + rpm = 60_000_000.0 / (delta_us * ppr) + angle_pulse = (pulse_index - phase) % ppr + angle_deg = angle_pulse * degrees_per_pulse + revolution_index = revolution_index_for(pulse_index, phase, ppr) + turn = 1 if angle_pulse == 0 else 0 + raw_rows.append((current_time, pulse_index, revolution_index, angle_deg, turn, rpm)) + + raw_rpm = [row[5] for row in raw_rows] + despiked_rpm, replaced = despike(raw_rpm, window=filter_window, sigma=hampel_sigma) + filtered_rpm = ema(despiked_rpm, filter_alpha) + + samples = [ + RpmSample( + time_us=time_us, + pulse_index=pulse_index, + revolution_index=revolution_index, + crank_angle_deg=angle_deg, + turn=turn, + rpm_raw=rpm_raw, + rpm_lpf=rpm_lpf, + ) + for (time_us, pulse_index, revolution_index, angle_deg, turn, rpm_raw), rpm_lpf in zip( + raw_rows, + filtered_rpm, + ) + ] + + return samples, replaced + + +def pulse_position_for_time( + pulse_times: list[int], + time_us: int, + *, + phase: int, + ppr: int, +) -> tuple[int, int, float]: + pulse_index = bisect_right(pulse_times, time_us) - 1 + if pulse_index < 0: + pulse_index = 0 + + angle_pulse = (pulse_index - phase) % ppr + revolution_index = revolution_index_for(pulse_index, phase, ppr) + angle_deg = angle_pulse * (360.0 / ppr) + + return pulse_index, revolution_index, angle_deg + + +def build_output_rows( + events: Events, + samples: list[RpmSample], + *, + ppr: int, +) -> list[OutputRow]: + phase = choose_turn_phase(events.pulse_times, events.turn_times, ppr) + rows: dict[tuple[int, int], OutputRow] = {} + + for sample in samples: + rows[(sample.time_us, 0)] = OutputRow( + time_us=sample.time_us, + pulse_index=sample.pulse_index, + revolution_index=sample.revolution_index, + crank_angle_deg=sample.crank_angle_deg, + turn=sample.turn, + gp0_falling=0, + gp1_falling=0, + rpm_raw=sample.rpm_raw, + rpm_lpf=sample.rpm_lpf, + ) + + for channel_index, gp_times in enumerate((events.gp0_falling, events.gp1_falling), start=1): + for gp_time in gp_times: + pulse_index, revolution_index, angle_deg = pulse_position_for_time( + events.pulse_times, + gp_time, + phase=phase, + ppr=ppr, + ) + existing_key = (gp_time, 0) + if existing_key in rows: + existing = rows[existing_key] + rows[existing_key] = OutputRow( + time_us=existing.time_us, + pulse_index=existing.pulse_index, + revolution_index=existing.revolution_index, + crank_angle_deg=existing.crank_angle_deg, + turn=existing.turn, + gp0_falling=1 if channel_index == 1 else existing.gp0_falling, + gp1_falling=1 if channel_index == 2 else existing.gp1_falling, + rpm_raw=existing.rpm_raw, + rpm_lpf=existing.rpm_lpf, + ) + continue + + rows[(gp_time, channel_index)] = OutputRow( + time_us=gp_time, + pulse_index=pulse_index, + revolution_index=revolution_index, + crank_angle_deg=angle_deg, + turn=0, + gp0_falling=1 if channel_index == 1 else 0, + gp1_falling=1 if channel_index == 2 else 0, + rpm_raw=None, + rpm_lpf=None, + ) + + return [rows[key] for key in sorted(rows)] + + +def write_rpm(path: Path, rows: list[OutputRow]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as file: + writer = csv.writer(file) + writer.writerow(OUTPUT_COLUMNS) + for row in rows: + writer.writerow( + ( + row.time_us, + "" if row.pulse_index is None else row.pulse_index, + "" if row.revolution_index is None else row.revolution_index, + "" if row.crank_angle_deg is None else f"{row.crank_angle_deg:.6f}", + row.turn, + row.gp0_falling, + row.gp1_falling, + "" if row.rpm_raw is None else f"{row.rpm_raw:.3f}", + "" if row.rpm_lpf is None else f"{row.rpm_lpf:.3f}", + ) + ) + + +def output_path_for(input_path: Path, input_root: Path, output_root: Path) -> Path: + relative = input_path.relative_to(input_root) + stem = relative.stem + if stem.endswith("_cleaned"): + stem = stem[: -len("_cleaned")] + return output_root / relative.with_name(f"{stem}_rpm.csv") + + +def process_file( + input_path: Path, + output_path: Path, + *, + ppr: int, + filter_window: int, + filter_alpha: float, + hampel_sigma: float, +) -> tuple[int, int]: + events = read_events(input_path) + samples, replaced = calculate_rpm( + events, + ppr=ppr, + filter_window=filter_window, + filter_alpha=filter_alpha, + hampel_sigma=hampel_sigma, + ) + rows = build_output_rows(events, samples, ppr=ppr) + write_rpm(output_path, rows) + return len(samples), replaced + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Convert cleaned pulse event CSVs into raw and filtered RPM CSVs." + ) + parser.add_argument( + "--input-root", + type=Path, + default=Path("recordings_cleaned"), + help="Folder to search recursively for cleaned CSV files.", + ) + parser.add_argument( + "--output-root", + type=Path, + default=Path("recordings_rpm"), + help="Folder where RPM CSV files are written.", + ) + parser.add_argument("--ppr", type=int, default=DEFAULT_PPR, help="Pulse encoder pulses per revolution.") + parser.add_argument( + "--filter-window", + type=int, + default=5, + help="Odd sample count for local despiking before low-pass filtering.", + ) + parser.add_argument( + "--filter-alpha", + type=float, + default=0.45, + help="EMA alpha for the low-pass RPM column. Higher follows transients faster.", + ) + parser.add_argument( + "--hampel-sigma", + type=float, + default=4.0, + help="Local median absolute deviation threshold for replacing single-sample outliers.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + if not 0.0 < args.filter_alpha <= 1.0: + print("--filter-alpha must be in the range (0, 1].", file=sys.stderr) + return 1 + if args.filter_window < 3: + print("--filter-window must be at least 3.", file=sys.stderr) + return 1 + + input_root = args.input_root + output_root = args.output_root + if not input_root.exists(): + print(f"Input folder does not exist: {input_root}", file=sys.stderr) + return 1 + + csv_paths = sorted(path for path in input_root.rglob("*.csv") if not path.name.endswith("_rpm.csv")) + if not csv_paths: + print(f"No CSV files found under {input_root}", file=sys.stderr) + return 1 + + failures = 0 + for input_path in csv_paths: + output_path = output_path_for(input_path, input_root, output_root) + try: + samples, replaced = process_file( + input_path, + output_path, + ppr=args.ppr, + filter_window=args.filter_window, + filter_alpha=args.filter_alpha, + hampel_sigma=args.hampel_sigma, + ) + except (OSError, ValueError) as exc: + failures += 1 + print(f"Skipping {input_path}: {exc}", file=sys.stderr) + continue + + print( + f"{input_path} -> {output_path} " + f"samples={samples} despiked={replaced}" + ) + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Python/pyproject.toml b/Python/pyproject.toml index 418c6bc..f45905c 100644 --- a/Python/pyproject.toml +++ b/Python/pyproject.toml @@ -5,5 +5,6 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "matplotlib", "pyserial>=3.5", ] diff --git a/Python/uv.lock b/Python/uv.lock index 4e7ec1e..3fea73a 100644 --- a/Python/uv.lock +++ b/Python/uv.lock @@ -2,16 +2,238 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "engine-recorder-dump" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "matplotlib" }, { name = "pyserial" }, ] [package.metadata] -requires-dist = [{ name = "pyserial", specifier = ">=3.5" }] +requires-dist = [ + { name = "matplotlib" }, + { name = "pyserial", specifier = ">=3.5" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] [[package]] name = "pyserial" @@ -21,3 +243,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6 wheels = [ { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, ] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +]