Update to high speed encoder stream
This commit is contained in:
@@ -12,3 +12,4 @@
|
||||
platform = atmelavr
|
||||
board = megaatmega2560
|
||||
framework = arduino
|
||||
monitor_speed = 921600
|
||||
|
||||
@@ -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>
|
||||
|
||||
#define PIN_REG 2
|
||||
#define PIN_SYNC 3
|
||||
#define REG_SIZE 256
|
||||
#define SYNC_SIZE 256
|
||||
#ifndef ARDUINO_ISR_ATTR
|
||||
#define ARDUINO_ISR_ATTR
|
||||
#endif
|
||||
|
||||
uint32_t REG_BUFF[REG_SIZE];
|
||||
uint32_t SYNC_BUFF[SYNC_SIZE];
|
||||
constexpr uint8_t TURN_PIN = 27;
|
||||
constexpr uint8_t PULSE_PIN = 26;
|
||||
constexpr uint8_t GP_PINS[] = {32, 33};
|
||||
|
||||
volatile uint8_t REG_IR = 0;
|
||||
volatile uint8_t REG_IW = 0;
|
||||
volatile uint8_t SYNC_IR = 0;
|
||||
volatile uint8_t SYNC_IW = 0;
|
||||
constexpr uint32_t BAUD_RATE = 921600;
|
||||
constexpr size_t EVENT_BUFFER_SIZE = 256;
|
||||
constexpr size_t GP_INPUT_COUNT = sizeof(GP_PINS) / sizeof(GP_PINS[0]);
|
||||
constexpr size_t PACKET_PULSE_COUNT = 16;
|
||||
constexpr size_t INPUT_PULSES_PER_TURN = 1024;
|
||||
constexpr size_t PULSE_DECIMATION = 4;
|
||||
constexpr size_t OUTPUT_PULSES_PER_TURN = INPUT_PULSES_PER_TURN / PULSE_DECIMATION;
|
||||
constexpr uint32_t UINT16_DELTA_MAX = UINT16_MAX;
|
||||
|
||||
void append_buff_reg();
|
||||
void append_buff_sync();
|
||||
static_assert(INPUT_PULSES_PER_TURN % PULSE_DECIMATION == 0);
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
constexpr uint8_t TURN_MAGIC[] = {0xE7, 0x54, 0xC3, 0xA1};
|
||||
constexpr uint8_t PULSE16_MAGIC[] = {0xE7, 0x50, 0xC3, 0xA1};
|
||||
constexpr uint8_t PULSE32_MAGIC[] = {0xE7, 0x70, 0xC3, 0xA1};
|
||||
constexpr uint8_t GP_MAGIC[] = {0xE7, 0x47, 0xC3, 0xA1};
|
||||
|
||||
attachInterrupt(digitalPinToInterrupt(PIN_REG), append_buff_reg, FALLING);
|
||||
attachInterrupt(digitalPinToInterrupt(PIN_SYNC), append_buff_sync, FALLING);
|
||||
volatile uint32_t PULSE_REG[EVENT_BUFFER_SIZE];
|
||||
volatile uint8_t PULSE_W_HEAD = 0;
|
||||
uint8_t PULSE_R_HEAD = 0;
|
||||
|
||||
volatile uint32_t TURN_REG[EVENT_BUFFER_SIZE];
|
||||
volatile uint8_t TURN_W_HEAD = 0;
|
||||
uint8_t TURN_R_HEAD = 0;
|
||||
|
||||
volatile uint32_t GP_REG[GP_INPUT_COUNT][EVENT_BUFFER_SIZE];
|
||||
volatile uint8_t GP_W_HEAD[GP_INPUT_COUNT] = {};
|
||||
uint8_t GP_R_HEAD[GP_INPUT_COUNT] = {};
|
||||
|
||||
uint32_t packet_deltas[PACKET_PULSE_COUNT];
|
||||
size_t packet_count = 0;
|
||||
size_t raw_turn_pulse_count = 0;
|
||||
size_t output_turn_pulse_count = 0;
|
||||
uint32_t previous_timestamp = 0;
|
||||
bool have_active_turn = false;
|
||||
|
||||
void ARDUINO_ISR_ATTR handle_pulse_isr() {
|
||||
PULSE_REG[PULSE_W_HEAD] = micros();
|
||||
PULSE_W_HEAD = PULSE_W_HEAD + 1;
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (Serial.availableForWrite() >= 5) {
|
||||
if (REG_IW != REG_IR) {
|
||||
Serial.write('R');
|
||||
Serial.write((uint8_t *)®_BUFF[REG_IR], sizeof(uint32_t));
|
||||
REG_IR++;
|
||||
void ARDUINO_ISR_ATTR handle_turn_isr() {
|
||||
TURN_REG[TURN_W_HEAD] = micros();
|
||||
TURN_W_HEAD = TURN_W_HEAD + 1;
|
||||
}
|
||||
|
||||
void ARDUINO_ISR_ATTR handle_gp_falling(size_t input) {
|
||||
GP_REG[input][GP_W_HEAD[input]] = micros();
|
||||
GP_W_HEAD[input] = GP_W_HEAD[input] + 1;
|
||||
}
|
||||
|
||||
void ARDUINO_ISR_ATTR handle_gp0_isr() {
|
||||
handle_gp_falling(0);
|
||||
}
|
||||
|
||||
void ARDUINO_ISR_ATTR handle_gp1_isr() {
|
||||
handle_gp_falling(1);
|
||||
}
|
||||
|
||||
bool timestamp_is_before(uint32_t lhs, uint32_t rhs) {
|
||||
return static_cast<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 (SYNC_IW != SYNC_IR) {
|
||||
Serial.write('S');
|
||||
Serial.write((uint8_t *)&SYNC_BUFF[SYNC_IR], sizeof(uint32_t));
|
||||
SYNC_IR++;
|
||||
if (packet_deltas[index] > UINT16_DELTA_MAX) {
|
||||
needs_u32 = true;
|
||||
}
|
||||
}
|
||||
|
||||
write_magic(needs_u32 ? PULSE32_MAGIC : PULSE16_MAGIC);
|
||||
|
||||
if (needs_u32) {
|
||||
for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) {
|
||||
write_u32(packet_deltas[index]);
|
||||
}
|
||||
} else {
|
||||
for (size_t index = 0; index < PACKET_PULSE_COUNT; index++) {
|
||||
write_u16(static_cast<uint16_t>(packet_deltas[index]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void append_buff_reg() {
|
||||
REG_BUFF[REG_IW] = micros();
|
||||
REG_IW++;
|
||||
void flush_partial_packet() {
|
||||
if (packet_count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
send_packet(packet_count);
|
||||
packet_count = 0;
|
||||
}
|
||||
|
||||
void append_buff_sync() {
|
||||
SYNC_BUFF[SYNC_IW] = micros();
|
||||
SYNC_IW++;
|
||||
void start_turn(uint32_t timestamp) {
|
||||
if (have_active_turn) {
|
||||
flush_partial_packet();
|
||||
}
|
||||
|
||||
write_magic(TURN_MAGIC);
|
||||
write_u32(timestamp);
|
||||
|
||||
have_active_turn = true;
|
||||
previous_timestamp = timestamp;
|
||||
packet_count = 0;
|
||||
raw_turn_pulse_count = 0;
|
||||
output_turn_pulse_count = 0;
|
||||
}
|
||||
|
||||
void handle_pulse(uint32_t timestamp) {
|
||||
if (!have_active_turn || raw_turn_pulse_count >= INPUT_PULSES_PER_TURN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool should_emit = raw_turn_pulse_count % PULSE_DECIMATION == 0;
|
||||
raw_turn_pulse_count++;
|
||||
|
||||
if (!should_emit || output_turn_pulse_count >= OUTPUT_PULSES_PER_TURN) {
|
||||
return;
|
||||
}
|
||||
|
||||
packet_deltas[packet_count] = timestamp - previous_timestamp;
|
||||
previous_timestamp = timestamp;
|
||||
packet_count++;
|
||||
output_turn_pulse_count++;
|
||||
|
||||
if (packet_count == PACKET_PULSE_COUNT) {
|
||||
send_packet(PACKET_PULSE_COUNT);
|
||||
packet_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void handle_gp(size_t channel, uint32_t timestamp) {
|
||||
if (!have_active_turn) {
|
||||
return;
|
||||
}
|
||||
|
||||
write_magic(GP_MAGIC);
|
||||
Serial.write(static_cast<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();
|
||||
}
|
||||
|
||||
5
ESP32/.gitignore
vendored
Normal file
5
ESP32/.gitignore
vendored
Normal 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
37
ESP32/include/README
Normal 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
46
ESP32/lib/README
Normal 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
15
ESP32/platformio.ini
Normal 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
224
ESP32/src/main.cpp
Normal 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
11
ESP32/test/README
Normal 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
394
Python/clean_recordings.py
Normal 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())
|
||||
283
Python/parse_binary_stream.py
Normal file
283
Python/parse_binary_stream.py
Normal 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
118
Python/plot_events.py
Normal 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
323
Python/plot_rpm.py
Normal 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
469
Python/process_rpm.py
Normal 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())
|
||||
@@ -5,5 +5,6 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"matplotlib",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
245
Python/uv.lock
generated
245
Python/uv.lock
generated
@@ -2,16 +2,238 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "contourpy"
|
||||
version = "1.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cycler"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engine-recorder-dump"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "matplotlib" },
|
||||
{ name = "pyserial" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "pyserial", specifier = ">=3.5" }]
|
||||
requires-dist = [
|
||||
{ name = "matplotlib" },
|
||||
{ name = "pyserial", specifier = ">=3.5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.63.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kiwisolver"
|
||||
version = "1.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matplotlib"
|
||||
version = "3.10.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "contourpy" },
|
||||
{ name = "cycler" },
|
||||
{ name = "fonttools" },
|
||||
{ name = "kiwisolver" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyserial"
|
||||
@@ -21,3 +243,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user