Update to high speed encoder stream

This commit is contained in:
2026-06-05 00:13:50 +02:00
parent 7e44092d3f
commit fe1bb0b4bb
15 changed files with 2381 additions and 37 deletions

View File

@@ -12,3 +12,4 @@
platform = atmelavr platform = atmelavr
board = megaatmega2560 board = megaatmega2560
framework = arduino framework = arduino
monitor_speed = 921600

View File

@@ -1,54 +1,228 @@
// Copyright (C) 2026 Pierre Barbier <pierrebarbier741@gmail.com>
// Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <Arduino.h> #include <Arduino.h>
#define PIN_REG 2 #ifndef ARDUINO_ISR_ATTR
#define PIN_SYNC 3 #define ARDUINO_ISR_ATTR
#define REG_SIZE 256 #endif
#define SYNC_SIZE 256
uint32_t REG_BUFF[REG_SIZE]; constexpr uint8_t TURN_PIN = 27;
uint32_t SYNC_BUFF[SYNC_SIZE]; constexpr uint8_t PULSE_PIN = 26;
constexpr uint8_t GP_PINS[] = {32, 33};
volatile uint8_t REG_IR = 0; constexpr uint32_t BAUD_RATE = 921600;
volatile uint8_t REG_IW = 0; constexpr size_t EVENT_BUFFER_SIZE = 256;
volatile uint8_t SYNC_IR = 0; constexpr size_t GP_INPUT_COUNT = sizeof(GP_PINS) / sizeof(GP_PINS[0]);
volatile uint8_t SYNC_IW = 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(); static_assert(INPUT_PULSES_PER_TURN % PULSE_DECIMATION == 0);
void append_buff_sync();
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<int32_t>(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<const uint8_t *>(&value), sizeof(value));
}
void write_u32(uint32_t value) {
Serial.write(reinterpret_cast<const uint8_t *>(&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<uint16_t>(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<uint8_t>(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() { void setup() {
Serial.begin(115200); Serial.begin(BAUD_RATE);
attachInterrupt(digitalPinToInterrupt(TURN_PIN), handle_turn_isr, RISING);
attachInterrupt(digitalPinToInterrupt(PIN_REG), append_buff_reg, FALLING); attachInterrupt(digitalPinToInterrupt(PULSE_PIN), handle_pulse_isr, FALLING);
attachInterrupt(digitalPinToInterrupt(PIN_SYNC), append_buff_sync, FALLING); attachInterrupt(digitalPinToInterrupt(GP_PINS[0]), handle_gp0_isr, FALLING);
attachInterrupt(digitalPinToInterrupt(GP_PINS[1]), handle_gp1_isr, FALLING);
} }
void loop() { void loop() {
if (Serial.availableForWrite() >= 5) { process_next_event();
if (REG_IW != REG_IR) {
Serial.write('R');
Serial.write((uint8_t *)&REG_BUFF[REG_IR], sizeof(uint32_t));
REG_IR++;
}
if (SYNC_IW != SYNC_IR) {
Serial.write('S');
Serial.write((uint8_t *)&SYNC_BUFF[SYNC_IR], sizeof(uint32_t));
SYNC_IR++;
}
}
}
void append_buff_reg() {
REG_BUFF[REG_IW] = micros();
REG_IW++;
}
void append_buff_sync() {
SYNC_BUFF[SYNC_IW] = micros();
SYNC_IW++;
} }

5
ESP32/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

37
ESP32/include/README Normal file
View File

@@ -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

46
ESP32/lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

15
ESP32/platformio.ini Normal file
View File

@@ -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

224
ESP32/src/main.cpp Normal file
View File

@@ -0,0 +1,224 @@
#include <Arduino.h>
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<int32_t>(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<const uint8_t *>(&value), sizeof(value));
}
void write_u32(uint32_t value) {
Serial.write(reinterpret_cast<const uint8_t *>(&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<uint16_t>(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<uint8_t>(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();
}

11
ESP32/test/README Normal file
View File

@@ -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

394
Python/clean_recordings.py Normal file
View File

@@ -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())

View File

@@ -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("<I", payload)[0]
previous_pulse_timestamp = timestamp
events.add(timestamp, "turn")
elif frame_type == "pulse16":
payload = read_exact(
stream,
16 * 2,
deadline=deadline,
eof_on_empty=eof_on_empty,
)
if payload is None:
break
if previous_pulse_timestamp is not None:
for delta in struct.unpack("<16H", payload):
if delta == 0:
continue
previous_pulse_timestamp = (
previous_pulse_timestamp + delta
) & UINT32_MASK
events.add(previous_pulse_timestamp, "pulse")
elif frame_type == "pulse32":
payload = read_exact(
stream,
16 * 4,
deadline=deadline,
eof_on_empty=eof_on_empty,
)
if payload is None:
break
if previous_pulse_timestamp is not None:
for delta in struct.unpack("<16I", payload):
if delta == 0:
continue
previous_pulse_timestamp = (
previous_pulse_timestamp + delta
) & UINT32_MASK
events.add(previous_pulse_timestamp, "pulse")
elif frame_type == "gp":
payload = read_exact(
stream,
5,
deadline=deadline,
eof_on_empty=eof_on_empty,
)
if payload is None:
break
channel = payload[0]
timestamp = struct.unpack("<I", payload[1:])[0]
if channel < len(GP_COLUMNS):
events.add(timestamp, GP_COLUMNS[channel])
frames += 1
return frames
def default_output_path() -> 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())

118
Python/plot_events.py Normal file
View File

@@ -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())

323
Python/plot_rpm.py Normal file
View File

@@ -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())

469
Python/process_rpm.py Normal file
View File

@@ -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())

View File

@@ -5,5 +5,6 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"matplotlib",
"pyserial>=3.5", "pyserial>=3.5",
] ]

245
Python/uv.lock generated
View File

@@ -2,16 +2,238 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" 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]] [[package]]
name = "engine-recorder-dump" name = "engine-recorder-dump"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "matplotlib" },
{ name = "pyserial" }, { name = "pyserial" },
] ]
[package.metadata] [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]] [[package]]
name = "pyserial" name = "pyserial"
@@ -21,3 +243,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
wheels = [ 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" }, { 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" },
]