diff --git a/.gitignore b/.gitignore index bfc40973..c3bd3eaf 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,27 @@ test_*.py *.csv .vscode/* test_delsys_api.py -resources/ \ No newline at end of file +resources/ +*.csv +*.txt +ContinuousTransitions/* +FORS-EMG/* +MyoDisCo/* +NinaProDB1/* +*.zip +libemg/_datasets/__pycache__/* +CIILData/* +EMGEPN612.pkl +OneSubjectMyoDataset/ +_3DCDataset/ +ContractionIntensity/ +CIILData/ +*.pkl +LimbPosition/ +CNN.py +MLP.py +__pycache__/ +MLPR.py +docs/Makefile +sifibridge-* +*.pyc diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/docs/example_data/OneSubjectMyoDataset b/docs/example_data/OneSubjectMyoDataset deleted file mode 160000 index 0a012e01..00000000 --- a/docs/example_data/OneSubjectMyoDataset +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0a012e015388b299e6753618b11bc8702226e511 diff --git a/docs/source/conf.py b/docs/source/conf.py index b1b1b756..54a44676 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,7 +22,7 @@ author = 'Ethan Eddy, Evan Campbell, Angkoon Phinyomark, Scott Bateman, and Erik Scheme' # The full version, including alpha/beta/rc tags -release = '1.0.0' +release = '2.0.0' # -- General configuration --------------------------------------------------- diff --git a/docs/source/doc.md b/docs/source/doc.md index b378126b..1243a575 100644 --- a/docs/source/doc.md +++ b/docs/source/doc.md @@ -20,6 +20,10 @@ The goal of this library is to provide an easy to use and feature-rich API for d $ pip install libemg ``` +# Interactive API Walkthrough + +Interactive offline and online walkthroughs were created as part of a `LibEMG` workshop we presented at [MEC24](https://www.unb.ca/ibme/mec/index.html), hosted by the Institute of Biomedical Engineering. These walkthroughs step through much of the core functionality of `LibEMG`, and offer visuals to explain pieces of the API. The offline example is formatted as a jupyter notebook, allowing you to see visuals for each code snippet without running the code yourself. Please check out out the workshop GitHub repository to see these interactive API walkthroughs: . + # Questions Check out our discord server if you have questions, comments, or feature requests: https://discord.gg/NeqTTXmM4F diff --git a/docs/source/documentation/adaptation/adaptation.rst b/docs/source/documentation/adaptation/adaptation.rst new file mode 100644 index 00000000..ebf4fd85 --- /dev/null +++ b/docs/source/documentation/adaptation/adaptation.rst @@ -0,0 +1,4 @@ +Online Adaptation +------------------------------ +.. include:: adaptation_doc.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/documentation/adaptation/adaptation_doc.md b/docs/source/documentation/adaptation/adaptation_doc.md new file mode 100644 index 00000000..368de5f0 --- /dev/null +++ b/docs/source/documentation/adaptation/adaptation_doc.md @@ -0,0 +1,240 @@ +[View Source Code](https://github.com/ECEEvanCampbell/CoAdaptUnderCurriculum) + +The offline performance of a myoelectric model (e.g., $R^2$, mean absolute error on screen-guided training data) does not necessarily reflect how usable it is once a person is actually in the loop. A model that looks excellent on a calibration set can still drift or feel unresponsive online. `LibEMG` provides an **adaptation suite** that keeps improving a model *while the user operates it*, using the ongoing interaction as a source of labels. This is the mechanism behind context-informed incremental learning (see [Context-Informed Incremental Learning Improves Throughput and Reduces Drift in Regression-Based Myoelectric Control, Morrell et al. 2025](https://github.com/ECEEvanCampbell/CoAdaptUnderCurriculum)). + +This tutorial builds the minimal pipeline: a **within-subject initialization** from screen-guided training (SGT) data, followed by an **online environment that adapts the model** as the user plays through it. + +# Architecture +Adaptation runs as **four cooperating processes** that communicate over shared memory. The `libemg.adaptation._base.get_edil_adaptation_objects` helper pre-builds every shared-memory item and `OutputWriter` needed to wire them together (each modality buffer and its sample counter are paired on a shared lock so readers always see a consistent snapshot), so you never allocate them by hand. + +1. **`OnlineEMGRegressor`** — the live model. It windows the incoming EMG, extracts features, predicts, and publishes each feature vector (`model_input`) with a timestamp to shared memory. It also watches an `adapt_flag`; when the adaptation manager raises it, the regressor hot-swaps in the newly adapted model. The flag is consulted through its state block, so the common answer that nothing has changed costs a handful of integers rather than three locked reads of the variable. +2. **Environment** (`CurricularFitts`) — the task the user performs. Every frame it turns the current cursor/target geometry into a *pseudo-label* through a `feedback_handle`, and writes that label (`environment_feedback`) with the same timestamp and trial number. +3. **`MemoryManager`** — joins each `environment_feedback` row to the `model_input` row with the matching timestamp, appends the pair to a `Memory`, and saves one memory slice (`memory_.pkl`) at the end of every trial. It is woken by the environment's write instead of asking whether one has happened. +4. **`AdaptationManager`** — loads memory slices (seeded by the SGT data for stability), calls `model.adapt(memory)`, saves the updated model (`mdl.pkl`), and — if `notify=True` — sets the `adapt_flag` so the live model reloads it. It is woken by the memory manager's write in the same way. + +The loop is therefore: **predict → act → pseudo-label → remember → adapt → reload → predict …**, all without pausing the user. + +**Neither manager spins.** Each one waits on a notifier slot and is woken by the write it used to poll for. The memory manager's old loop copied both the feedback buffer and the whole model-input buffer on every pass, just to compare one counter. Their signatures and their behaviour are otherwise unchanged. + +By default the adaptation manager still calls `model.adapt` on every pass of its loop, including passes where no new slice has arrived. Set its `wait_for_memory` attribute to adapt only when a slice actually arrives: + +```Python +adaptation_manager.wait_for_memory = True +``` + +**You supply three things.** The suite is model-agnostic; it only assumes: +- a **model** with `predict(features)`, `adapt(memory)`, `save(path)`, and `load(path)` (the reference repository uses a small MLP / Transformer), +- a **memory** subclassing `libemg.adaptation.memory.Memory` (implementing `append`, `reset`, `save`, `load`, and `__add__`), +- a **feedback function** mapping game state to a pseudo-label — `libemg.adaptation._base.produce_tciil_feedback` is a ready-made regression example. + +**Note:** The snippets below use the `Myo Armband` and a 2-DOF wrist regressor (flexion/extension and radial/ulnar deviation). Any hardware works by switching the `streamer`, `window_size`, and `window_increment`. + +# Building the Same Loop with Hooks + +The four processes above pass work between them by writing to shared memory. A write now announces itself, so the memory and adaptation stages can be written as observers rather than as loops of their own. `libemg.adaptation.hooks` provides them. The layer they are built on is described in the [reactive pipelines guide](../reactive/reactive_doc.md). + +**The existing API is unchanged.** `MemoryManager` and `AdaptationManager` keep their signatures and their behaviour, and Step 2 below still runs exactly as written. The hooks are an alternative way to assemble the same loop, with the same behaviour, for a pipeline that is already reactive. + +Three hooks cover the chain: + +- `MemoryHook` observes `environment_feedback`, pairs each judgement with the model input that produced it, and appends the pair to a memory. A judgement carrying a new trial number closes the slice: the memory is saved as `memory_N.pkl` and `memory_flag` is advanced. +- `AdaptationHook` observes `memory_flag`, loads whatever slices have appeared since it last looked, calls `model.adapt`, saves the new weights as `mdlN.pkl`, and advances `adapt_flag`. +- `ModelSwapHook` observes `adapt_flag` and calls a function of your own with the new model number. It is for anything outside the streaming process that wants to know the model changed, such as a plot marking the moment or a counter of how often adaptation reached the user. + +**The three stages disagree about what a change is.** That disagreement is the argument the whole reactive layer is built around. An item publishes facts, and each observer pairs those facts with its own criterion. + +| Stage | Dirty when | Why | +| --- | --- | --- | +| Memory | Any feedback row arrives, `OnCommit()` | Every judgement the person produced is data worth keeping. | +| Adaptation | A slice is finished, `OnNewSlice()` | Training on a fraction of a trial is worse than waiting for the whole one. | +| Predictor | A new model is published | A swap is rare, and acting on one costs a model load. | + +`MemoryHook` reads `model_input` as well, but its arrival is not a reason to run. It is declared with a criterion that is never dirty, so the inputs are delivered on every run without ever firing one. Feedback rows are joined to inputs **by timestamp** rather than by position, because the two are written by different processes at different rates and their indices do not correspond. + +**Assembling the graph.** This replaces the `MemoryManager` and `AdaptationManager` block of Step 2. Everything else in Step 2 is unchanged, including the shared-memory items from `get_edil_adaptation_objects`. + +```Python +from libemg.adaptation.hooks import MemoryHook, AdaptationHook +from libemg.reactive import ReactiveGraph, default_notifier_pool + +# Every item the two hooks touch, with duplicate tags removed. +adaptation_items = list({item[0]: item for item in + memory_manager_smi + adaptation_manager_smi + model_smi}.values()) + +# Sharing the output writers' pool is what lets their writes wake the hooks. +graph = ReactiveGraph(adaptation_items, notifier_pool=default_notifier_pool()) + +graph.add( + MemoryHook( + memory = MyMemory(...), # empty memory of the same type as the SGT seed + save_dir = ADAPT_DIR, # writes memory_N.pkl + ), + executor='memory') + +graph.add( + AdaptationHook( + model = model, + load_dir = ADAPT_DIR, # reads memory_N.pkl (== MemoryHook.save_dir) + save_dir = ADAPT_DIR, # writes mdlN.pkl (== OnlineEMGRegressor.file_path) + initial_memory_loc = ADAPT_DIR + 'sgt_memory.pkl', # SGT seed for stability + stop_after = NUM_TRIALS - 1, + notify = True, # advance adapt_flag so the live model hot-swaps + ), + executor='adapt') + +graph.start() + +env.run_helper(block=False) # spawn the game +env.process.join() # adapt for as long as the user is playing +graph.stop() +``` + +The two path linkages are the ones Step 2 describes. The adaptation hook's `load_dir` equals the memory hook's `save_dir`, and its `save_dir` equals the online regressor's `file_path`. Each hook runs in its own executor process, so a long training call never delays memory assembly. + +**One behavioural difference is deliberate.** `AdaptationManager` calls `model.adapt` on every pass of its loop, including passes where no new memory arrived, so it retrains continuously on unchanged data and republishes a model each time. `AdaptationHook` trains when a slice arrives. Pass `continuous=True` to restore the old behaviour. The same choice is available on the manager through the `wait_for_memory` attribute shown above. + +**Diagnostics.** `MemoryHook.unmatched()` counts feedback rows that had no model input to pair with. A non-zero count means the predictor's input buffer is too small for the delay between a prediction and the environment's judgement of it, and that training data is being lost. `MemoryHook.appended()` counts the pairs that reached the memory. `MemoryHook.flush()` closes the slice in progress, which is how the final trial gets saved; a slice is otherwise closed by the arrival of the next trial number. + +**Acting where the model lives.** `ModelSwapHook` runs in whichever executor you give it, which is not the process holding the live model. For work that has to happen in the streaming process when a new model is loaded, such as resetting a decision history that the old model's outputs populated or re-fitting a scaler, override `OnlineStreamer.on_model_update(number)`. Override it in a subclass rather than assigning a lambda to it. The streamer is a spawned process, and a lambda cannot be pickled. + +**What made the flags observable.** `SharedMemoryManager.apply(tag, fn, count_fn=None)` is the write path for a value that is set rather than appended. `SharedMemoryOutputWriter` now writes through it, which is what turns an adaptation flag or a row of environment feedback into something a hook can be triggered by at all. It takes the variable's lock once, where the old pair of `modify_variable` calls took it twice, so a reader can no longer see a count running ahead of the data it counts. + +**A bug fixed on this path.** `OnlineStreamer.load_emg_predictor` used an exact type check, `type(loaded) == EMGPredictor`. `EMGClassifier` and `EMGRegressor` both subclass `EMGPredictor`, and they are exactly what an adaptation run saves, so a saved classifier was misrouted into `.model`. The next prediction then failed on a classifier having no `predict_proba`, after the swap had already been reported as successful. The check now uses `isinstance`. + +# Step 1 — Within-Subject Initialization +First, record a short screen-guided training session and fit an initial model to it. This is the same collection flow used elsewhere in `LibEMG`; for regression we prompt the four wrist directions and record continuous labels. + +```Python +import libemg +from libemg.streamers import myo_streamer + +# Stream from the device and collect calibration (SGT) data. +streamer, sm_items = myo_streamer() +odh = libemg.data_handler.OnlineDataHandler(shared_memory_items=sm_items) + +gui = libemg.gui.GUI(odh, args={ + 'media_folder': 'media/', # regression prompts (wrist flexion/extension, radial/ulnar) + 'data_folder': 'data/sgt/', + 'num_reps': 5, + 'rep_time': 5, + 'auto_advance': True, +}) +gui.start_gui() +streamer.stop() +``` + +Next, parse those recordings with an `OfflineDataHandler`, fit your model, and — crucially — save the SGT data **as a memory slice**. That slice seeds the adaptation manager so early online updates don't wander away from a known-good starting point. + +```Python +# Parse the SGT recordings. +offdh = libemg.data_handler.OfflineDataHandler() +offdh.get_data('data/sgt/', regex_filters, metadata_fetchers, delimiter=',') + +# Fit the initial within-subject model (implements predict/adapt/save/load). +model = MyModel(...) +model.calibrate(offdh) + +# Seed the adaptation with the SGT data as the first memory slice. +initial_memory = MyMemory(...) # subclass of libemg.adaptation.memory.Memory +initial_memory.load_from_odh(offdh) +initial_memory.save('data/adapt/sgt_memory.pkl') +``` + +The same `model` object is handed to both the live regressor and the adaptation manager below; because they run in separate processes each gets its own copy. + +# Step 2 — Launch the Online Adaptive Environment +Now assemble the four processes. Start by requesting the pre-wired shared-memory items and output writers. + +```Python +from libemg.adaptation._base import get_edil_adaptation_objects, produce_tciil_feedback +from libemg.adaptation.managers import MemoryManager, AdaptationManager +from libemg.environments.controllers import RegressorController +from libemg.environments.curricular_fitts import ( + CurricularFitts, CurricularFittsConfig, RadiusTargetGenerator, +) + +NUM_FEATURES, NUM_DOFS, NUM_TRIALS = 64, 2, 80 +ADAPT_DIR = 'data/adapt/' # adapted models (mdl.pkl) AND memory slices (memory_.pkl) + +(model_smi, model_ow, + environment_smi, environment_ow, + adaptation_manager_smi, adaptation_manager_ow, + memory_manager_smi, memory_manager_ow) = get_edil_adaptation_objects( + num_features=NUM_FEATURES, num_outputs=NUM_DOFS) +``` + +**The live model.** Pass `model_ow`/`model_smi` so it publishes `model_input` and exposes the `active_flag`/`adapt_flag`. Its `file_path` is where it reloads adapted models from. + +```Python +streamer, sm_items = myo_streamer() +odh = libemg.data_handler.OnlineDataHandler(shared_memory_items=sm_items) + +emg_regressor = libemg.emg_predictor.EMGRegressor(model) # the SGT-trained model +online_regressor = libemg.emg_predictor.OnlineEMGRegressor( + offline_regressor = emg_regressor, + online_data_handler = odh, + window_size = 100, window_increment = 40, + features = ['WENG'], + output_writers = model_ow, # publishes model_input (features + timestamp) + smm = True, smm_items = model_smi, # exposes active_flag / adapt_flag / model_input + file_path = ADAPT_DIR, # reloads mdl.pkl from here when adapt_flag is set +) +online_regressor.run(block=False) +``` + +**The environment.** The `feedback_handle` converts cursor/target geometry into a pseudo-label; `environment_ow` writes it out for the memory manager. + +```Python +controller = RegressorController() +config = CurricularFittsConfig( + feedback_handle = produce_tciil_feedback, + num_trials = NUM_TRIALS, + controller_map = [1, -1], +) +env = CurricularFitts( + controller, config, + target_generator = RadiusTargetGenerator(config, F=0, P=0), + environment_ow = environment_ow, # writes environment_feedback (label + timestamp + trial) + save_file = ADAPT_DIR, +) +``` + +**The memory and adaptation managers.** Two path linkages must line up: the adaptation manager's `load_dir` equals the memory manager's `save_dir` (where slices are written), and its `save_dir` equals the online regressor's `file_path` (where adapted models are read back). Setting `notify=True` is what closes the loop by raising the `adapt_flag`. + +```Python +memory_manager = MemoryManager( + memory = MyMemory(...), # empty memory of the same type as the SGT seed + smi = memory_manager_smi, + ow = memory_manager_ow, + save_dir = ADAPT_DIR, # writes memory_.pkl +) + +adaptation_manager = AdaptationManager( + model = model, # adapts its own copy of the SGT model + smi = adaptation_manager_smi, + ow = adaptation_manager_ow, + initial_memory_loc = ADAPT_DIR + 'sgt_memory.pkl', # SGT seed for stability + load_dir = ADAPT_DIR, # reads memory_.pkl (== MemoryManager.save_dir) + save_dir = ADAPT_DIR, # writes mdl.pkl (== OnlineEMGRegressor.file_path) + stop_condition = lambda n: n >= NUM_TRIALS - 1, + notify = True, # raise adapt_flag so the live model hot-swaps +) +``` + +Finally, launch them. Run the environment and memory manager in the background and block on the adaptation manager; it returns once `stop_condition` is met. Then tear everything down. + +```Python +env.run_helper(block=False) # spawn the game +memory_manager.run_helper(block=False) # spawn the memory assembler +adaptation_manager.run_helper(block=True) # adapt until stop_condition; blocks here + +# Cleanup once adaptation ends. +env.process.join() +memory_manager.signal.set(); memory_manager.join() +online_regressor.odh.stop_all() +online_regressor.stop_running() +streamer.stop() +``` + +# Result +As the user completes trials, memory slices accumulate, the adaptation manager retrains on them (seeded by the SGT slice), and the live model is swapped out mid-session — so control quality improves *during* use rather than only between sessions. To make the model non-adaptive for a baseline comparison, build everything identically but pass `notify=False` to the `AdaptationManager`: memories are still collected, but the `adapt_flag` is never raised and the live model stays fixed. diff --git a/docs/source/documentation/animation/animation.rst b/docs/source/documentation/animation/animation.rst new file mode 100644 index 00000000..bada21c1 --- /dev/null +++ b/docs/source/documentation/animation/animation.rst @@ -0,0 +1,4 @@ +Animation +------------------------------ +.. include:: animation_doc.md + :parser: myst_parser.sphinx_ \ No newline at end of file diff --git a/docs/source/documentation/animation/animation_doc.md b/docs/source/documentation/animation/animation_doc.md new file mode 100644 index 00000000..e461defa --- /dev/null +++ b/docs/source/documentation/animation/animation_doc.md @@ -0,0 +1,75 @@ +## Use Case + +The `Animator` class offers some simple functionality to create animations in multiple video formats (e.g., .gif and .mp4). Specific `Animator` classes have been created to help create specific visual prompts, such as bar plots and cartesian plots. These prompts are primarily used for training of regression-based myoelectric control systems, but the `Animator` class can also be used to generate these prompts for any purpose. For fully custom animations, you can inherit from the `Animator` class and pass a set of frames to `save_video()` (see other `Animators` for examples). If you'd rather implement a custom plotting animation that isn't supported, you can inherit from `PlotAnimator` instead. + +For all further examples, we will use a generated set of coordinates to illustrate the difference between plots. + +```Python +import numpy as np + +fps = 24 +coordinates = np.concatenate(( + np.linspace(0, 1, num=fps), # each movement is 24 frames -> 1 second + np.ones(2 * fps), # steady state + np.linspace(1, -1, num=2 * fps), + np.ones(2 * fps) * -1, + np.linspace(-1, 0, num=fps) +)) +coordinates = np.hstack(( + np.expand_dims(coordinates, 1), + np.zeros((coordinates.shape[0], 1)) +)) +``` + +## Bar Plots + +One type of `Animator` built into `LibEMG` creates bar plot animations. Pass in an array of coordinates to the `plot_icon` method to create a bar plot visualization (see Figure 1). + +```Python +from libemg.animator import BarPlotAnimator + +animator = BarPlotAnimator(['Open', 'Close'], fps=fps) +animator.save_plot_video(coordinates) +``` + +![alt text](bar.gif) +

Figure 1: Simple bar plot animation.

+ +Additional information can also be shown during these animations, such as the next destination and a countdown for steady states (see Figure 2). + +```Python +animator = BarPlotAnimator(['Open', 'Close'], fps=fps, show_countdown=True, show_direction=True) +animator.save_plot_video(coordinates) +``` + +![alt text](bar-info.gif) +

Figure 2: Bar plot animation with added information.

+ +Parameters such as the time per unit distance, figure size, and more can also be modified. See the `BarPlotAnimator` API for more details. + +## Scatter Plots + +`LibEMG` also provides a helper class to animate scatter plots (see Figure 3). + +```Python +from libemg.animator import ScatterPlotAnimator + +animator = ScatterPlotAnimator(['Open', 'Close'], fps=fps) +animator.save_plot_video(coordinates) +``` + +![alt text](scatter.gif) +

Figure 3: Simple scatter plot animation.

+ +Similar to the bar plot animation, extra information can be added such as next destination, a countdown, and a unit circle boundary (see Figure 4). + +```Python + +animator = ScatterPlotAnimator(['Open', 'Close'], fps=fps, show_countdown=True, show_direction=True, show_boundary=True) +animator.save_plot_video(coordinates) +``` + +![alt text](scatter-info.gif) +

Figure 4: Scatter plot animation with added information.

+ +Parameters such as the time per unit distance, figure size, and more can also be modified. See the `ScatterPlotAnimator` API for more details. diff --git a/docs/source/documentation/animation/bar-info.gif b/docs/source/documentation/animation/bar-info.gif new file mode 100644 index 00000000..c43417d9 Binary files /dev/null and b/docs/source/documentation/animation/bar-info.gif differ diff --git a/docs/source/documentation/animation/bar.gif b/docs/source/documentation/animation/bar.gif new file mode 100644 index 00000000..97d39c8a Binary files /dev/null and b/docs/source/documentation/animation/bar.gif differ diff --git a/docs/source/documentation/animation/scatter-info.gif b/docs/source/documentation/animation/scatter-info.gif new file mode 100644 index 00000000..3ab0b7a0 Binary files /dev/null and b/docs/source/documentation/animation/scatter-info.gif differ diff --git a/docs/source/documentation/animation/scatter.gif b/docs/source/documentation/animation/scatter.gif new file mode 100644 index 00000000..2fa251c9 Binary files /dev/null and b/docs/source/documentation/animation/scatter.gif differ diff --git a/docs/source/documentation/classification/classification_doc.md b/docs/source/documentation/classification/classification_doc.md deleted file mode 100644 index 077d4480..00000000 --- a/docs/source/documentation/classification/classification_doc.md +++ /dev/null @@ -1,101 +0,0 @@ -# Classifiers -After recording, processing, and extracting features from a window of EMG data, it is passed to a machine learning algorithm for classification. These control systems have evolved in the prosthetics community for continuously classifying muscular contractions for enabling prosthesis control. Therefore, they are primarily limited to recognizing static contractions (e.g., hand open/close and wrist flexion/extension) as they have no temporal awareness. Currently, this is the form of recognition supported by LibEMG and is an initial step to explore EMG as an interaction opportunity for general-purpose use. This section highlights the machine-learning strategies that are part of LibEMG's pipeline. Additionally, a number of post-processing methods (i.e., techniques to improve performance after classification) are explored. - -## Statistical Models - -The statistical classifiers (i.e., traditional machine learning methods) implemented leverage the sklearn package. For most cases, the "base" classifiers use the default options, meaning that the pre-defined models are not necessarily optimal. However, the `parameters` attribute can be used when initializing the classifiers to pass in additional sklearn parameters in a dictionary. For example, looking at the `RandomForestClassifier` docs on sklearn: - -![Random Forest](random_forest.png) - -A classifier with any of those parameters using the `parameters` attribute. For example: -```Python -parameters = { - 'n_estimators': 99, - 'max_depth': 20, - 'random_state': 5, - 'max_leaf_nodes': 10 -} -classifier.fit(data_set, parameters=parameters) -``` - -Please reference the [sklearn docs](https://scikit-learn.org/stable/) for parameter options for each classifier. - -Additionally, custom classifiers can be created. Any custom classifier should be modeled after the sklearn classifiers and must have the `fit`, `predict`, and `predict_proba` functions to work correctly. - -```Python -from sklearn.ensemble import RandomForestClassifier -from libemg.predictor import EMGClassifier - -rf_custom_classifier = RandomForestClassifier(max_depth=5, random_state=0) -classifier = EMGClassifier(rf_custom_classifier) -classifier.fit(data_set) -``` - -### Linear Discriminant Analysis (LDA) -A linear classifier that uses common covariances for all classes and assumes a normal distribution. -```Python -classifier = EMGClassifier('LDA') -classifier.fit(data_set) -``` -Check out the LDA docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.LinearDiscriminantAnalysis.html) - -### K-Nearest Neighbour (KNN) -Discriminates between inputs using the K closest samples in feature space. The implemented version in the library defaults to k = 5. A commonly used classifier for EMG-based recognition. - -```Python -params = {'n_neighbors': 5} # Optional -classifier = EMGClassifier('KNN') -classifier.fit(data_set, parameters=params) -``` -Check out the KNN docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html) - -### Support Vector Machines (SVM) -A hyperplane that maximizes the distance between classes is used as the boundary for recognition. A commonly used classifier for EMG-based recognition. -```Python -classifier = EMGClassifier('SVM') -classifier.fit(data_set) -``` -Check out the SVM docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html) - -### Artificial Neural Networks (MLP) -A deep learning technique that uses human-like "neurons" to model data to help discriminate between inputs. Especially for this model, we **highly** recommend you create your own. -```Python -classifier = EMGClassifier('MLP') -classifier.fit(data_set) -``` -Check out the MLP docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html) - -### Random Forest (RF) -Uses a combination of decision trees to discriminate between inputs. -```Python -classifier = EMGClassifier('RF') -classifier.fit(data_set) -``` -Check out the RF docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) - -### Quadratic Discriminant Analysis (QDA) -A quadratic classifier that uses class-specific covariances and assumes normally distributed classes. -```Python -classifier = EMGClassifier('QDA') -classifier.fit(data_set) -``` -Check out the QDA docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis.html) - -### Gaussian Naive Bayes (NB) -Assumes independence of all input features and normally distributed classes. -```Python -classifier = EMGClassifier('NB') -classifier.fit(data_set) -``` -Check out the NB docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.GaussianNB.html) - - - - -## Deep Learning (Pytorch) -Another available option is to use [pytorch](https://pytorch.org/) models (i.e., a library for deep learning) to train the classifier, although this involves making some custom code for preparing the dataset and the deep learning model. For a guide on how to use deep learning models, consult the deep learning example. \ No newline at end of file diff --git a/docs/source/documentation/data/data_doc.md b/docs/source/documentation/data/data_doc.md index daea40c3..d82217fd 100644 --- a/docs/source/documentation/data/data_doc.md +++ b/docs/source/documentation/data/data_doc.md @@ -23,146 +23,1031 @@ This module has three data-related functions: **(1) Datasets**, **(2) Offline Da # Datasets Several validated datasets consisting of different gestures and recording technology are included in this library. These datasets can be used for exploring the library's capabilities and for future research. When using the packaged datasets for research purposes, we ask that you reference the original dataset contribution and not just this toolkit (the original dataset contributions might not be obvious since they are included for download with this toolkit). +## Classification + + +
OneSubjectMyoDataset +
+ +**Dataset Description:** +Simple one subject dataset. + | Attribute | Description | | ------------------ | ----------- | | **Num Subjects:** | 1 | | **Num Reps:** | 12 Reps (i.e., 6 Trials x 2 Reps)| -| **Time Per Rep:** | 3s | | **Classes:** |
  • 0 - Hand Open
  • 1 - Hand Close
  • 2 - No Movement
  • 3 - Wrist Extension
  • 4 - Wrist Flexion
| | **Device:** | Myo | -| **Sampling Rates:** | EMG (200 Hz) | +| **Sampling Rates:** | 200 Hz | +| **Auto Download:** | True | + **Using the Dataset:** ```Python -from libemg.datasets import OneSubjectMyoDataset -dataset = OneSubjectMyoDataset(redownload=False) +from libemg.datasets import * +dataset = get_dataset_list()['OneSubjectMyo']() odh = dataset.prepare_data() ``` +**Dataset Location** +https://github.com/LibEMG/OneSubjectEMaGerDataset + **References:** ``` -Work to be published... +@ARTICLE{libemg, + author={Eddy, Ethan and Campbell, Evan and Phinyomark, Angkoon and Bateman, Scott and Scheme, Erik}, + journal={IEEE Access}, + title={LibEMG: An Open Source Library to Facilitate the Exploration of Myoelectric Control}, + year={2023}, + volume={11}, + number={}, + pages={87380-87397}, + keywords={Electromyography;Prosthetics;Libraries;Human computer interaction;Feature extraction;Muscles;Control systems;Gesture recognition;Open source software;EMG;electromyography;toolkit;library;myoelectric control;gesture recognition}, + doi={10.1109/ACCESS.2023.3304544}} ``` -------------

+ +
3DCDatset +
+ +**Dataset Description:** +A relatively simple within session baseline. + | Attribute | Description | | ------------------ | ----------- | | **Num Subjects:** | 22 | | **Num Reps:** | 4 Training, 4 Testing | -| **Time Per Rep:** | 5s | | **Classes:** |
  • 0 - No Motion
  • 1 - Radial Deviaton
  • 2 - Wrist Flexion
  • 3 - Ulnar Deviaton
  • 4 - Wrist Extension
  • 5 - Supination
  • 6 - Pronation
  • 7 - Power Grip
  • 8- Open Hand
  • 9 - Chuck Grip
  • 10 - Pinch Grip
| | **Device:** | Delsys | -| **Sampling Rates:** | EMG (1000 Hz) | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | **Using the Dataset:** ```Python -from libemg.datasets import _3DCDataset -dataset = _3DCDataset(redownload=False) +from libemg.datasets import * +dataset = get_dataset_list()['3DC']() odh = dataset.prepare_data() ``` +**Dataset Location** +https://github.com/LibEMG/3DCDataset + **References:** ``` -@article{cote2019deep, title={Deep learning for electromyographic hand gesture signal classification using transfer learning}, author={C{^o}t{'e}-Allard, Ulysse and Fall, Cheikh Latyr and Drouin, Alexandre and Campeau-Lecours, Alexandre and Gosselin, Cl{'e}ment and Glette, Kyrre and Laviolette, Fran{\c{c}}ois and Gosselin, Benoit}, journal={IEEE transactions on neural systems and rehabilitation engineering}, volume={27}, number={4}, pages={760--771}, year={2019}, publisher={IEEE} } +@article{cote2019low, + title={A low-cost, wireless, 3-D-printed custom armband for sEMG hand gesture recognition}, + author={C{\^o}t{\'e}-Allard, Ulysse and Gagnon-Turcotte, Gabriel and Laviolette, Fran{\c{c}}ois and Gosselin, Benoit}, + journal={Sensors}, + volume={19}, + number={12}, + pages={2811}, + year={2019}, + publisher={MDPI} +} +``` +
+
+ + + + +
+CIIL_MinimalData + +
-@article{cote2020interpreting, title={Interpreting deep learning features for myoelectric control: A comparison with handcrafted features}, author={C{^o}t{'e}-Allard, Ulysse and Campbell, Evan and Phinyomark, Angkoon and Laviolette, Fran{\c{c}}ois and Gosselin, Benoit and Scheme, Erik}, journal={Frontiers in Bioengineering and Biotechnology}, volume={8}, pages={158}, year={2020}, publisher={Frontiers Media SA} } +**Dataset Description:** +The goal of this Myo dataset is to explore how well models perform when they have a limited amount of training data (1s per class). + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 11 | +| **Num Reps:** | 1 Train, 15 Test | +| **Classes:** |
  • 0 - Close
  • 1 - Open
  • 2 - Rest
  • 3 - Flexion
  • 4 - Extension
| +| **Device:** | Myo Armband | +| **Sampling Rates:** | 200 Hz | +| **Auto Download:** | True | + + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['CIIL_MinimalData']() +odh = dataset.prepare_data() ``` -------------- + +**Dataset Location** +https://github.com/LibEMG/CIILData + +**References:** +``` +@inproceedings{ciil_md, + title={Leveraging task-specific context to improve unsupervised adaptation for myoelectric control}, + author={Eddy, Ethan and Campbell, Evan and Bateman, Scott and Scheme, Erik}, + booktitle={2023 IEEE International Conference on Systems, Man, and Cybernetics (SMC)}, + pages={4661--4666}, + year={2023}, + organization={IEEE} +} +``` +
+
-
+
-Nina Pro DB2 +CIIL_ElectrodeShift -
-The Ninapro DB2 is a dataset that can be used to test how algorithms perform for large gesture sets. The dataset contains 6 repetitions of 50 motion classes (plus optional rest) that were recorded using 12 Delsys Trigno electrodes around the forearm. +
-
-
+**Dataset Description:** +An electrode shift confounding factors dataset. -Note, this dataset will not be automatically downloaded. To download this dataset, please see [Nina DB2](http://ninapro.hevs.ch/node/17). Simply download the ZIPs and place them in a folder and LibEMG will handle the rest. All credit for this dataset should be given to the original authors. +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 21 | +| **Num Reps:** | 5 Train (Before Shift), 8 Test (After Shift) | +| **Classes:** |
  • 0 - Close
  • 1 - Open
  • 2 - Rest
  • 3 - Flexion
  • 4 - Extension
| +| **Device:** | Myo Armband | +| **Sampling Rates:** | 200 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['CIIL_ElectrodeShift']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/CIILData + +**References:** +``` +@article{ciil_es, + title={Context-informed incremental learning improves both the performance and resilience of myoelectric control}, + author={Campbell, Evan and Eddy, Ethan and Bateman, Scott and C{\^o}t{\'e}-Allard, Ulysse and Scheme, Erik}, + journal={Journal of NeuroEngineering and Rehabilitation}, + volume={21}, + number={1}, + pages={70}, + year={2024}, + publisher={Springer} +} +``` + +
+
+ + + +
+CIIL_WeaklySupervised + +
+ +**Dataset Description:** +A weakly supervised environment with sparse supervised calibration. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 16 | +| **Num Reps:** | 30 min weakly supervised, 1 rep calibration, 14 reps test | +| **Classes:** |
  • 0 - Close
  • 1 - Open
  • 2 - Rest
  • 3 - Flexion
  • 4 - Extension
| +| **Device:** | OyMotion gForcePro+ EMG Armband | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('WEAKLYSUPERVISED')['CIIL_WeaklySupervised']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/WS_CIIL + +**References:** +``` +In Publication... +``` + +
+
+ + +
+ContinuousTransitions + +
+ +**Dataset Description:** +The testing set in this dataset has continuous transitions between classes, providing a more realistic offline evaluation standard for myoelectric control. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 43 | +| **Num Reps:** | 6 Training (Ramp), 42 Transitions (All combinations of Transitions) x 6 Reps | +| **Classes:** |
  • 0 - No Motion
  • 1 - Wrist Flexion
  • 2 - Wrist Extension
  • 3 - Wrist Pronation
  • 4 - Wrist Supination
  • 5 - Hand Close
  • 6 - Hand Open
| +| **Device:** | Delsys | +| **Sampling Rates:** | 2000 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['ContinuousTransitions']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://unbcloud-my.sharepoint.com/:f:/g/personal/ecampbe2_unb_ca/EjgjhM9ZHJxOglKoAf062ngBf4wFj2Mn2bORKY1-aMYGRw?e=WkZNwI + +**References:** +``` +@ARTICLE{transitions, + author={Raghu, Shriram Tallam Puranam and MacIsaac, Dawn and Scheme, Erik}, + journal={IEEE Journal of Biomedical and Health Informatics}, + title={Decision-Change Informed Rejection Improves Robustness in Pattern Recognition-Based Myoelectric Control}, + year={2023}, + volume={27}, + number={12}, + pages={6051-6061}, + doi={10.1109/JBHI.2023.3316599}} +``` + +
+
+ + +
+ContractionIntensity + +
+ +**Dataset Description:** +A contraction intensity dataset. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 10 | +| **Num Reps:** | 4 Ramp Reps (Train), 4 Reps x 20%, 30%, 40%, 50%, 60%, 70%, 80%, MVC (Test) | +| **Classes:** |
  • 0 - No Motion
  • 1 - Wrist Flexion
  • 2 - Wrist Extension
  • 3 - Wrist Pronation
  • 4 - Wrist Supination
  • 5 - Chuck Grip
  • 6 - Hand Open
| +| **Device:** | BE328 by Liberating Technologies, Inc | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['ContractionIntensity']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/ContractionIntensity + +**References:** +``` +@article{contraction_intensity, + title={Training strategies for mitigating the effect of proportional control on classification in pattern recognition--based myoelectric control}, + author={Scheme, Erik and Englehart, Kevin}, + journal={JPO: Journal of Prosthetics and Orthotics}, + volume={25}, + number={2}, + pages={76--83}, + year={2013}, + publisher={LWW} +} +``` + +
+
+ + +
+EMGEPN612 + +
+ +**Dataset Description:** +A large 612 user dataset for developing cross-user models. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 612 | +| **Num Reps:** | 50 Reps x 306 Users (Train), 25 Reps x 306 Users (Test) --> Cross User Split | +| **Classes:** |
  • 0 - No Movement
  • 1 - Hand Close
  • 2 - Flexion
  • 3 - Extension
  • 4 - Hand Open
  • 5 - Pinch
| +| **Device:** | Myo Armband | +| **Sampling Rates:** | 200 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['EMGEPN612']() # User Dependent +dataset = get_dataset_list(cross_user=True)['EMGEPN612']() # User Independent +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://unbcloud-my.sharepoint.com/:u:/g/personal/ecampbe2_unb_ca/EWf3sEvRxg9HuAmGoBG2vYkBLyFv6UrPYGwAISPDW9dBXw?e=vjCA14 + +**References:** +``` +@article{epn, + title={EMG-EPN-612 Dataset. 2020}, + author={Benalc{\'a}zar, M and Barona, L and Valdivieso, L and Aguas, X and Zea, J}, + journal={DOI: https://doi. org/10.5281/zenodo}, + volume={4027874}, + year={2020} +} +``` + +
+
+ + +
+FORSEMG + +
+ +**Dataset Description:** +Twelve gestures elicited in three forearm orientations (neutral, pronation, and supination). + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 19 | +| **Num Reps:** | 5 Train, 10 Test (2 Forearm Orientations x 5 Reps) | +| **Classes:** |
  • 0 - Thump Up
  • 1 - Index
  • 2 - Right Angle
  • 3 - Peace
  • 4 - Index Little
  • 5 - Thumb Little
  • 6 - Hand Close
  • 7 - Hand Open
  • 8 - Wrist Flexion
  • 9 - Wrist Extension
  • 10 - Ulnar Deviation
  • 11 - Radial Deviation
| +| **Device:** | Experimental Device | +| **Sampling Rates:** | 985 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['FORSEMG']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://www.kaggle.com/datasets/ummerummanchaity/fors-emg-a-novel-semg-dataset + +**References:** +``` +@article{fors_emg, + title={FORS-EMG: A Novel sEMG Dataset for Hand Gesture Recognition Across Multiple Forearm Orientations}, + author={Rumman, Umme and Ferdousi, Arifa and Hossain, Md Sazzad and Islam, Md Johirul and Ahmad, Shamim and Reaz, Mamun Bin Ibne and Islam, Md Rezaul}, + journal={arXiv preprint arXiv:2409.07484}, + year={2024} +} +``` + +
+
+ + + + +
+FougnerLP + +
+ +**Dataset Description:** +A limb position dataset (with 5 static limb positions). + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 12 | +| **Num Reps:** | 10 Reps (Train), 10 Reps x 4 Positions | +| **Classes:** |
  • 0 - Wrist Flexion
  • 1 - Wrist Extension
  • 2 - Pronation
  • 3 - Supination
  • 4 - Hand Open
  • 5 - Power Grip
  • 6 - Pinch Grip
  • 7 - Rest
| +| **Device:** | BE328 by Liberating Technologies, Inc. | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | + + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['FougnerLP']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/LimbPosition + +**References:** +``` +@article{fougner_lp, + title={Resolving the limb position effect in myoelectric pattern recognition}, + author={Fougner, Anders and Scheme, Erik and Chan, Adrian DC and Englehart, Kevin and Stavdahl, {\O}yvind}, + journal={IEEE Transactions on Neural Systems and Rehabilitation Engineering}, + volume={19}, + number={6}, + pages={644--651}, + year={2011}, + publisher={IEEE} +} +``` + +
+
+ + +
+GRABMyo + +
+ +**Dataset Description:** +A large cross-session dataset including 17 gestures elicited across 3 separate sessions. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 43 | +| **Num Reps:** | 7 Train, 14 Test (2 Separate Days x 7 Reps) --> Cross Day Split | +| **Classes:** |
  • 0 - Lateral Prehension
  • 1 - Thumb Adduction
  • 2 - Thumb and Little Finger Opposition
  • 3 - Thumb and Index Finger Opposition
  • 4 - Thumb and Index Finger Extension
  • 5 - Thumb and Little Finger Extension
  • 6 - Index and Middle Finger Extension
  • 7 - Little Finger Extension
  • 8 - Index Finger Extension
  • 9 - Thumb Finger Extension
  • 10 - Wrist Extension
  • 11 - Wrist Flexion
  • 12 - Forearm Supination
  • 13 - Forearm Pronation
  • 14 - Hand Open
  • 15 - Hand Close
  • 16 - Rest
| +| **Device:** | EMGUSB2+ device (OT Bioelletronica, Italy) | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['GRABMyoBaseline']() # Baseline +dataset = get_dataset_list()['GRABMyoCrossDay']() # CrossDay +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://physionet.org/content/grabmyo/1.0.2/ + +**References:** +``` +@article{grabmyo, + title={Multi-day dataset of forearm and wrist electromyogram for hand gesture recognition and biometrics}, + author={Pradhan, Ashirbad and He, Jiayuan and Jiang, Ning}, + journal={Scientific data}, + volume={9}, + number={1}, + pages={733}, + year={2022}, + publisher={Nature Publishing Group UK London} +} +``` + +
+
+ + + +
+HyserPR + +
+ +**Dataset Description:** +High Density Hyser pattern recognition (PR) dataset. Includes dynamic and maintenance tasks for 34 hand gestures. + +| Attribute | Description | +| ------------------ | ----------- | +| **Num Subjects:** | 18 | +| **Num Reps:** | 1 Train, 1 Test (Consisting of dynamic and maintanance tasks) | +| **Classes:** |
  • 1 - Thumb Extension
  • 2 - Index Finger Extension
  • 3 - Middle Finger Extension
  • 4 - Ring Finger Extension
  • 5 - Little Finger Extension
  • 6 - Wrist Flexion
  • 7 - Wrist Extension
  • 8 - Wrist Radial
  • 9 - Wrist Ulnar
  • 10 - Wrist Pronation
  • 11 - Wrist Supination
  • 12 - Extension of Thumb and Index Fingers
  • 13 - Extension of Index and Middle Fingers
  • 14 - Wrist Flexion Combined with Hand Close
  • 15 - Wrist Extension Combined with Hand Close
  • 16 - Wrist Radial Combined with Hand Close
  • 17 - Wrist Ulnar Combined with Hand Close
  • 18 - Wrist Pronation Combined with Hand Close
  • 19 - Wrist Supination Combined with Hand Close
  • 20 - Wrist Flexion Combined with Hand Open
  • 21 - Wrist Extension Combined with Hand Open
  • 22 - Wrist Radial Combined with Hand Open
  • 23 - Wrist Ulnar Combined with Hand Open
  • 24 - Wrist Pronation Combined with Hand Open
  • 25 - Wrist Supination Combined with Hand Open
  • 26 - Extension of Thumb, Index and Middle Fingers
  • 27 - Extension of Index, Middle and Ring Fingers
  • 28 - Extension of Middle, Ring and Little Fingers
  • 29 - Extension of Index, Middle, Ring and Little Fingers
  • 30 - Hand Close
  • 31 - Hand Open
  • 32 - Thumb and Index Fingers Pinch
  • 33 - Thumb, Index and Middle Fingers Pinch
  • 34 - Thumb and Middle Fingers Pinch
| +| **Device:** | OT Bioelettronica Quattrocento | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['HyserPR']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://www.physionet.org/content/hd-semg/2.0.0/ + +**References:** +``` +@ARTICLE{hyser, + author={Jiang, Xinyu and Liu, Xiangyu and Fan, Jiahao and Ye, Xinming and Dai, Chenyun and Clancy, Edward A. and Akay, Metin and Chen, Wei}, + journal={IEEE Transactions on Neural Systems and Rehabilitation Engineering}, + title={Open Access Dataset, Toolbox and Benchmark Processing Results of High-Density Surface Electromyogram Recordings}, + year={2021}, + volume={29}, + number={}, + pages={1035-1046}, + doi={10.1109/TNSRE.2021.3082551}} +``` +
+
+ + +
+KaufmannMD + +
+ +**Dataset Description:** +A single subject, multi-day (120 days) collection. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 1 | +| **Num Reps:** | 1 rep per day, 120 days total. 60/60 train-test split | +| **Classes:** |
  • 0 - No Motion
  • 1 - Wrist Extension
  • 2 - Wrist Flexion
  • 3 - Wrist Adduction
  • 4 - Wrist Abduction
  • 5 - Wrist Supination
  • 6 - Wrist Pronation
  • 7 - Hand Open
  • 8 - Hand Closed
  • 9 - Key Grip
  • 10 - Index Point
| +| **Device:** | MindMedia | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['KaufmannMD']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/MultiDay -
+**References:** +``` +@INPROCEEDINGS{kaufmann, + author={Kaufmann, Paul and Englehart, Kevin and Platzner, Marco}, + booktitle={2010 Annual International Conference of the IEEE Engineering in Medicine and Biology}, + title={Fluctuating emg signals: Investigating long-term effects of pattern matching algorithms}, + year={2010}, + volume={}, + number={}, + pages={6357-6360}, + doi={10.1109/IEMBS.2010.5627288}} +``` + +
+
+ + + +
+NinaProDB2 + +
+ +**Dataset Description:** +The Ninapro DB2 is a dataset that can be used to test how algorithms perform for large gesture sets. The dataset contains 6 repetitions of 50 motion classes (plus optional rest) that were recorded using 12 Delsys Trigno electrodes around the forearm. | Attribute | Description | | ------------------ | ----------- | | **Num Subjects:** | 40 | | **Num Reps:** | 6 | -| **Time Per Rep:** | 5s | | **Classes:** | 50 [Nina Pro DB2](http://ninapro.hevs.ch/node/123) | | **Device:** | Delsys | -| **Sampling Rates:** | EMG (2000 Hz) | +| **Sampling Rates:** | 2000 Hz | +| **Auto Download:** | False | **Using the Dataset:** ```Python -from libemg.datasets import NinaproDB2 -dataset = NinaproDB2("data/NinaDB2") #The loacation of Nina DB2 is downloaded +from libemg.datasets import * +dataset = get_dataset_list()['NinaProDB2']() odh = dataset.prepare_data() ``` +**Dataset Location** +Note, this dataset will not be automatically downloaded. To download this dataset, please see [Nina DB2](http://ninapro.hevs.ch/node/17). Simply download the ZIPs and place them in a folder and LibEMG will handle the rest. All credit for this dataset should be given to the original authors. + **References:** ``` -Atzori, M., Gijsberts, A., Castellini, C. et al. -Electromyography data for non-invasive naturally-controlled robotic hand prostheses. -Sci Data 1, 140053 (2014). -https://doi.org/10.1038/sdata.2014.53 +@article{db2, + title={Electromyography data for non-invasive naturally-controlled robotic hand prostheses}, + author={Atzori, Manfredo and Gijsberts, Arjan and Castellini, Claudio and Caputo, Barbara and Hager, Anne-Gabrielle Mittaz and Elsig, Simone and Giatsidis, Giorgio and Bassetto, Franco and M{\"u}ller, Henning}, + journal={Scientific data}, + volume={1}, + number={1}, + pages={1--13}, + year={2014}, + publisher={Nature Publishing Group} +} ``` -------------

+
-Nina Pro DB8 +RadmandLP -
+
-Note, this dataset will not be automatically downloaded. To download this dataset, please see [Nina DB8](http://ninapro.hevs.ch/DB8). Simply download the ZIPs and place them in a folder and LibEMG will handle the rest. All credit for this dataset should be given to the original authors. +**Dataset Description:** +A large limb position dataset (with 16 static limb positions). + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 10 | +| **Num Reps:** | 4 Reps (Train), 4 Reps x 15 Positions | +| **Classes:** |
  • Mapping is Uncertain
| +| **Device:** | DelsysTrigno | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['RadmandLP']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/LimbPosition + +**References:** +``` +@INPROCEEDINGS{radmand_lp, + author={Radmand, A. and Scheme, E. and Englehart, K.}, + booktitle={2014 36th Annual International Conference of the IEEE Engineering in Medicine and Biology Society}, + title={A characterization of the effect of limb position on EMG features to guide the development of effective prosthetic control schemes}, + year={2014}, + volume={}, + number={}, + pages={662-667}, + keywords={}, + doi={10.1109/EMBC.2014.6943678}} +``` + +
+
+ + +
+TMRShirleyRyanAbilityLab + +
+ +**Dataset Description:** +6 subjects, 8 reps, 24 motions, pre/post intervention. + +| Attribute | Description | +|-------------------|------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 6 | +| **Num Reps:** | 8 reps per motion (pre/post intervention) | +| **Classes:** |
  • 0 - Hand Open
  • 1 - Key Grip
  • 2 - Power Grip
  • 3 - Fine Pinch Opened
  • 4 - Fine Pinch Closed
  • 5 - Tripod Opened
  • 6 - Tripod Closed
  • 7 - Tool
  • 8 - Hook
  • 9 - Index Point
  • 10 - Thumb Flexion
  • 11 - Thumb Extension
  • 12 - Thumb Abduction
  • 13 - Thumb Adduction
  • 14 - Index Flexion
  • 15 - Ring Flexion
  • 16 - Pinky Flexion
  • 17 - Wrist Supination
  • 18 - Wrist Pronation
  • 19 - Wrist Flexion
  • 20 - Wrist Extension
  • 21 - Radial Deviation
  • 22 - Ulnar Deviation
  • 23 - No Motion
| +| **Device:** | Ag/AgCl | +| **Sampling Rates:** | 1000 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list()['TMRShirleyRyanAbilityLab']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/TMR_ShirleyRyanAbilityLab + +**References:** +``` +@article{tmr, + title={Myoelectric prosthesis hand grasp control following targeted muscle reinnervation in individuals with transradial amputation}, + author={Simon, Ann M and Turner, Kristi L and Miller, Laura A and Dumanian, Gregory A and Potter, Benjamin K and Beachler, Mark D and Hargrove, Levi J and Kuiken, Todd A}, + journal={PloS one}, + volume={18}, + number={1}, + pages={e0280210}, + year={2023}, + publisher={Public Library of Science San Francisco, CA USA} +} +``` + +
+
+ + +## Regression + + + +
+OneSubjectEmaGEr + +
+ +**Dataset Description:** +Simple one subject regression dataset. + +| Attribute | Description | +| ------------------ | ----------- | +| **Num Subjects:** | 1 | +| **Num Reps:** | 5 Reps | +| **Classes:** |
  • 0: Hand Close (-) / Hand Open (+)
  • Pronation (-) / Supination (+)
| +| **Device:** | EmaGEr | +| **Sampling Rates:** | 1010 Hz | +| **Auto Download:** | True | + + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['OneSubjectMyo']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/OneSubjectMyoDataset + +**References:** +``` +@ARTICLE{libemg, + author={Eddy, Ethan and Campbell, Evan and Phinyomark, Angkoon and Bateman, Scott and Scheme, Erik}, + journal={IEEE Access}, + title={LibEMG: An Open Source Library to Facilitate the Exploration of Myoelectric Control}, + year={2023}, + volume={11}, + number={}, + pages={87380-87397}, + doi={10.1109/ACCESS.2023.3304544}} +``` +
+ +
+ + + +
+EMG2POSE + +
+ +**Dataset Description:** +A large dataset from ctrl-labs (Meta) for joint angle estimation. Note that not all subjects have all stages. + +| Attribute | Description | +|-------------------|--------------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 193 | +| **Num Reps:** | N/A | +| **Classes:** |
  • FingerPinches1 - AllFingerPinchesThumbSwipeThumbRotate
  • Object1 - CoffeePanicPete
  • Counting1 - CountingUpDownFaceSideAway
  • Counting2 - CountingUpDownFingerWigglingSpreading
  • DoorknobFingerGraspFistGrab - DoorknobFingerGraspFistGrab
  • Throwing - FastPongFronthandBackhandThrowing
  • Abduction - FingerAbductionSeries
  • FingerFreeform - FingerFreeform
  • FingerPinches2 - FingerPinchesSingleFingerPinchesMultiple
  • HandHandInteractions - FingerTouchPalmClapmrburns
  • Wiggling1 - FingerWigglingSpreading
  • Punch - GraspPunchCloseFar
  • Gesture1 - HandClawGraspFlicks
  • StaticHands - HandDeskSeparateClaspedChest
  • FingerPinches3 - HandOverHandAllFingerPinchesThumbSwipeThumbRotate
  • Wiggling2 - HandOverHandCountingUpDownFingerWigglingSpreading
  • Unconstrained - unconstrained
  • Gesture2 - HookEmHornsOKScissors
  • FingerPinches4 - IndexPinchesMiddlePinchesThumbswipes
  • Pointing - IndividualFingerPointingSnap
  • Freestyle1 - OneHandedFreeStyle
  • Object2 - PlayBlocksChess
  • Draw - PokeDrawPinchRotateclosefar
  • Poke - PokePinchCloseFar
  • Gesture3 - ShakaVulcanPeace
  • ThumbsSwipes - ThumbsSwipesWholeHand
  • ThumbRotations - ThumbsUpDownThumbRotationsCWCCWP
  • Freestyle2 - TwoHandedFreeStyle
  • WristFlex - WristFlexionAbduction
| +| **Device:** | Ctrl Labs Armband | +| **Sampling Rates:** | 2000 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['EMG2POSE']() # Within USer +dataset = get_dataset_list('REGRESSION', cross_user=True)['EMG2POSE']() # Cross User +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://fb-ctrl-oss.s3.amazonaws.com/emg2pose/emg2pose_dataset.tar + +**References:** +``` +@inproceedings{salteremg2pose, + title={emg2pose: A Large and Diverse Benchmark for Surface Electromyographic Hand Pose Estimation}, + author={Salter, Sasha and Warren, Richard and Schlager, Collin and Spurr, Adrian and Han, Shangchen and Bhasin, Rohin and Cai, Yujun and Walkington, Peter and Bolarinwa, Anuoluwapo and Wang, Robert and others}, + booktitle={The Thirty-eight Conference on Neural Information Processing Systems Datasets and Benchmarks Track} +} +``` + +
+
-
+ + +
+NinaProDB8 | Attribute | Description | | ------------------ | ----------- | | **Num Subjects:** | 12 | | **Num Reps:** | 20 Training, 2 Testing | -| **Time Per Rep:** | 6-9s | -| **Classes:** | 9 [Nina Pro DB8](http://ninapro.hevs.ch/DB8) | +| **Classes:** | 9 [NinaProDB8](http://ninapro.hevs.ch/DB8) | | **Device:** | Delsys | -| **Sampling Rates:** | EMG (1111 Hz) | +| **Sampling Rates:** | 1111 Hz | +| **Auto Download:** | False | **Using the Dataset:** ```Python -from libemg.datasets import NinaproDB8 -dataset = NinaproDB8("data/NinaDB8") #The loacation of Nina DB8 is downloaded +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['NinaProDB8']() odh = dataset.prepare_data() ``` +**Dataset Location** +Note, this dataset will not be automatically downloaded. To download this dataset, please see [Nina DB8](http://ninapro.hevs.ch/DB8). Simply download the ZIPs and place them in a folder and LibEMG will handle the rest. All credit for this dataset should be given to the original authors. + **References:** ``` -AUTHOR=Krasoulis Agamemnon, Vijayakumar Sethu, Nazarpour Kianoush -TITLE=Effect of User Practice on Prosthetic Finger Control With an Intuitive Myoelectric Decoder -JOURNAL=Frontiers in Neuroscience -VOLUME=13 -YEAR=2019 -URL=https://www.frontiersin.org/articles/10.3389/fnins.2019.00891 -DOI=10.3389/fnins.2019.00891 -ISSN=1662-453X +@article{db8, + title={Effect of user practice on prosthetic finger control with an intuitive myoelectric decoder}, + author={Krasoulis, Agamemnon and Vijayakumar, Sethu and Nazarpour, Kianoush}, + journal={Frontiers in neuroscience}, + volume={13}, + pages={891}, + year={2019}, + publisher={Frontiers Media SA} +} +``` +
+
+ + +
+Hyser1DOF + +
+ +**Dataset Description:** +Hyser 1 DOF dataset. Includes within-DOF finger movements. Ground truth finger forces are recorded for use in finger force regression. +
+ +| Attribute | Description | +|-------------------|-----------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 20 | +| **Num Reps:** | 3 | +| **Classes:** |
  • 1 - Thumb
  • 2 - Index
  • 3 - Middle
  • 4 - Ring
  • 5 - Little
| +| **Device:** | OT Bioelettronica Quattrocento | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['Hyser1DOF']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://www.physionet.org/content/hd-semg/2.0.0/ + +**References:** +``` +@ARTICLE{hyser, + author={Jiang, Xinyu and Liu, Xiangyu and Fan, Jiahao and Ye, Xinming and Dai, Chenyun and Clancy, Edward A. and Akay, Metin and Chen, Wei}, + journal={IEEE Transactions on Neural Systems and Rehabilitation Engineering}, + title={Open Access Dataset, Toolbox and Benchmark Processing Results of High-Density Surface Electromyogram Recordings}, + year={2021}, + volume={29}, + number={}, + pages={1035-1046}, + doi={10.1109/TNSRE.2021.3082551}} ``` --------------

+ +
+HyserNDOF + +
+ +**Dataset Description:** +Hyser N DOF dataset. Includes combined finger movements. Ground truth finger forces are recorded for use in finger force regression. +
+ +| Attribute | Description | +|-------------------|-----------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 20 | +| **Num Reps:** | 2 | +| **Classes:** |
  • 1 - Thumb + Index
  • 2 - Thumb + Middle
  • 3 - Thumb + Ring
  • 4 - Thumb + Little
  • 5 - Index + Middle
  • 6 - Thumb + Index + Middle
  • 7 - Index + Middle + Ring
  • 8 - Middle + Ring + Little
  • 9 - Index + Middle + Ring + Little
  • 10 - All Fingers
  • 11 - Thumb + Index (Opposing)
  • 12 - Thumb + Middle (Opposing)
  • 13 - Thumb + Ring (Opposing)
  • 14 - Thumb + Little (Opposing)
  • 15 - Index + Middle (Opposing)
| +| **Device:** | OT Bioelettronica Quattrocento | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['HyserNDOF']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://www.physionet.org/content/hd-semg/2.0.0/ + +**References:** +``` +@ARTICLE{hyser, + author={Jiang, Xinyu and Liu, Xiangyu and Fan, Jiahao and Ye, Xinming and Dai, Chenyun and Clancy, Edward A. and Akay, Metin and Chen, Wei}, + journal={IEEE Transactions on Neural Systems and Rehabilitation Engineering}, + title={Open Access Dataset, Toolbox and Benchmark Processing Results of High-Density Surface Electromyogram Recordings}, + year={2021}, + volume={29}, + number={}, + pages={1035-1046}, + doi={10.1109/TNSRE.2021.3082551}} +``` + +
+
+ + +
+HyserRandom + +
+ +**Dataset Description:** +Hyser random dataset. Includes random motions performed by users. Ground truth finger forces are recorded for use in finger force regression. +
+ +| Attribute | Description | +|-------------------|-----------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 19 | +| **Num Reps:** | 5 | +| **Classes:** | Random | +| **Device:** | OT Bioelettronica Quattrocento | +| **Sampling Rates:** | 2048 Hz | +| **Auto Download:** | False | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['HyserRandom']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://www.physionet.org/content/hd-semg/2.0.0/ + +**References:** +``` +@ARTICLE{hyser, + author={Jiang, Xinyu and Liu, Xiangyu and Fan, Jiahao and Ye, Xinming and Dai, Chenyun and Clancy, Edward A. and Akay, Metin and Chen, Wei}, + journal={IEEE Transactions on Neural Systems and Rehabilitation Engineering}, + title={Open Access Dataset, Toolbox and Benchmark Processing Results of High-Density Surface Electromyogram Recordings}, + year={2021}, + volume={29}, + number={}, + pages={1035-1046}, + doi={10.1109/TNSRE.2021.3082551}} +``` + +
+
+ + + +
+UserCompliance + +
+ +**Dataset Description:** +Regression dataset used for investigation into user compliance during mimic training. +
+ +| Attribute | Description | +|-------------------|-----------------------------------------------------------------------------------------------------------| +| **Num Subjects:** | 6 | +| **Num Reps:** | 5 | +| **Classes:** |
  • 0 - Hand Close (-) / Hand Open (+)
  • 1 - Pronation (-) / Supination (+)
| +| **Device:** | EMaGer | +| **Sampling Rates:** | 1010 Hz | +| **Auto Download:** | True | + +**Using the Dataset:** +```Python +from libemg.datasets import * +dataset = get_dataset_list('REGRESSION')['UserCompliance']() +odh = dataset.prepare_data() +``` + +**Dataset Location** +https://github.com/LibEMG/UserComplianceDataset + +**References:** +``` +@inproceedings{morrell2024exploring, + title={Exploring user compliance in the training of regression-based myoelectric control}, + author={Morrell, Christian and Campbell, Evan and Scheme, Erik}, + booktitle={Myoelectric Controls Symposium}, + year={2024} +} +``` + +
+
+ + # Offline Data Handler One overhead for most EMG projects is interfacing with a particular dataset since they often have different folder and file structures. LibEMG provides a means to quickly interface datasets so you can focus on using them with minimal setup time. Assuming the files in the dataset are well formatted (i.e., they include all metadata such as rep, class, and subject) and are either .csv or .txt files, the OfflineDataHandler does all accumulation and processing. To do this, LibEMG relies on regular expressions to define a dataset's file and folder structure. These expressions can be used to create a dictionary that is passed to the OfflineDataHandler. Once the data handler has collected all the files that satisfy the regexes, the dataset can be sliced using the metadata tags (e.g., by rep, subjects, classes, etc.). After extracting the data it is ready to be passed through the rest of the pipeline. The following code snippet exemplifies how to process a dataset with testing/training, rep, and class metadata. In this case the file format is: `dataset/train/R_1_C_1_EMG.csv` where R is the rep and C is the class. @@ -196,6 +1081,8 @@ training_features = fe.extract_features(feature_list, train_windows) # Online Data Handler One complication when using EMG devices is the lack of standardization, meaning that interfacing with hardware is a new undertaking for each device. A goal of LibEMG is to abstract these differences and enable a hardware-agnostic framework. Therefore, this module acts as a middle layer for processing real-time data streaming from any device. In this architecture - exemplified in Figure 1 – live data streaming is performed by using a shared memory buffer as the core. This shared memory buffer is created by the device streamer, where a process is spawned that continuously populates the buffer with samples. Other modules can gain access to the shared memory buffer using the shared memory items that the streamer returned, allowing for cross-process, low-latency, non-blocking access to the data of interest. We provide an OnlineDataHandler object that is a generic object for attaching to the shared memory buffer with added some utilities. + +Each write to the buffer announces itself. The streamer commits its samples, which advances a small block of counters stored alongside the buffer and wakes anything hooked into that data. Consumers such as the OnlineEMGClassifier are therefore told when data arrives instead of asking for it. The Reactive Pipelines section describes that layer, and this section covers the parts of it the OnlineDataHandler exposes directly. An example of the online data streaming workflow is provided below: @@ -213,4 +1100,51 @@ odh.visualization() ![alt text](online_dh.png)

Figure 1: OnlineDataHandler Architecture

-**For more information on the default streamers and creating your own, please reference the Supported Hardware section.** +**For more information on the default streamers and creating your own, please reference the Supported Hardware section.** + +## Reading the State of a Modality + +`get_state` answers what has happened to a modality without copying its buffer. It returns a `Snapshot` of the counters the streamer maintains, so the question costs a handful of integers rather than a buffer copy. + +```Python +streamer_process, shared_memory_items = libemg.streamers.myo_streamer() +odh = libemg.data_handler.OnlineDataHandler(shared_memory_items=shared_memory_items) + +state = odh.get_state('emg') +print(state.total_samples) # rows ever committed +print(state.generation) # writes so far +print(state.closed) # True once the streamer has finished +``` + +The remaining fields are `epoch`, which advances whenever the modality is reset, `commits` and `dropped` for diagnostics, and `t_last_ns` for latency accounting. Calling `get_state` with no modality returns a snapshot for every modality the handler is attached to. + +`reset` still empties the buffer for a modality as before. It now also zeroes that modality's counters and advances its epoch. Without the epoch, an observer that missed the reset would compare what it had consumed against a total that had gone backwards and conclude nothing had arrived. + +## Hooks + +A hook is a piece of work that runs whenever the data it watches changes enough to matter. `install_hook` registers one, `start_hooks` starts them in their own process, and `stop_hooks` shuts them down. The handler builds and owns the reactive graph, so this is the short path when you only want one or two observers on a live stream. + +```Python +from libemg.reactive import ProbeHook + +streamer_process, shared_memory_items = libemg.streamers.myo_streamer() +odh = libemg.data_handler.OnlineDataHandler(shared_memory_items=shared_memory_items) + +# Print the newest EMG sample at most five times a second +odh.install_hook(ProbeHook('watch', 'emg', print, hz=5)) +odh.start_hooks() +... +odh.stop_hooks() +``` + +`install_event_log` records what the hooks did and why, which is the way to answer "why did this not fire?". Install it before the first `install_hook`, since the graph is built on the first registration. + +```Python +from libemg.event_log import EventLog + +log = EventLog(path='reactive.log') +odh.install_event_log(log) +``` + +Building the graph yourself instead gives control over which hooks share a process, and lets a hook's output feed another hook. That, the available criteria and the built-in hooks are covered in the Reactive Pipelines section. + diff --git a/docs/source/documentation/environments/environments.rst b/docs/source/documentation/environments/environments.rst new file mode 100644 index 00000000..b50de04f --- /dev/null +++ b/docs/source/documentation/environments/environments.rst @@ -0,0 +1,4 @@ +Environments In The GUI +------------------------------ +.. include:: environments_doc.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/documentation/environments/environments_doc.md b/docs/source/documentation/environments/environments_doc.md new file mode 100644 index 00000000..09ef59f6 --- /dev/null +++ b/docs/source/documentation/environments/environments_doc.md @@ -0,0 +1,244 @@ +LibEMG's environments are its real-time tasks. Each one is a pygame game with its own loop, its own drawing and its own results log. Until now each one opened a window of its own, and reaching it meant writing a script first. + +The Environments window runs them inside the LibEMG window instead. Open the LibEMG GUI, choose **Environments**, then **Launch Environment**. A task is picked from a list at the top, set up on the screen that appears, and launched into a panel beside its settings. The window builds its own environment, so it opens with no `OnlineDataHandler` and no hardware attached. + +```Python +from libemg.gui import GUI + +if __name__ == "__main__": + # No handler is passed. The Environments window does not need one. + GUI().start_gui() +``` + +Four tasks are offered in this release. + +| Task | What it is | Frame it opens at | +| --- | --- | --- | +| Fitts' Law | A cursor and a single target. The classic test of how quickly a control scheme acquires a target. | 1250 by 750 | +| ISO Fitts' Law | Targets in a ring, acquired in the standard ISO 9241-9 order. The usual way to report throughput. | 1250 by 750 | +| Curricular Fitts | A Fitts task whose difficulty adapts as the user improves. The task used for user-in-the-loop adaptation. | 1000 by 1080 | +| EMG Hero | Notes fall down the screen and are hit with the matching gesture. A rhythm game for discrete control. | 1500 by 750 | + +The frame size is whatever the task's own width and height settings say. The numbers above are the defaults the setup screen starts with. + +# How a pygame game ends up in a DearPyGui window + +This is the part worth understanding, because it explains why nothing about the games had to change. + +SDL has a video driver called `dummy`. Under it `pygame.display.set_mode` still returns a real surface, and everything still draws onto that surface. What is missing is the window. The environment never learns this. Its `game_setup` and its `_run_loop` run exactly as they always did. + +The frame then has to reach the interface. It does so by both sides pointing at the same memory. + +| Side | What it does with the memory | +| --- | --- | +| The GUI | Hands the array to `add_raw_texture` as the texture's backing store. | +| The environment | Writes the surface it just drew into that same array. | + +DearPyGui draws from the array it was given rather than from a copy taken when the texture was made. So there is no upload per frame and nothing to copy on the GUI side. The environment paints, and the window shows what it painted. + +The consequence matters more than the mechanism. An environment needs no special support to be embeddable. A new task written against the same base class runs in the window unchanged, and what it needs is a registry entry and a factory rather than a new drawing path. + +| Piece | Module | What it is | +| --- | --- | --- | +| Bridge | `libemg._gui._environments.frame_bridge` | The shared memory carrying one frame, plus control and input. | +| Runner | `libemg._gui._environments.embedded` | The process that runs the game offscreen. | +| Registry | `libemg._gui._environments.registry` | What each environment can be set up with. | +| Factories | `libemg._gui._environments.factories` | What crosses into the child process to be built there. | + +Nothing about the game is constructed in the GUI. A pygame object cannot cross a process boundary, and neither can an open socket. What crosses is a small description of what to build, and the building happens on the other side. + +# Why the environment stays in its own process + +A game that stalls must not take the interface with it. A thread would share the interpreter with the render loop, so a game stuck in its own update would freeze the window that was meant to stop it. A separate process cannot do that. The **Stop** button is still answered by the GUI even when the game answers nothing. + +The cost of that separation is one write of the frame per drawn frame. It was measured on the machine this page was written on. + +| Frame size | Bytes in one frame | Shared segment | Cost of one publish | +| --- | --- | --- | --- | +| 640 by 480 | 1.23 MB | 4.92 MB | 1.9 ms | +| 800 by 600 | 1.92 MB | 7.68 MB | 2.9 ms | +| 1250 by 750 | 3.75 MB | 15.0 MB | 6.5 ms | + +The segment is four times the frame because the texture holds float RGBA rather than bytes. Almost all of the cost is the pixels themselves, not the coordination. + +| Step in one publish, at 800 by 600 | Cost | +| --- | --- | +| Reading the surface as RGBA bytes | 1.0 ms | +| Scaling those bytes into the texture memory | 1.8 ms | +| Stamping the control block under its lock | 0.001 ms | + +The pixels are not locked. There is one writer, one reader, and a frame replaced whole sixty times a second. The worst a race can do is show one frame half new and half old, for one sixtieth of a second. Locking two megabytes sixty times a second would cost more than it saves, and would let a slow reader hold up the game. The small control block beside the pixels is guarded, because a torn integer there would be a real fault. + +An embedded ISO Fitts run at 640 by 480, asked for 60 frames per second, gave the following. + +| Measure | Value | +| --- | --- | +| Frames published in the first three seconds | 187 | +| Frames published in one measured second | 60 | +| Rate the bridge reports | 61 fps | + +The task ran at its requested rate with the frame going through shared memory every frame. + +# Input, going the other way + +With no window there are no keyboard events. That matters more than it sounds, because a keyboard-driven environment does not read the event queue at all. It calls `pygame.key.get_pressed` and asks what is held right now. SDL has no key state to report when there is no window, so that call would always answer nothing. + +Inside the environment's process, `pygame.key.get_pressed` is therefore replaced. The replacement reports the keys the GUI forwarded through the bridge. This replacement lives only in that process. Anywhere else in LibEMG, pygame behaves exactly as it always did. + +Posting synthetic key events instead would look correct and do nothing. `get_pressed` reflects SDL's own view of the physical keyboard, which posted events never reach. + +These are the keys the GUI forwards, listed in `FORWARDED_KEYS`. + +| Key names | Read by | +| --- | --- | +| `left`, `right`, `up`, `down` | The keyboard controller, as the four cursor directions. | +| `1`, `2`, `3`, `4` | The keyboard controller, for a task whose prediction map covers them. | +| `w`, `a`, `s`, `d` | Forwarded and available. No environment in this release reads them. | +| `space`, `escape` | Forwarded and available. No environment in this release reads them. | + +The keyboard is read once per rendered frame rather than through key-down handlers. A game wants to know what is held right now, every frame, not to be told once when a key went down. + +Click the game panel before typing. The forwarded keys are the ones the LibEMG window has, so the window has to have the focus. + +# The setup screens are generated + +No setup screen is hand-written. Each is built from the environment's own configuration, the same declaration the API documentation is built from. + +| Environment | Where its settings are declared | Settings offered | +| --- | --- | --- | +| Fitts' Law | `FittsConfig`, a dataclass with typed fields | 18 | +| ISO Fitts' Law | `FittsConfig`, plus the two ring settings `ISOFitts` takes | 20 | +| Curricular Fitts | `CurricularFittsConfig`, a dataclass with typed fields | 19 | +| EMG Hero | The constructor arguments of `EMGHero` | 7 | + +Every one of those settings gets a control. The help text beside a control is the author's own docstring sentence for that field, so what a user reads is what the author wrote where the setting is declared. A setting added to an environment appears on its setup screen without the screen being edited. + +The control is chosen from the setting's kind, which is read from its type annotation and its default. + +| Kind | Control drawn | +| --- | --- | +| `int` | Integer spinner | +| `float` | Float spinner | +| `bool` | Checkbox | +| `enum` | Drop-down of the allowed values | +| `color` | Colour picker | +| `str` | Text box | +| `path` | Text box | + +The `mapping` setting on a Fitts task is the one enumeration. A free text box would let somebody type a value that is only rejected once the task starts. Note that bare `polar` is not one of the choices. The environment accepts `polar+` and `polar-`, which say which way up maps, and raises on anything else. + +Colour settings are collected under an **Appearance** disclosure. They are about how the task looks rather than what it measures, and they would otherwise crowd out the settings that change the result. + +| Environment | Colour settings under Appearance | +| --- | --- | +| Fitts' Law and ISO Fitts' Law | 5 | +| Curricular Fitts | 6 | +| EMG Hero | 0 | + +# Two settings that behave specially + +Both come from the same place. A control has to be able to say things a plain number cannot. + +**Zero means off, for an optional number.** A timeout that can be switched off has no number meaning "off", and a spinner cannot show a blank. Zero is the only value a spinner can offer that is not a real duration. So zero is read as off. Without this a timeout left at zero would fail every trial the instant it began. + +| Setting | Value in the control | Value the task receives | +| --- | --- | --- | +| `timeout` | 0 | No timeout | +| `timeout` | 2.5 | 2.5 seconds | +| `game_time` | 0 | No time limit | +| `save_file` | Empty | Nothing is saved | + +**A required setting says so and still starts somewhere usable.** `num_trials` on a Fitts task has no default, because the environment cannot run without being told how many trials to run. Its label is marked, and the control starts at 1 rather than at 0. A control showing zero looks like a setting rather than a blank, and a task asking for zero trials is not runnable. + +| Setting | Label shown | Starting value | +| --- | --- | --- | +| `num_trials` on Fitts and ISO Fitts | `Num Trials (required)` | 1 | + +# Controllers + +A task needs something to drive it. The controller is chosen at the top of the setup screen, above the settings. + +| Controller | What drives the task | Fields shown | +| --- | --- | --- | +| Keyboard | The arrow keys, read every frame. | None | +| Classifier | A running classifier's output, over a socket. | Address, port, classes | +| Regressor | A running regressor's output, over a socket. | Address, port | + +Keyboard is for trying a task out and seeing that it behaves. Classifier and Regressor listen on the address given for a model that is already running elsewhere. + +Choosing Keyboard also supplies a prediction map, without being asked. A Fitts task turns a prediction into a direction through that map, and the default map covers class indices 0 to 4. The keyboard controller does not produce class indices. It produces pygame key codes, and -1 when nothing is held. Launching with the default map would fail on the first frame with a key error. The map supplied instead is this one. + +| Key | Direction | +| --- | --- | +| Up | `N` | +| Down | `S` | +| Right | `E` | +| Left | `W` | +| Nothing held | `NM` | + +The four arrows steer, and every other forwarded key maps to no motion. A Fitts task looks a prediction up in that map with no fallback, so a key with no entry would end the task with an error rather than being ignored. + +# When an environment refuses its settings + +An environment checks its own settings and raises when they conflict. It does that in its own process, where a traceback reaches nobody who is looking at the GUI. + +The clearest example is an ISO Fitts ring that will not fit. The ring radius has to be smaller than half of each screen dimension, or the targets are drawn off the edge. Asking for a radius of 400 on a 640 by 480 frame gives this. + +``` +ValueError: Radius between ISO Fitts targets is larger than screen size will allow. +Target distance radius must be less than half the screen dimensions. +Please increase screen width and height or reduce target distance radius. +``` + +The bridge reserves 2048 bytes for exactly this reason. The runner writes the reason there as well as printing the full traceback to the console, so the setup window shows the message rather than leaving a black rectangle and no explanation. The play window closes on its own and the setup window reads `That task could not start:` followed by the reason. + +The settings that can refuse a launch are these. + +| Setting | Rule | +| --- | --- | +| `target_distance_radius` | Must be less than half the width and less than half the height. | +| `mapping` | Must be `cartesian`, `polar+` or `polar-`. The drop-down offers only these. | + +Anything that goes wrong before the process is spawned is reported on the setup screen directly, because it happens where the window can see it. + +# Driving an environment without the GUI + +The same pieces work in a script. This runs ISO Fitts offscreen and reads its frames, with no window of any kind. + +```Python +import time +from libemg._gui._environments import (EmbeddedEnvironment, ControllerSpec, + build_factory, default_registry) + +if __name__ == "__main__": + spec = default_registry()["iso_fitts"] + values = spec.defaults() + values.update({'num_trials': 5, 'width': 640, 'height': 480, + 'target_distance_radius': 180}) + + factory = build_factory(spec, ControllerSpec(kind='keyboard'), values) + env = EmbeddedEnvironment('demo', factory, *spec.frame_size(values)).start() + + time.sleep(3.0) + print(env.status()) + # {'running': True, 'finished': False, 'frames': 187, 'fps': 60.8, + # 'generation': 187, 'error': ''} + + env.send_input({'right'}) # the same keys the GUI forwards + frame = env.pixels().reshape(480, 640, 4) + env.stop() +``` + +`pixels` returns the array a raw texture would be backed by. `status` is what the play window's status line is drawn from. `send_input` is what the GUI calls once per rendered frame. + +The factory has to be picklable, which is why it is a small class holding settings rather than a closure. A lambda capturing a configuration cannot be sent to another process, and the failure it produces names an anonymous function rather than anything a user could act on. + +# What is not built yet + +Embedding is an addition, not a replacement. Every environment still runs standalone exactly as it did before, in its own window, from its own script. Nothing on this page changes that. + +| Not implemented | What happens instead | +| --- | --- | +| Mouse control of a task | The pointer position and button are forwarded every frame. No environment in this release reads them. | +| Saving a set of settings | Settings last as long as the window. **Reset settings** returns them to the environment's own defaults. | +| Two tasks at once | One environment runs at a time. Launching while one is running says so rather than starting a second. | diff --git a/docs/source/documentation/gui_workflow/gui_workflow.rst b/docs/source/documentation/gui_workflow/gui_workflow.rst new file mode 100644 index 00000000..150d855b --- /dev/null +++ b/docs/source/documentation/gui_workflow/gui_workflow.rst @@ -0,0 +1,4 @@ +Doing It All In The GUI +------------------------------ +.. include:: gui_workflow_doc.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/documentation/gui_workflow/gui_workflow_doc.md b/docs/source/documentation/gui_workflow/gui_workflow_doc.md new file mode 100644 index 00000000..135c8e10 --- /dev/null +++ b/docs/source/documentation/gui_workflow/gui_workflow_doc.md @@ -0,0 +1,177 @@ +A myoelectric control session has always been four jobs. A device has to be brought up, training data has to be collected, a pipeline has to be built and run, and something has to be controlled with it. Each of those used to be a script, and the first one had to be a script before any of the others could be tried at all. + +The LibEMG window now does all four. Open it with no arguments and no handler. + +```Python +from libemg.gui import GUI + +if __name__ == "__main__": + GUI().start_gui() +``` + +Nothing is passed in. The window starts the device itself, and every other panel works off the one it started. + +# The whole workflow, in order + +| Step | Menu | Item | What it is for | +| --- | --- | --- | --- | +| 1 | Device | Streamer | Brings a device up and hands it to the rest of the window. | +| 2 | Data | Collect Data | Records labelled training data with screen guided prompts. | +| 3 | Pipeline | Pipeline Editor | Builds a pipeline as a graph, then runs it. | +| 4 | Environments | Launch Environment | Plays a task driven by the pipeline's output. | + +There is a fifth item that is useful at any point. **Visualize** then **Live Signal** plots whatever the started device is delivering, which is the quickest way to see that electrodes are on properly before anything is recorded. + +The order matters in one place only. Step 1 comes first. The other three can be revisited in any order once a device is running. + +# The streamer panel + +Choose **Device** then **Streamer**. The panel has a device drop-down, a **Start** button, a **Stop** button, a block of options for the chosen device, and a table of what is arriving. + +The device list is generated rather than written out. It is the streamer functions in `libemg.streamers`, filtered to the ones that accept `shared_memory_items`, with the synthetic device added at the top. A streamer that talks over a socket is left out, because it publishes nothing the rest of the window could attach to. That is why `mock_emg_stream` does not appear. These are the devices this release offers. + +| Device | +| --- | +| Synthetic (no hardware) | +| `delsys_api_streamer` | +| `delsys_streamer` | +| `emager_streamer` | +| `leap_streamer` | +| `myo_streamer` | +| `oymotion_streamer` | +| `sifi_bioarmband_streamer` | +| `sifi_biopoint_streamer` | + +Each device's options are generated too. They are the keyword arguments of that device's own function, read from its signature, with the control chosen from the type of each default. A device added to `libemg.streamers` appears here with its options intact and nothing in the panel changes. Three devices, counted from a real run. + +| Device | Options drawn | +| --- | --- | +| `delsys_streamer` | 8 | +| `sifi_biopoint_streamer` | 23 | +| Synthetic (no hardware) | 4 | + +Press **Start** and the panel launches that device, attaches an `OnlineDataHandler` to it, and begins reporting. The table has one row per modality. Which modalities appear depends on what the device was asked for. A Delsys gives `emg`, and `imu` as well when its Imu option is turned on. A SiFi BioPoint gives whichever of `ecg`, `emg`, `eda`, `imu`, `ppg` and `temperature` are enabled. The synthetic device gives `emg`. + +| Column | What it counts | +| --- | --- | +| Modality | The shared memory item the device writes to. | +| Samples | Every sample committed since this run started. | +| Rate (Hz) | Samples per second, smoothed over the last few refreshes. | +| Writes | Commits made by the device, which is how often it delivered. | + +The synthetic device at 1000 Hz on 6 channels, four seconds after Start, read as follows. + +| Modality | Samples | Rate (Hz) | Writes | +| --- | --- | --- | --- | +| `emg` | 4006 | 1000 | 4006 | + +The header above the table said this. + +``` +Incoming data 1000 samples per second across 1 modalities +``` + +The rate costs nothing to show. The panel reads each modality's state block, which is a handful of integers, rather than its data. Reading the samples themselves every frame would compete with the device for the same memory. + +Press **Stop** and the device is shut down. A streamer that has a stop signal is asked to stop and given three seconds to finish. A streamer that has none is terminated. + +# Why starting a device matters to everything else + +The panel does not keep the device to itself. When a device starts, the panel hands the GUI the `OnlineDataHandler` it built and the shared memory items behind it. Every other panel reads that handler when it opens. + +| Panel | What it does with the started device | +| --- | --- | +| Collect Data | Records the live stream against the prompts it shows. | +| Live Signal | Plots each modality as it arrives. | +| Pipeline Editor | Offers the running device as a source block. | +| Launch Environment | Drives a task from a model reading the same stream. | + +This is the reason step 1 comes first. Before a device is started, nothing in the window can see data. Opening Collect Data with no device running gives a panel with nothing to record. + +The handler is a normal `OnlineDataHandler`, so anything that accepts one accepts this. The one published by a synthetic device asked for 6 channels answered `get_data(N=200)` with the following. + +| Modality | Rows returned | Channels returned | +| --- | --- | --- | +| `emg` | 200 | 6 | + +**Stop** withdraws the handler as well as stopping the device. The other panels are told the device has gone rather than being left holding a handler attached to nothing. + +# The synthetic device + +The synthetic device is offered alongside the real ones. It commits generated samples into shared memory at a fixed rate, exactly as a device streamer does. Nothing downstream can tell the difference, so the entire window can be rehearsed with no hardware on the desk. A pipeline built against it is the same pipeline, and swapping in the real device later changes one choice in one drop-down. + +| Option | Default | What it sets | +| --- | --- | --- | +| Sampling Rate | 1000 | Samples committed per second. | +| Num Channels | 8 | Columns in each sample. | +| Pattern | bursts | The shape of the generated signal. | +| Amplitude | 1.0 | Scale of the generated signal. | + +There are three patterns. + +| Pattern | What it produces | Good for | +| --- | --- | --- | +| `noise` | Gaussian noise on every channel. | Checking shapes, rates and connections. | +| `sine` | A sine per channel, each at a different frequency. | Seeing a filter do something visible. | +| `bursts` | Four seconds quiet, then four seconds active. | Giving a classifier two states to separate. | + +Bursts is the default because it is the only one of the three a classifier can learn anything from. The quiet and active halves are easy to recognise in a probe and easy to label. + +# Two behaviours worth knowing + +**Starting resets the counters.** Shared memory outlives the process that made it, and every device writes to the same modality names. A device started now attaches to whatever the last one left behind. Without a reset, counts from a previous session would read as live data, and a device that is not connected would look like it was working. So the panel zeroes the counters on every Start. A second run begins near zero rather than continuing from the first. + +**A device that never delivers is reported.** A streamer spawns a process that goes and finds the device on its own. A device that is unplugged, asleep or already paired to something else starts perfectly well and simply never produces a sample. So the panel does not claim success at Start. It says it is waiting. + +``` +Synthetic (no hardware) started. Waiting for the first samples. +``` + +Only once samples actually arrive does the message change. + +``` +Synthetic (no hardware) is streaming. Collect Data, Live Signal, the pipeline +editor and the environments can all use it now. +``` + +A device that has delivered nothing after five seconds is said so plainly. This is a real Myo with no dongle attached. + +``` +myo_streamer started, but no samples have arrived in five seconds. Check that +the device is on, paired and not in use by another program. It is left running +in case it is still connecting. +``` + +It is left running rather than shut down, because a device that is slow to connect and a device that is not there look identical for the first few seconds. A device that fails to launch at all is a different case and reports its own reason instead. + +Switching device while one is running is refused. The drop-down is put back to the running device and the panel says to stop it first. + +**Clicking a menu item twice brings the panel forward.** It does not build a second one. This matters most for the streamer: a second panel would take over the widget tags the first one owns, leaving the running device held by a panel with no window able to stop it. The same applies to the pipeline editor and the environments panel, so a running pipeline is never orphaned by a stray click. + +# Fitting the model + +The fourth job is in the window too. Build a pipeline whose source is **Stored Data**, pointed at the folder the collection panel wrote, and press **Train**. The pipeline already says which regex filters parse the file names, which metadata field carries the labels, how the signal is filtered and windowed, which features to take and which model to fit, so nothing has to be said twice. + +``` +Trained on 71972 windows of 27 features over classes [0, 1, 2]. +Saved to C:\work\models\lda.pkl. Point a live pipeline at that file to run it. +``` + +The file it writes is the one the model block's **Fitted Model** parameter already names, so the same document scores itself on the next **Start**, and a live pipeline built on the same model block picks it up with nothing else to set. + +A live pipeline still will not compile without that file, and says so rather than failing later. + +``` +'Classifier' (classifier_5) needs a fitted model to run live. +Set its Fitted Model parameter to a saved predictor. +``` + +# The whole loop, in the window + +1. **Device ▸ Streamer**, pick the hardware, press Start. The handler it publishes reaches every other panel. +2. **Data ▸ Collect Data**, record the prompted gestures. +3. **Pipeline ▸ Pipeline Editor**, build a stored-data pipeline over that folder, press **Train**, then **Start** to see its offline metrics. +4. Rebuild the same pipeline against the live device, or open the one you saved, and press **Start**. +5. **Environments**, set up a task and launch it. It reads the predictions the pipeline is publishing. + +No step in that list needs Python. diff --git a/docs/source/documentation/introduction/core_modules.png b/docs/source/documentation/introduction/core_modules.png index 814f78dc..ac9a4b3a 100644 Binary files a/docs/source/documentation/introduction/core_modules.png and b/docs/source/documentation/introduction/core_modules.png differ diff --git a/docs/source/documentation/introduction/intro_doc.md b/docs/source/documentation/introduction/intro_doc.md index f9384ff0..6af5b8d1 100644 --- a/docs/source/documentation/introduction/intro_doc.md +++ b/docs/source/documentation/introduction/intro_doc.md @@ -8,6 +8,8 @@ Myoelectric control has been primarily limited in its use for prosthesis control # Modules As displayed in Figure 1, LibEMG consists of six main modules. Although many of these modules can stand independently from the others (e.g., the Feature Extraction module can be used on any dataset), they work sequentially to make up the core EMG pipeline. This pipeline is directly inspired from prosthetics research. +Underneath the online parts of that pipeline sits a reactive layer. It carries data between live stages by notification, so a filter, a feature stage or a model runs when new samples arrive rather than checking for them. For details, see the Reactive Pipelines section. + ![alt text](core_modules.png)

Figure 1: Diagram of LibEMG's Core Modules. Dashed lines represent modules that are optional to implement.

@@ -27,9 +29,9 @@ As EMG signals are stochastic, they do not provide adequate descriptive informat Feature selection is an important design consideration when developing EMG-based control systems, as features can drastically influence performance. Often, however, it is difficult to know what features to select for a particular problem. **This module provides a means to extract the most relevant features for a specific problem.** This module is optional and is primarily a tool to explore the robustness of certain features and groups using a variety of metrics. These are the techniques used by previous work to suggest predefined feature groups. -

Classification Module

+

Prediction Module

-Classification uses machine learning models to predict user intent from EMG data (i.e., features) generated during contractions. **This module enables online (real-time) and offline (after-the-fact) classification.** Currently, it is limited to continuous control schemes where a model continuously predicts user intent based on segments (i.e., windows) of data. +The prediction module uses machine learning models (classification or regression) to predict user intent from EMG data (i.e., features) generated during contractions. **This module enables online (real-time) and offline (after-the-fact) predictions.** Currently, it is limited to continuous control schemes where a model continuously predicts user intent based on segments (i.e., windows) of data.

Evaluation Module

diff --git a/docs/source/documentation/pipeline/pipeline.rst b/docs/source/documentation/pipeline/pipeline.rst new file mode 100644 index 00000000..6e4fa017 --- /dev/null +++ b/docs/source/documentation/pipeline/pipeline.rst @@ -0,0 +1,4 @@ +Pipeline Editor +------------------------------ +.. include:: pipeline_doc.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/documentation/pipeline/pipeline_doc.md b/docs/source/documentation/pipeline/pipeline_doc.md new file mode 100644 index 00000000..db56d9ee --- /dev/null +++ b/docs/source/documentation/pipeline/pipeline_doc.md @@ -0,0 +1,404 @@ +A myoelectric control pipeline is a chain of stages. Samples are filtered, cut into windows, reduced to features, handed to a model, and the model's output is sent somewhere useful. Writing that chain out in code means knowing which class each stage belongs to, and which argument carries the window size, before anything can be run at all. + +The pipeline editor is a node editor for that chain. Open the LibEMG GUI, choose **Pipeline**, then **Pipeline Editor**. Blocks are placed from a palette on the left and connected by dragging from one port to another. The editor creates its own sources, so it opens with no `OnlineDataHandler` and no hardware attached. + +```Python +from libemg.gui import GUI + +if __name__ == "__main__": + # No handler is passed. The pipeline editor supplies its own sources. + GUI().start_gui() +``` + +# Three layers, and why the editor is the thin one + +A pipeline is built from three modules, and only the last one needs a display. + +| Layer | Module | What it is | +| --- | --- | --- | +| Registry | `libemg._gui._pipeline.registry` | A description of every block that can be placed. | +| Document | `libemg._gui._pipeline.document` | The pipeline itself, as plain data. | +| Compiler | `libemg._gui._pipeline.compile` | Turns a document into something that runs. | + +The registry is generated from the library rather than hand-listed. Feature names come from the feature extractor, metric names from the offline metrics, model names from the predictors, and each device's parameters from its own signature. A block added to LibEMG appears in the palette without the registry being edited. + +The document holds the nodes, the links, the probes and the canvas positions. It imports neither DearPyGui nor the LibEMG runtime. + +The consequence matters more than the structure. A pipeline is plain data, so it can be built, saved, validated and run with no display at all. The editor is a view onto a document, not the thing itself. + +# Building a pipeline without the editor + +The headless route is the same document the editor edits. This builds a live pipeline, compiles it and runs it. + +```Python +from libemg._gui._pipeline import PipelineDocument +from libemg._gui._pipeline.compile import compile_pipeline + +doc = PipelineDocument() +src = doc.add_node('source.synthetic_streamer', + params={'num_channels': 4, 'sampling_rate': 1000, + 'pattern': 'bursts'}) +flt = doc.add_node('transform.filter', + params={'name': 'bandpass', 'cutoff': '20,450', + 'order': 4, 'sampling_rate': 1000}) +win = doc.add_node('window.enframe', + params={'window_size': 200, 'window_increment': 50}) +fea = doc.add_node('features.extract', params={'features': ['MAV', 'RMS']}) +mdl = doc.add_node('model.classifier', params={'model_path': 'mdl.pkl'}) +snk = doc.add_node('sink.console') + +doc.connect(src, 'emg', flt, 'input') +doc.connect(flt, 'output', win, 'input') +doc.connect(win, 'output', fea, 'input') +doc.connect(fea, 'output', mdl, 'input') +doc.connect(mdl, 'output', snk, 'input') + +print(doc.mode(), doc.validate()) +# online [] + +doc.save('pipeline.json') +pipeline = compile_pipeline(doc) +pipeline.start() +``` + +`validate()` returns every reason a pipeline could not run, as sentences. An empty list means it is ready. `check()` raises `ValidationError` instead, for a script that should stop. This is what makes a pipeline testable without a person watching it. + +# The blocks + +Blocks are grouped in the palette by category. + +| Category | Block | Consumes | Produces | Notes | +| --- | --- | --- | --- | --- | +| Source | Device streamers | | samples | One block per entry in `libemg.streamers`. Live only. | +| Source | Synthetic Source | | samples | Generated samples, no hardware. Live only. | +| Source | Stored Data | | samples, labels | Replays recordings from disk. Stored only. | +| Transform | Filter | samples | samples | Conditions the signal once, for every consumer downstream. | +| Transform | Channel Mask | samples | samples | Narrows the stream to a subset of channels. | +| Window | Window | samples | windows | Sets the window size and increment. Never a stage of its own. | +| Features | Features | windows | features | Extracts features once, so several models can share them. | +| Model | Classifier | features or windows | prediction | Runs a fitted classifier. | +| Model | Regressor | features or windows | continuous | Runs a fitted regressor. | +| Sink | Socket Output | prediction | | Sends each output over UDP or TCP. Live only. | +| Sink | File Output | prediction | | Appends each output to a file. | +| Sink | Console Output | prediction | | Prints each output, while a pipeline is being built up. | +| Sink | Offline Metrics | prediction, labels | metrics | Scores predictions against ground truth. Stored only. | + +The device source blocks in this release of LibEMG are: + +| Block id | +| --- | +| `source.delsys_streamer` | +| `source.delsys_api_streamer` | +| `source.emager_streamer` | +| `source.leap_streamer` | +| `source.myo_streamer` | +| `source.oymotion_streamer` | +| `source.sifi_bioarmband_streamer` | +| `source.sifi_biopoint_streamer` | + +Each one carries the keyword arguments of the streamer function it was generated from. Those appear under an **Advanced** disclosure on the block, because they have sensible defaults and rarely need touching. + +# Port types, and why a wrong connection cannot be made + +Every port carries one of seven types. + +| Port type | Carries | +| --- | --- | +| `samples` | An N by C continuous stream. | +| `windows` | Enframed windows. | +| `features` | An N by F feature matrix. | +| `prediction` | A class index with its confidence. | +| `continuous` | A regression output vector. | +| `labels` | Ground truth, stored data only. | +| `metrics` | A summary result table. | + +The port type becomes the `category` on the underlying DearPyGui node attribute, and DearPyGui refuses to link two attributes whose categories differ. A features input simply will not accept a samples output as the link is dragged. Typing is enforced by the toolkit while the user drags, not by validation that runs afterwards and has to explain itself. + +Three rules cannot be expressed as a category. Those are checked when the link is released, and reported in words. + +| Situation | What the editor says | +| --- | --- | +| A block linked to itself | `A block cannot feed itself.` | +| An input that already has a link | `'Input' already has a connection, and two sources into one input would interleave with no defined order.` | +| A link that would close a loop | `That would make a loop, and each stage would wait for the other.` | + +A type mismatch is still explained if it reaches the document, for instance when the document is built in Python rather than dragged: + +```Python +doc.why_not_connect(src, 'emg', fea, 'input') +# "'EMG' carries samples and 'Input' expects windows." +``` + +`why_not_connect` returns `None` when a link would be accepted. `connect` raises `ValueError` with the same sentence. + +# The window block is fused, not a stage + +This is the central design point of the compiler. A window block does not become a stage. What it configures is how the next stage observes the one before it. + +| Window parameter | What it becomes | +| --- | --- | +| `window_increment` | The criterion on the next stage, as `OnSamples(increment)`. | +| `window_size` | The number of rows that stage is handed, in window mode. | + +So a Window between a Filter and a Features block compiles to the Features block observing the Filter directly. Nothing copies an enframed array into shared memory just to hand it along. The criteria and modes referred to here are the ones described in the [reactive pipelines guide](../reactive/reactive_doc.md). + +The document above compiles to this graph. It is the compiler's own description, printed from a real run: + +``` +executor 'filter_2': + filter_2: observes pipe_synthetic_streamer_1_emg [OnSamples(1), window(64)] -> writes pipe_filter_2_output +executor 'extract_4': + extract_4: observes pipe_filter_2_output [OnSamples(50), window(200)] -> writes pipe_extract_4_output +executor 'classifier_5': + classifier_5: observes pipe_extract_4_output [OnSamples(1), latest] -> writes pipe_classifier_5_output +executor 'console_6': + console_6: observes pipe_classifier_5_output [OnCommit(), latest] -> writes - +executor 'probe_filter_2': + probe_filter_2_output: observes pipe_filter_2_output [Periodic(20), window(400)] -> writes probe_filter_2_output +executor 'probe_classifier_5': + probe_classifier_5_output: observes pipe_classifier_5_output [Periodic(20), latest] -> writes probe_classifier_5_output +``` + +There are six blocks in the document and no stage named after the window. Its two numbers reappear as `OnSamples(50), window(200)` on the features stage. That is the whole of its effect. + +Because a window is only ever a pair of numbers on the stage it feeds, it has no online-or-offline mode to set. Live, those numbers trigger the next stage when an increment has accrued. Over stored data, the same numbers enframe a recording in batches. + +Running that pipeline for six seconds on a synthetic source produced the following, and the counts are what the fusion predicts: + +| Item | Rows committed | +| --- | --- | +| `pipe_synthetic_streamer_1_emg` | 6287 | +| `pipe_filter_2_output` | 6286 | +| `pipe_extract_4_output` | 125 | +| `pipe_classifier_5_output` | 125 | + +6287 samples at an increment of 50 gives 125 windows. Every window produced one feature row, and every feature row produced one prediction. + +# A model reads features, or raw windows + +A model block has two inputs and takes exactly one of them. Connect features for a statistical model. Connect a window directly for a model that works on raw windows, which is the usual shape for a deep model. + +| Connected to | What the model observes | +| --- | --- | +| Features | One feature row at a time | +| Window | The window itself, on the window's increment | + +Connecting both is refused, because a model reads one or the other. Connecting neither is refused too. + +```Python +# a deep model, straight off the window +doc.connect(window, 'output', model, 'windows') +``` + +# Standardize is a stored-data filter + +Standardizing subtracts a mean and divides by a deviation, and both have to be measured from data. A recording supplies them, so the filter works offline. A live stream has nothing to measure before it starts, so choosing Standardize on a live pipeline is refused while compiling rather than failing later inside a running stage. + +| Pipeline | Standardize | +| --- | --- | +| Stored data | Works. The loaded recording supplies the statistics. | +| Live stream | Refused at compile time, with the reason and the alternatives. | + +# Online or offline is inferred, never configured + +A pipeline's mode comes from its sources. + +| Sources | `doc.mode()` | +| --- | --- | +| No source at all | `empty` | +| Live devices or the synthetic source | `online` | +| Stored Data only | `offline` | +| Both kinds at once | `mixed` | + +There is no mode setting anywhere in the editor. A user who had to declare the mode could declare it wrongly, and a pipeline built for a live stream but told to read a recording would start cleanly and then silently do nothing. Inferring it makes that mistake unrepresentable. + +Mixing the two is an error, and validation names the blocks on each side: + +``` +This mixes live sources (synthetic_streamer_1) with stored data (offline_2). +A run is either one or the other. +``` + +Blocks that only make sense in one mode are checked against the inferred mode too. A Socket Output in a stored-data pipeline is reported as only working on a live stream. + +# Probes + +A probe watches one output port while the pipeline runs. It is a toggle on the port, not a block to place. Making it a block would clutter the canvas and force the user to wire up something they only want to look at. + +How a probe draws is derived from the type of the port it watches, so probing is one click and there is nothing to configure. + +| Port type | Rendering | +| --- | --- | +| `samples` | Time series | +| `continuous` | Time series | +| `windows` | Window overlay | +| `features` | Bars | +| `prediction` | Probabilities | +| `labels` | Time series | +| `metrics` | Table | + +Probes open in a **Scope** window alongside the editor when a live pipeline starts. Each one is rate limited, by default to 30 updates per second, and publishes into its own shared-memory item rather than calling back into the pipeline. A probe that cannot keep up with its source is still a probe. It shows less, and it can never stall what it watches. + +Two probes were set at 20 Hz on the six-second run above. Neither held its stage back. + +| Stage or probe | Times it ran | +| --- | --- | +| `filter_2`, the stage being watched | 6291 | +| `probe_filter_2_output` | 120 | +| `probe_classifier_5_output` | 75 | + +```Python +doc.add_probe(flt, 'output', hz=20) +doc.add_probe(mdl, 'output', hz=20) +doc.probe_render(doc.probes[0]) +# 'timeseries' +``` + +A window cannot be probed. It is folded into the block it feeds rather than becoming a stage, so it publishes nothing to watch. Asking for one is refused straight away, with a suggestion: + +``` +A window cannot be probed. It is folded into the block it feeds rather than +producing anything of its own, so there is nothing to watch. Probe the block +before it to see the samples going in, or the block after it to see what +comes out. +``` + +Refusing at the point it is asked for matters. Accepting the probe and dropping it when the pipeline compiled would leave an empty plot with no explanation, and would also let it count as a reader of a branch that nothing actually reads. + +# Running + +Both modes get a **Start** and a **Stop** button, and both report what they are doing under the canvas. What they can honestly report differs. + +| | Live pipeline | Stored-data pipeline | +| --- | --- | --- | +| Started by | Start | Start | +| Stopped by | Stop | Stop | +| Progress shown as | A status line of rows committed per stage | A progress bar | +| Probes | A Scope window | Not applicable | +| Finishes into | Nothing. It runs until stopped | A Results window | + +A live run has no total, because a stream has no end. The status line reports rows committed per stage instead, which is the honest thing a live run knows. A recording knows its own length, so the bar is a real fraction rather than a spinner. + +The stored-data run reports its progress as it works through the files. Running the pipeline above over a two-file recording gave: + +```Python +results = pipeline.run(on_progress=print) +# 0.5 +# 1.0 +# 1.0 +``` + +A cancelled run says so rather than claiming to have finished. Stopping part way leaves the fraction where it stopped, and `stopped` records that it was cut short. + +| After a run | `stopped` | `progress` | `results` | +| --- | --- | --- | --- | +| Finished | False | 1.0 | Over every recording | +| Stopped part way | True | Where it stopped | Over the recordings it read | + +The metrics from a cancelled run are kept, because throwing away work already done is worse than reporting less. They are computed over part of the recording, which is exactly why the run has to be distinguishable from a complete one. + +The fraction is reported once per recording read, and once more when the run completes. + +When it finishes, the Results window opens with a metrics table and a heat map of any confusion matrix. **Copy as CSV** puts the whole table on the clipboard. The same run, scored on the data it was fitted on: + +| Metric | Value | +| --- | --- | +| CA | 1.0 | +| RECALL | 1.0 | + +A run in progress can be asked to finish early with **Stop**, or with `request_stop` from a script. + +Compilation happens when Start is pressed, and anything wrong is reported before a single process is spawned. The messages name the block and say what to do: + +``` +This pipeline cannot run yet: + - 'Offline Metrics' (metrics_3) has nothing connected to its Input input. + - 'Offline Metrics' (metrics_3) has nothing connected to its Labels input. + - 'Window' (enframe_2) produces something that nothing reads and no probe watches. +``` + +# Saving and loading + +**Save** writes a JSON file holding the schema version, the nodes with their parameters and canvas positions, the links and the probes. Canvas positions are read back from the editor before writing, so layout survives a save. **Open** restores all of it, including the probe toggles and the links on the canvas. + +The registry is generated from the library, so it legitimately differs between LibEMG versions. A file written against a newer version will name blocks this one has never heard of. Refusing to open that file loses the user's work, and opening it while quietly dropping what was not recognised loses it more insidiously. + +An unknown block therefore loads as an **unresolved** node. + +| Behaviour | Detail | +| --- | --- | +| Kept | Its block id, its parameters and its canvas position. | +| Drawn | As a node marked `Not recognised by this LibEMG.` | +| Reported | By `validate`, naming the node. | +| Blocks the run | Yes. Compilation refuses until it is dealt with. | +| Written back | Byte for byte as it came in. | + +Loading a file containing `transform.timewarp`, a block that does not exist here, gives: + +``` +These blocks are not recognised by this version of LibEMG: future_1. +They were kept so nothing is lost, but the pipeline cannot run until they +are removed. +``` + +Saving that document again writes the unresolved node back unchanged, parameters and position included. Nothing is destroyed by looking at the file with the wrong version installed. + +A parameter the current registry no longer declares is kept the same way, so downgrading LibEMG and upgrading again does not lose it. + +# Building without hardware + +The Synthetic Source writes into shared memory exactly as a device streamer does. A pipeline built on it is the same pipeline, and swapping in the real device changes one block. + +| Parameter | Default | Meaning | +| --- | --- | --- | +| `sampling_rate` | 1000 | Samples per second. | +| `num_channels` | 8 | Channels produced. | +| `pattern` | `bursts` | `noise`, `sine` or `bursts`. | +| `amplitude` | 1.0 | Scale of the generated signal. | + +The `bursts` pattern alternates four seconds quiet with four seconds active. That gives a classifier something to separate and a probe something visible to draw. Every figure quoted on this page was produced by a synthetic source on a machine with no electrodes attached. + +It works outside the editor as well, anywhere a streamer is accepted: + +```Python +from libemg._gui._pipeline.synthetic import synthetic_streamer +from libemg.data_handler import OnlineDataHandler + +streamer, shared_memory = synthetic_streamer(pattern='bursts', num_channels=8) +odh = OnlineDataHandler(shared_memory) +``` + +# Training a model from the pipeline that describes it + +**Train** fits the model the pipeline names, on the recording the pipeline reads. It is only for a stored-data pipeline, because a fit needs labelled data that has already been collected. + +Everything a fit needs is already on the canvas. The Stored Data block says where the recordings are, which regex filters parse their names and which metadata field carries the labels. The filter and window blocks say how the signal is conditioned and enframed. The features block says what to extract. The model block says which model to fit and where to put it. Training walks the recording exactly as a scoring run does, so the model is fitted on precisely the data it will then be scored on. + +Build the pipeline, press **Train**, then press **Start**. The second run scores what the first one fitted. + +``` +Trained on 71972 windows of 27 features over classes [0, 1, 2]. +Saved to C:\work\models\lda.pkl. Point a live pipeline at that file to run it. +``` + +Which kind of predictor it builds follows from the block. A **Classifier** block fits an `EMGClassifier` and rounds its labels to class indices. A **Regressor** block fits an `EMGRegressor` and treats the label field as one degree of freedom per column, so a field holding a single value per sample trains as one DOF. + +The same document then runs live. Point its source at a device instead of a folder and the model block already holds the file that was just written. + +## What Train refuses, and why + +| Message | What to do | +| --- | --- | +| The model block has no Fitted Model path | Set that parameter. It is both where training saves and where a live run loads. | +| That recording produced no windows | Check the folder, the regex filters, and that the window is not longer than the recordings. | +| That recording carries no labels | Set the Stored Data block's **Label Key** to a metadata field its regex filters produce. | +| Training was stopped part way | Nothing was saved. A model fitted on half a recording would be indistinguishable from one fitted on all of it, so the existing file is left alone. | + +A live model still will not compile without a file, and says so: + +``` +'Classifier' (classifier_5) needs a fitted model to run live. +Set its Fitted Model parameter to a saved predictor. +``` + +An **offline** pipeline compiles without one, which is what makes the Train button reachable: the only way to get the file is to run the training that demanding it would refuse. diff --git a/docs/source/documentation/prediction/classification_doc.md b/docs/source/documentation/prediction/classification_doc.md new file mode 100644 index 00000000..da48865c --- /dev/null +++ b/docs/source/documentation/prediction/classification_doc.md @@ -0,0 +1,75 @@ +# Classifiers + +Below is a list of the classifiers that can be instatiated by passing in a string to the `EMGClassifier`. For other classifiers, pass in a custom model that has the `fit`, `predict`, and `predict_proba` methods. + +## Linear Discriminant Analysis (LDA) + +A linear classifier that uses common covariances for all classes and assumes a normal distribution. +```Python +classifier = EMGClassifier('LDA') +classifier.fit(data_set) +``` +Check out the LDA docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.LinearDiscriminantAnalysis.html) + +## K-Nearest Neighbour (KNN) + +Discriminates between inputs using the K closest samples in feature space. The implemented version in the library defaults to k = 5. A commonly used classifier for EMG-based recognition. + +```Python +params = {'n_neighbors': 5} # Optional +classifier = EMGClassifier('KNN') +classifier.fit(data_set, parameters=params) +``` +Check out the KNN docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html) + +## Support Vector Machines (SVM) + +A hyperplane that maximizes the distance between classes is used as the boundary for recognition. A commonly used classifier for EMG-based recognition. +```Python +classifier = EMGClassifier('SVM') +classifier.fit(data_set) +``` +Check out the SVM docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVC.html) + +## Artificial Neural Networks (MLP) + +A deep learning technique that uses human-like "neurons" to model data to help discriminate between inputs. Especially for this model, we **highly** recommend you create your own. +```Python +classifier = EMGClassifier('MLP') +classifier.fit(data_set) +``` +Check out the MLP docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPClassifier.html) + +## Random Forest (RF) + +Uses a combination of decision trees to discriminate between inputs. +```Python +classifier = EMGClassifier('RF') +classifier.fit(data_set) +``` +Check out the RF docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) + +## Quadratic Discriminant Analysis (QDA) + +A quadratic classifier that uses class-specific covariances and assumes normally distributed classes. +```Python +classifier = EMGClassifier('QDA') +classifier.fit(data_set) +``` +Check out the QDA docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis.html) + +## Gaussian Naive Bayes (NB) + +Assumes independence of all input features and normally distributed classes. +```Python +classifier = EMGClassifier('NB') +classifier.fit(data_set) +``` +Check out the NB docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.GaussianNB.html) + + diff --git a/docs/source/documentation/classification/decision_stream.png b/docs/source/documentation/prediction/decision_stream.png similarity index 100% rename from docs/source/documentation/classification/decision_stream.png rename to docs/source/documentation/prediction/decision_stream.png diff --git a/docs/source/documentation/classification/post_processing_doc.md b/docs/source/documentation/prediction/post_processing_doc.md similarity index 71% rename from docs/source/documentation/classification/post_processing_doc.md rename to docs/source/documentation/prediction/post_processing_doc.md index 97217ddf..30a9637b 100644 --- a/docs/source/documentation/classification/post_processing_doc.md +++ b/docs/source/documentation/prediction/post_processing_doc.md @@ -1,6 +1,8 @@ # Post-Processing -## Rejection +## Classification + +### Rejection Classifier outputs are overridden to a default or inactive state when the output decision is uncertain. This concept stems from the notion that it is often better (less costly) to incorrectly do nothing than it is to erroneously activate an output. - **Confidence [1]:** Rejects based on a predefined **confidence threshold** (between 0-1). If predicted probability is less than the confidence threshold, the decision is rejected. Figure 1 exemplifies rejection using an SVM classifier with a threshold of 0.8. @@ -9,7 +11,7 @@ Classifier outputs are overridden to a default or inactive state when the output classifier.add_rejection(threshold=0.9) ``` -## Majority Voting [2,3] +### Majority Voting [2,3] Overrides the current output with the label corresponding to the class that occurred most frequently over the past $N$ decisions. As a form of simple low-pass filter, this introduces a delay into the system but reduces the likelihood of spurious false activations. Figure 1 exemplifies applying a majority vote of 5 samples to a decision stream. ```Python @@ -17,7 +19,7 @@ Overrides the current output with the label corresponding to the class that occu classifier.add_majority_vote(num_samples=10) ``` -## Velocity Control [4] +### Velocity Control [4] Outputs an associated *velocity* with each prediction that estimates the level of muscular contractions (normalized by the particular class). This means that within the same contraction, users can contract harder or lighter to control the velocity of a device. Note that ramp contractions should be accumulated during the training phase. ```Python @@ -26,11 +28,27 @@ classifier.add_velocity(train_windows, train_labels) ``` Figure 1 shows the decision stream (i.e., the predictions over time) of a classifier with no post-processing, rejection, and majority voting. In this example, the shaded regions show the ground truth label, whereas the colour of each point represents the predicted label. All black points indicate predictions that have been rejected. - ![alt text](decision_stream.png)

Figure 1: Decision Stream of No Post-Processing, Rejection, and Majority Voting. This can be created using the .visualize() method call.

+## Regression + +### Deadband [5] + +Modifies a regressor's output based on whether the prediction's magnitude is above a certain threshold. Any value whose magnitude is less than the defined threshold is output as 0. This preprocessing technique is typically used to combat drift at lower amplitudes. + +```Python +# Add deadband to regressor (i.e., values with magnitude < 0.25 will be output as 0) +regressor.add_deadband(0.25) +``` + +Figure 2 shows the decision stream of a regressor with no post-processing and deadband thresholding. In this visualization, the shaded blue regions are the ground truth and each black dot corresponds to a single prediction. Predictions for each degree of freedom (DOF) are plotted on separate subplots for visual clarity. + +![alt text](regression_post_processing.png) +

Figure 2: Decision Stream of Regressor with No Post-Processing and Deadband Thresholding. This can be created using the regressor's .visualize() method call.

+ ## References + [1] E. J. Scheme, B. S. Hudgins and K. B. Englehart, "Confidence-Based Rejection for Improved Pattern Recognition Myoelectric Control," in IEEE Transactions on Biomedical Engineering, vol. 60, no. 6, pp. 1563-1570, June 2013, doi: 10.1109/TBME.2013.2238939. @@ -43,5 +61,8 @@ Wahid MF, Tafreshi R, Langari R. A Multi-Window Majority Voting Strategy to Impr [4] E. Scheme, B. Lock, L. Hargrove, W. Hill, U. Kuruganti and K. Englehart, "Motion Normalized Proportional Control for Improved Pattern Recognition-Based Myoelectric Control," in IEEE Transactions on Neural Systems and Rehabilitation Engineering, vol. 22, no. 1, pp. 149-157, Jan. 2014, doi: 10.1109/TNSRE.2013.2247421. +[5] +A. Ameri, E. N. Kamavuako, E. J. Scheme, K. B. Englehart, and P. A. Parker, “Support vector regression for improved real-time, simultaneous myoelectric control,” IEEE Transactions on Neural Systems and Rehabilitation Engineering, vol. 22, no. 6, pp. 1198–1209, Nov. 2014, doi: 10.1109/TNSRE.2014.2323576. + [Sklearn] -Fabian Pedregosa, Gaël Varoquaux, Alexandre Gramfort, Vincent Michel, Bertrand Thirion, Olivier Grisel, Mathieu Blondel, Peter Prettenhofer, Ron Weiss, Vincent Dubourg, Jake Vanderplas, Alexandre Passos, David Cournapeau, Matthieu Brucher, Matthieu Perrot, and Édouard Duchesnay. 2011. Scikit-learn: Machine Learning in Python. J. Mach. Learn. Res. 12, null (2/1/2011), 2825–2830. \ No newline at end of file +Fabian Pedregosa, Gaël Varoquaux, Alexandre Gramfort, Vincent Michel, Bertrand Thirion, Olivier Grisel, Mathieu Blondel, Peter Prettenhofer, Ron Weiss, Vincent Dubourg, Jake Vanderplas, Alexandre Passos, David Cournapeau, Matthieu Brucher, Matthieu Perrot, and Édouard Duchesnay. 2011. Scikit-learn: Machine Learning in Python. J. Mach. Learn. Res. 12, null (2/1/2011), 2825–2830. diff --git a/docs/source/documentation/classification/classification.rst b/docs/source/documentation/prediction/prediction.rst similarity index 54% rename from docs/source/documentation/classification/classification.rst rename to docs/source/documentation/prediction/prediction.rst index 751e673f..f15dfc84 100644 --- a/docs/source/documentation/classification/classification.rst +++ b/docs/source/documentation/prediction/prediction.rst @@ -1,7 +1,13 @@ -Classification +EMG Prediction ------------------------------ +.. include:: predictors.md + :parser: myst_parser.sphinx_ + .. include:: classification_doc.md :parser: myst_parser.sphinx_ +.. include:: regression_doc.md + :parser: myst_parser.sphinx_ + .. include:: post_processing_doc.md :parser: myst_parser.sphinx_ \ No newline at end of file diff --git a/docs/source/documentation/prediction/predictors.md b/docs/source/documentation/prediction/predictors.md new file mode 100644 index 00000000..366a2bd6 --- /dev/null +++ b/docs/source/documentation/prediction/predictors.md @@ -0,0 +1,62 @@ +# Models + +After recording, processing, and extracting features from a window of EMG data, it is passed to a machine learning algorithm for prediction. These control systems have evolved in the prosthetics community for continuously predicting muscular contractions for enabling prosthesis control. Therefore, they are primarily limited to recognizing static contractions (e.g., hand open/close and wrist flexion/extension) as they have no temporal awareness. Currently, this is the form of recognition supported by LibEMG and is an initial step to explore EMG as an interaction opportunity for general-purpose use. This section highlights the machine-learning strategies that are part of `LibEMG`'s pipeline. + +There are two types of models supported in `LibEMG`: classifiers and regressors. Classifiers output a discrete motion class for each window, whereas regressors output a continuous prediction along a degree of freedom. For both classifiers and regressors, `LibEMG` supports statistical models as well as deep learning models. Additionally, a number of post-processing methods (i.e., techniques to improve performance after prediction) are supported for all models. + +## Statistical Models + +The statistical models (i.e., traditional machine learning methods) implemented leverage the sklearn package. For most cases, the "base" models use the default options, meaning that the pre-defined models are not necessarily optimal. However, the `parameters` attribute can be used when initializing the models to pass in additional sklearn parameters in a dictionary. For example, looking at the `RandomForestClassifier` docs on sklearn: + +![Random Forest](random_forest.png) + +A classifier with any of those parameters using the `parameters` attribute. For example: + +```Python +parameters = { + 'n_estimators': 99, + 'max_depth': 20, + 'random_state': 5, + 'max_leaf_nodes': 10 +} +classifier.fit(data_set, parameters=parameters) +``` + +The same process can be done using the `RandomForestRegressor` from sklearn and an `EMGRegressor`. Please reference the [sklearn docs](https://scikit-learn.org/stable/) for parameter options for each model. + +Additionally, custom models can be created. Any custom classifier should be modeled after the `sklearn` classifiers and must have the `fit`, `predict`, and `predict_proba` functions to work correctly. Any custom regressor should be modeled after the `sklearn` regressors and must have the `fit` and `predict` methods. + +```Python +from sklearn.ensemble import RandomForestClassifier +from libemg.predictor import EMGClassifier + +rf_custom_classifier = RandomForestClassifier(max_depth=5, random_state=0) +classifier = EMGClassifier(rf_custom_classifier) +classifier.fit(data_set) +``` + +## Deep Learning (Pytorch) + +Another available option is to use [pytorch](https://pytorch.org/) models (i.e., a library for deep learning) to train the model, although this involves making some custom code for preparing the dataset and the deep learning model. For a guide on how to use deep learning models, consult the deep learning example. The same methods are expected to be implemented for both deep and statistical classifiers/regressors. + +## Online Prediction + +`OnlineEMGClassifier` and `OnlineEMGRegressor` wrap a trained model and run it against live data in their own process. They are event driven. A write to the shared memory item a predictor consumes announces itself, the predictor is woken, and it then decides whether enough new samples have accrued to form a window. It no longer copies and filters the whole buffer to find that out. + +None of this shows up in your code. The constructor arguments, the streamed output, the installed filters and the post-processing options are all unchanged. What changes is the cost of running a predictor: + +| Measurement | Value | +| ------------------ | ----------- | +| Sampling rate | 1000 Hz | +| Channels | 8 | +| Window size | 200 | +| Window increment | 50 | +| CPU used by the streaming process, original polling loop | 93.7% | +| CPU used by the streaming process, event-driven loop | 10.7% | +| Latency, last sample of a window to a prediction on the wire | 0.57 ms mean | + +Those runs produce the same predictions, with a bandpass and a notch filter installed on the data handler. + +One case keeps the original loop. If you have replaced `window_trigger_function_handle` with your own predicate, that loop is used automatically, because an arbitrary predicate cannot be restated as a per-item criterion. Setting `classifier.reactive = False` selects the original loop explicitly. + +For the mechanism behind this, and for hooking your own stages into the same notifications, see the Reactive Pipelines section. diff --git a/docs/source/documentation/classification/random_forest.png b/docs/source/documentation/prediction/random_forest.png similarity index 100% rename from docs/source/documentation/classification/random_forest.png rename to docs/source/documentation/prediction/random_forest.png diff --git a/docs/source/documentation/prediction/regression_doc.md b/docs/source/documentation/prediction/regression_doc.md new file mode 100644 index 00000000..3ffe4da5 --- /dev/null +++ b/docs/source/documentation/prediction/regression_doc.md @@ -0,0 +1,58 @@ +# Regressors + +Below is a list of the regressors that can be instatiated by passing in a string to the `EMGRegressor`. For other regressors, pass in a custom model that has the `fit` and `predict` methods. + +## Linear Regression (LR) + +A linear regressor that aims to minimize the residual sum of squares between the predicted values and the true targets. + +```Python +regressor = EMGRegressor('LR') +regressor.fit(data_set) +``` + +Check out the LR docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.html) + +## Support Vector Machines (SVM) + +A regressor that uses a kernel trick to find the hyperplane that best fits the data. + +```Python +regressor = EMGRegressor('SVM') +regressor.fit(data_set) +``` + +Check out the SVM docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.svm.SVR.html) + +## Artificial Neural Networks (MLP) + +A deep learning technique that uses human-like "neurons" to model data to help discriminate between inputs. Especially for this model, we **highly** recommend you create your own. + +```Python +regressor = EMGRegressor('MLP') +regressor.fit(data_set) +``` + +Check out the MLP docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.neural_network.MLPRegressor.html) + +## Random Forest (RF) + +Uses a combination of decision trees to discriminate between inputs. + +```Python +regressor = EMGRegressor('RF') +regressor.fit(data_set) +``` + +Check out the RF docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html) + +## Gradient Boosting (GB) + +Additive model that fits a regression tree on the negative gradient of the loss function. + +```Python +regressor = EMGRegressor('GB') +regressor.fit(data_set) +``` + +Check out the GB docs [here.](https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.GradientBoostingRegressor.html) diff --git a/docs/source/documentation/prediction/regression_post_processing.png b/docs/source/documentation/prediction/regression_post_processing.png new file mode 100644 index 00000000..8286a2df Binary files /dev/null and b/docs/source/documentation/prediction/regression_post_processing.png differ diff --git a/docs/source/documentation/reactive/reactive.rst b/docs/source/documentation/reactive/reactive.rst new file mode 100644 index 00000000..fbd90e1d --- /dev/null +++ b/docs/source/documentation/reactive/reactive.rst @@ -0,0 +1,4 @@ +Reactive Pipelines +------------------------------ +.. include:: reactive_doc.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/documentation/reactive/reactive_doc.md b/docs/source/documentation/reactive/reactive_doc.md new file mode 100644 index 00000000..b91b0433 --- /dev/null +++ b/docs/source/documentation/reactive/reactive_doc.md @@ -0,0 +1,236 @@ +Online processing in LibEMG used to work by asking. A classifier sat in a loop asking "is there a window yet?", and asking was expensive: each question copied an entire shared-memory buffer and, if a filter was installed, ran that filter over all of it, only to read a single counter and throw the data away. Several consumers meant several such loops, each independently re-deriving the same filtered signal and the same features, with nowhere to put a result another process could use. + +The reactive layer replaces asking with being told. A write to a shared-memory item announces itself, and anything hooked into that item is notified and decides for itself whether the change matters. + +The change is measurable. Streaming 1000 Hz of 8-channel data through a classifier with a bandpass and notch filter installed, window 200 and increment 50: + +| Streaming loop | CPU used by the streaming process | +| --- | --- | +| Polling, the original loop | 93.7% | +| Reactive, hooked | 10.7% | + +Same predictions, 8.8 times less CPU. End-to-end latency from the final sample of a window to a prediction on the wire measures 0.57 ms on average. + +**Existing code does not need to change.** `OnlineEMGClassifier`, `OnlineEMGRegressor` and `OnlineDataHandler` keep their signatures and their behaviour. The streaming loop switched underneath them. + +# Why dirtiness cannot be a flag on the item + +The obvious design is to mark an item "dirty" when it changes and "clean" when somebody handles it. That breaks as soon as an item has more than one observer, because whichever observer clears the flag starves the rest. + +It fails for a second and more interesting reason: observers legitimately disagree about what counts as a change. + +- A **filter** wants to run on a single new sample. One sample in, one filtered sample out. +- A **windowing** stage does not care until a full window increment has accrued. Forty-nine new samples are not a window. +- A **live plot** wants thirty updates a second no matter how fast samples arrive. Anything more is invisible. + +Given the same item in the same state, those three answer "has it changed enough to act?" differently. So the item does not hold an opinion. It publishes facts, and each observer pairs those facts with its own criterion and its own memory of what it last consumed. + +- **Propagation** is the notification, and it goes to everybody hooked in. +- **Dirtiness** is a per-observer judgement made against shared facts. + +# What a stateful item publishes + +Every shared-memory item now carries a small block of counters alongside its buffer, in its own segment. Reading it costs a handful of integers rather than a copy of the buffer, which is what makes it affordable to consult often. + +| Fact | Meaning | +| --- | --- | +| `generation` | Writes since the item was created. Unchanged means nothing happened. | +| `total_samples` | Rows ever committed. What a volume-based criterion compares against. | +| `epoch` | Bumped on reset, so a reader can tell a reset from a long silence. | +| `closed` | The writer has finished. This is how a finite pipeline knows to stop. | +| `commits`, `dropped` | Diagnostics, including rows overwritten before anyone read them. | +| `t_last_ns` | When the newest write landed, for latency accounting. | + +Read it through the data handler: + +```Python +state = odh.get_state('emg') +print(state.total_samples, state.generation, state.closed) +``` + +Because the block lives in shared memory and the wake-up is a synchronisation primitive shared between processes, an observer does not have to live in the process that did the writing. + +# Criteria + +A criterion is an observer's own definition of a meaningful change. + +| Criterion | Dirty when | Suits | +| --- | --- | --- | +| `OnCommit()` | Any write at all | Filters, loggers | +| `OnSamples(n)` | `n` new samples have accrued | Windowing | +| `AfterSamples(n)` | `n` accrued, then skip the backlog | Models that must not fall behind | +| `Periodic(hz)` | At most `hz` times a second, and only if something changed | Plots, probes | +| `WhenClosed()` | The writer declares itself finished | Summarising a finite run | +| `Always()` | Whenever asked | Heartbeats | +| `AllOf(...)`, `AnyOf(...)` | Combinations | Multimodal inputs | +| `Custom(fn, name)` | Whatever you write | Anything else | + +`OnSamples` and `AfterSamples` share a threshold and differ in what they do about a backlog. `OnSamples` advances by exactly its increment on each firing, so a burst that delivers three increments at once produces three firings and window boundaries stay evenly spaced. `AfterSamples` jumps to the newest total, so the same burst produces one firing on the latest data. Use the first when every window matters and the second when freshness matters more than completeness. + +Writing your own takes one method: + +```Python +from libemg.reactive import Criterion + +class OnEnergy(Criterion): + """Dirty once the accumulated signal energy passes a threshold.""" + def __init__(self, threshold): + self.threshold = threshold + + def is_dirty(self, snapshot, memory): + return snapshot.total_samples - memory.samples >= self.threshold + + def describe(self): + return f'OnEnergy({self.threshold})' +``` + +`is_dirty` reads the item's published facts and the observer's memory and returns a judgement without mutating either. `describe` names the criterion in the event log so a recorded decision can be read back and understood. + +# Hooks + +A hook declares what it observes and what it writes. The runtime does the attaching, the locking and the committing, so a hook never takes a lock and never commits. That is what makes one testable on its own: build the input dict by hand, call `step`, and check what comes back. + +Here is one written from scratch. `RmsHook` is not part of LibEMG; it is an example of the shape your own hooks take, and it is reused further down to show how a hook joins a pipeline. + +```Python +from libemg.reactive import Hook, Input, Output, OnSamples, WINDOW +import numpy as np + +class RmsHook(Hook): + def __init__(self): + super().__init__( + 'rms', + inputs=[Input('filtered_emg', OnSamples(50), mode=WINDOW, size=200)], + outputs=[Output('rms', (100, 8), np.double)], + ) + + def step(self, data, snapshots): + window = data['filtered_emg'] + return {'rms': np.sqrt(np.mean(window ** 2, axis=0, keepdims=True))} +``` + +An input's `mode` says how the data should arrive: `WINDOW` for the newest `size` rows oldest-first, `DELTA` for everything since this hook last consumed, `LATEST` for one row, `FULL` for the whole buffer newest-first as `get_data` returns it, and `STATE` for no data at all when only the counters matter. + +Anything a hook cannot pickle, such as a model or a socket, is built in `setup` rather than in `__init__`, because on Windows the executor is a spawned process and the hook has to reach it. + +Built-in hooks cover the common stages: `FilterHook`, `FeatureHook`, `ProbeHook`, and `CallbackHook` for wrapping a plain function. + +# Incremental learning + +Adapting a model while a person is using it is built on these same primitives. `libemg.adaptation.hooks` adds three more: `MemoryHook` assembles training data from what the environment judged, `AdaptationHook` folds a finished slice into the model, and `ModelSwapHook` reacts to newly adapted weights. They are a worked example of the argument above, since the three stages watch one chain of items and disagree about what counts as a change. The loop they build is documented in the [online adaptation guide](../adaptation/adaptation_doc.md). + +# The cascade + +A hook's output is itself a stateful item, so committing to it notifies that item's observers in turn. That is the whole mechanism: `emg` to `filtered_emg` to `features` to `predictions` needs no coordinating loop, because each stage wakes the next. + +```Python +from libemg.reactive import ReactiveGraph, FilterHook + +graph = ReactiveGraph(shared_memory_items) +graph.add(FilterHook('filter', 'emg', 'filtered_emg', fi=fi, shape=(2000, 8))) +graph.add(RmsHook(), executor='features') # the hook defined in the section above +graph.start() +... +graph.stop() +``` + +Two consequences are worth being explicit about, because they are what the old design could not express. + +**An item may have any number of observers.** Filtering happens once and every consumer reads the result, instead of each consumer filtering the same samples again. + +**A model may hook whichever stage it wants.** Because what a hook observes is declared rather than hard-wired, subscribing a model to `features` and subscribing it to raw `emg` are the same code path. A model that takes raw windows and one that takes features differ by one line. + +Hooks are grouped into executors, one process each. Hooks in the same executor share a wake-up and run in sequence, which is what you want for stages that are individually cheap. A hook that is expensive, or that must not be delayed by its neighbours, belongs in its own executor. + +# Processes and threads + +A process executor reaches its process by pickling its hooks, which has two consequences worth knowing before you hit them. + +A hook that closes over a lambda or a local function cannot be pickled. Neither can one holding a live handle such as an open socket or a plotting window. Register such a hook with `in_process=True` and it runs as a thread in the process that built the graph instead: + +```Python +graph.add(ProbeHook('scope', 'emg', lambda data, snaps: plot(data)), + executor='scope', in_process=True) +``` + +This is also the answer for a probe that draws. A GUI toolkit will not accept calls from another process, so anything touching a window has to run where the window lives. + +The trade is the interpreter lock: a threaded hook shares this process's, so heavy numeric work belongs in a process executor. Numpy releases the lock for the arithmetic itself, so probes and light transforms are usually fine as threads. + +The graph refuses to start rather than misbehave later. Two hooks writing the same item, a hook observing an item nothing declares or produces, a duplicate hook name and a cycle are all rejected before any process spawns. So is a hook that cannot be pickled, with a message naming the hook and the three ways out, because the alternative is a bare pickling error raised from inside multiprocessing about an anonymous function. + +# How a graph over stored data finishes + +A writer that runs out of data calls `mark_closed`. Observers see `closed` on the item's state block, and an executor whose watched items are all closed with no criterion still dirty flushes and exits. Without that, a pipeline over a recording could only be stopped from outside. + +# The debug log + +The reactive layer records every decision it makes, which matters because the failure you hit is usually "why did this not fire?" or "why did it fire that often?" + +```Python +from libemg.event_log import EventLog + +log = EventLog(path='reactive.log') +graph = ReactiveGraph(shared_memory_items, log=log) +``` + +Events carry a wall-clock timestamp, the emitting process, the item that changed, the observer concerned, the criterion applied and the outcome. Kinds are `commit`, `notify`, `evaluate`, `dirty`, `clean`, `invoke`, `complete`, `drop`, `error` and `lifecycle`. + +Recorded lines look like this, and the pair below is the design's central claim in evidence. The same item, at the same instant, judged differently by two observers: + +``` +1789068702.012910 pid=44620 clean origin=doubled observer=windowmean criterion=OnSamples(25) generation=1 total_samples=1 +1789068702.017528 pid=46664 dirty origin=emg observer=doubler criterion=OnCommit() generation=3 total_samples=3 +``` + +`summary()` gives the counts that tell you whether a criterion is set sensibly, since a criterion that is always dirty or never dirty is usually a mistake: + +```Python +log.stop() +print(log.summary()) +# {'kinds': {...}, +# 'invocations': {'doubler': 200, 'windowmean': 8}, +# 'dirty': {'doubler': 200, 'windowmean': 8}, +# 'clean': {'windowmean': 211, 'doubler': 211}} +``` + +Logging is cheap to leave on. An event is a small tuple pushed onto a queue by the process that observed it, and one drain thread in the owning process does all the formatting and file writing. Pass `kinds=` to filter in the emitting process so excluded events cost nothing beyond the check. With no log installed, a null object absorbs every call. + +An online streamer takes a log the same way: + +```Python +classifier.install_event_log(log) +``` + +# What changed inside the library + +**Online streamers no longer poll.** `OnlineStreamer._run_helper` was an unthrottled loop asking `window_trigger_function_handle` whether a window was ready, and that question copied and filtered the whole buffer. It now subscribes to the items it consumes and blocks until it is woken, then applies `OnSamples(window_increment)` to decide whether to run. Everything else is untouched: the same startup, window, prediction and postprocessing handles run in the same order in the same separate process. + +If you have replaced `window_trigger_function_handle` with your own predicate, the original loop is used automatically, because an arbitrary predicate cannot be restated as a per-item criterion. Setting `classifier.reactive = False` selects it explicitly. + +**Writers commit rather than read-modify-write.** Streamers used to prepend samples with `modify_variable` and a whole-buffer `vstack`, then update the counter in a second locked call. `commit` does both under one lock acquisition, advances the state block and wakes subscribers. Taking the lock once also closes a real gap: writing the buffer and its counter separately let a reader observe a count running ahead of the data it had just copied. + +**A reset now moves the state too.** `OnlineDataHandler.reset` zeroes the counters and bumps the epoch. An observer that missed a reset would otherwise compare against a total that had gone backwards and conclude nothing had arrived. + +**The old read API still works, unchanged.** `get_data`, `get_variable`, `get_variables`, `get_samples_since` and `modify_variable` behave exactly as before, and the newest-first buffer layout is preserved. The state block is additive. + +# Notification across processes + +Each executor owns one wake-up slot and one blocking wait. A writer signals the slots subscribed to the item it just wrote, which it finds in that item's state block, so a writer that started before an observer existed still reaches it. + +Slots come from a `NotifierPool` created up front, because a synchronisation primitive cannot be looked up by name after the fact on every platform LibEMG supports. Everything built in one process shares one pool via `default_notifier_pool()`, which is how a streamer started early in a script can wake a classifier constructed later in the same script. + +A writer that never obtains the pool still works correctly. Its commits update the state block, and observers fall back to re-reading that block at their fallback interval, which defaults to 2 ms. That fallback reads a few integers instead of copying and filtering a buffer, so it is still orders of magnitude cheaper than the loop it replaces; holding the pool turns a cheap check into no check at all. The fallback is also the safety net that keeps a lost notification from becoming a hang. + +# Hooking a live stream directly + +For one or two observers on a live stream, the data handler is enough and you do not need to build a graph: + +```Python +from libemg.reactive import ProbeHook + +odh.install_hook(ProbeHook('watch', 'emg', print, hz=5)) +odh.start_hooks() +... +odh.stop_hooks() +``` diff --git a/docs/source/documentation/supported_hardware/supported_hardware_doc.md b/docs/source/documentation/supported_hardware/supported_hardware_doc.md index 77c338d8..75d99001 100644 --- a/docs/source/documentation/supported_hardware/supported_hardware_doc.md +++ b/docs/source/documentation/supported_hardware/supported_hardware_doc.md @@ -23,7 +23,7 @@ By default, LibEMG supports several hardware devices (shown in Table 1). - The [**Delsys**](https://delsys.com/) is a commercially available system primarily used for medical applications due to its relatively high cost. - The [**SIFI Cuff**](https://sifilabs.com/) is a pre-released device that will soon be commercially available. Compared to the Myo armband, this device has a much higher sampling rate (~2000 Hz). - The [**Oymotion Cuff**](http://www.oymotion.com/en/product32/149) is a commercial device that samples EMG at 1000 Hz (8 bits) or 500 Hz (12 bits). -- The [**OTBioelettronica**](https://otbioelettronica.it/hardware/) devices are a set of commercially available HDEMG systems. + If selecting EMG hardware for real-time use, wireless armbands that sample above 500 Hz are preferred. Additionally, future iterations of LibEMG will include Inertial Measurement Unit (IMU) support. As such, devices should have IMUs to enable more interaction opportunities. @@ -33,9 +33,9 @@ If selecting EMG hardware for real-time use, wireless armbands that sample above | Delsys | `delsys_streamer()` or `delsys_API_streamer()` |
![](devices/delsys_trigno.png)
| | SIFI Cuff | `sifi_streamer()` |
![](devices/sifi_cuff.png)
| | Oymotion | `oymotion_streamer()`|
![](devices/oymotion.png)
| -| Muovi | `otb_muovi_streamer()`|
![](devices/muovi.png)
| +

Table 1: The list of all implemented streamers.

@@ -71,6 +71,8 @@ Repeating Values: 0 ## Creating Custom Streamers Custom UDP streamers can be created to interface with other hardware. A UDP streamer reads a value from a device, pickles it, and sends it over UDP. An example streamer for the Myo Armband is shown in the code snippet below. +The default streamers write into shared memory with `SharedMemoryManager.commit`. One call stores the new samples, advances the item's state block and wakes anything hooked into that item, all under a single acquisition of the item's lock. A custom streamer that writes the same way gets those notifications for free, and the online classifier and any installed hooks pick its data up without further work. See the Reactive Pipelines section for what is being notified. +
Example Code diff --git a/docs/source/documentation/visualization/heatmap.gif b/docs/source/documentation/visualization/heatmap.gif new file mode 100644 index 00000000..27079e0e Binary files /dev/null and b/docs/source/documentation/visualization/heatmap.gif differ diff --git a/docs/source/documentation/visualization/regressor.png b/docs/source/documentation/visualization/regressor.png new file mode 100644 index 00000000..4880becd Binary files /dev/null and b/docs/source/documentation/visualization/regressor.png differ diff --git a/docs/source/documentation/visualization/visualization_doc.md b/docs/source/documentation/visualization/visualization_doc.md index 0f7d2061..e45c7a42 100644 --- a/docs/source/documentation/visualization/visualization_doc.md +++ b/docs/source/documentation/visualization/visualization_doc.md @@ -16,6 +16,19 @@ if __name__ == "__main__": | ![alt text](all_channels.gif) | ![alt text](multi_channel.gif) |

Figure 1: Raw Data from the OnlineDataHandler

+These plots redraw on a timer, and each frame copies the whole buffer out of shared memory. For a cheaper look at live data, install a probe hook instead. A `ProbeHook` carries a `Periodic(hz)` criterion and declares no outputs, so it is woken at most `hz` times a second and nothing downstream waits on it. A probe cannot stall or alter the pipeline it watches, which makes it the safe way to observe a running control system. + +```Python +from libemg.reactive import ProbeHook + +odh.install_hook(ProbeHook('watch', 'emg', print, hz=30)) +odh.start_hooks() +... +odh.stop_hooks() +``` + +Pass your own function in place of `print` to draw, log, or forward the samples. See the Reactive Pipelines section for the other criteria and hooks. + # EMG Classifier The EMG classifier contains a visualization tool for viewing the decisions stream (i.e., the predictions over time) for a particular classifier using the `visualize` function. @@ -23,16 +36,30 @@ The EMG classifier contains a visualization tool for viewing the decisions strea ![alt text](decision_stream.png)

Figure 2: The decision stream of a classifier.

+# EMG Regressor + +The EMG regressor also contains a visualization tool for viewing the model's decision stream. Similar to the classifier, you can view the decision stream using the `visualize` method. + +![alt text](regressor.png) +

Figure 3: The decision stream of a regressor.

+ # Feature Extractor -The Feature Extrator and Online Data Handler contain a visualization tool for viewing the PCA feature space. This can be done using the `visualize_feature_space` function. If this function is run on an online data handler, a live PCA feature space will be shown (see Figure 3). +The Feature Extrator and Online Data Handler contain a visualization tool for viewing the PCA feature space. This can be done using the `visualize_feature_space` function. If this function is run on an online data handler, a live PCA feature space will be shown (see Figure 4). |
Offline
|
Online (Live)
| | ------------- | ------------- | | ![alt text](feature_space.png) | ![alt text](feature_space.gif) | -

Figure 3: The PCA feature space of a set of data.

+

Figure 4: The PCA feature space of a set of data.

# Filtering -The filtering module has a `visualize_effect` function that demonstrates the effect of a filter on a set of data in the time and frequency domain. +The filtering module has a `visualize_effect` function that demonstrates the effect of a filter on a set of data in the time and frequency domain. ![](filtering_1.png) -

Figure 4: Data before and after filtering in the time and frequency domain.

\ No newline at end of file +

Figure 5: Data before and after filtering in the time and frequency domain.

+ +# Heatmap + +Viewing EMG as a time series may not be appropriate for high-density EMG systems. `LibEMG` offers a live heatmap visualization using the `visualize_heatmap` method. Heatmaps of multiple features can be visualized in real-time to show spatial information (only features that produce a single value per window are supported). + +![alt text](heatmap.gif) +

Figure 6: Real-time heatmap visualization.

diff --git a/docs/source/emg_toolbox.rst b/docs/source/emg_toolbox.rst index 7d3c6fe3..45bf146b 100644 --- a/docs/source/emg_toolbox.rst +++ b/docs/source/emg_toolbox.rst @@ -3,6 +3,12 @@ Data Handler .. automodule:: libemg.data_handler :members: +Datasets +------------------------------ +.. automodule:: libemg.datasets + :members: + + Filtering ------------------------------ .. automodule:: libemg.filtering @@ -18,16 +24,42 @@ Feature Selection .. automodule:: libemg.feature_selector :members: -Classification +EMG Prediction ------------------------------ .. automodule:: libemg.emg_predictor :members: +Adaptation +------------------------------ +.. automodule:: libemg.adaptation.managers + :members: + +.. automodule:: libemg.adaptation.memory + :members: + +.. automodule:: libemg.adaptation.hooks + :members: + Offline Evaluation Metrics ------------------------------ .. automodule:: libemg.offline_metrics :members: +Reactive Pipelines +------------------------------ +.. automodule:: libemg.reactive + :members: + +Shared Memory +------------------------------ +.. automodule:: libemg.shared_memory_manager + :members: + +Event Log +------------------------------ +.. automodule:: libemg.event_log + :members: + Utils ------------------------------ .. automodule:: libemg.utils @@ -42,3 +74,37 @@ Screen Guided Training ------------------------------ .. automodule:: libemg.gui :members: + +Streamer Panel +------------------------------ +.. automodule:: libemg._gui._streamer_panel + :members: + +Pipeline Editor +------------------------------ +.. automodule:: libemg._gui._pipeline.registry + :members: + +.. automodule:: libemg._gui._pipeline.document + :members: + +.. automodule:: libemg._gui._pipeline.compile + :members: + +.. automodule:: libemg._gui._pipeline.synthetic + :members: + +Environments In The GUI +------------------------------ +.. automodule:: libemg._gui._environments.registry + :members: + :no-index: + +.. automodule:: libemg._gui._environments.frame_bridge + :members: + +.. automodule:: libemg._gui._environments.embedded + :members: + +.. automodule:: libemg._gui._environments.factories + :members: diff --git a/docs/source/examples/fitts_example/fitts.md b/docs/source/examples/fitts_example/fitts.md index 6da5927c..d76dae3d 100644 --- a/docs/source/examples/fitts_example/fitts.md +++ b/docs/source/examples/fitts_example/fitts.md @@ -9,74 +9,139 @@ } -For EMG-based control systems, it has been shown that the offline performance of a system (i.e., classification accuracy) does not necessarily correlate to online usability. In this example, we introduce an Iso Fitts test for assessing the online performance of continuous EMG-based control systems. While this test evaluates systems with 2DOFs that leverage continuous constant control, it could be extended to more complicated systems such as proportional control with discrete inputs. +For EMG-based control systems, it has been shown that the offline performance of a system (i.e., classification accuracy, mean absolute error) does not necessarily correlate to online usability. In this example, we introduce an Iso Fitts test for assessing the online performance of continuous EMG-based control systems. +Different types of models, such as regressors and classifiers, cannot be easily compared offline since different metrics are calculated. Online tests allow us to compare these distinct model types and assess a model's ability to perform a task with a user in the loop. # Methods -This example acts as a mini experiment that you can try out on yourself or a friend where the offline and online performance of four popular classifiers (**LDA, SVM, RF,** and **KNN (k=5**)) are compared. +This example acts as a mini experiment that you can try out on yourself or a friend where the offline and online performance of four popular classifiers (**LDA, SVM, RF,** and **KNN (k=5**)) and two regressors (**LR and SVM**) are compared. The steps of this 'mini experiment' are as follows: -1. **Accumulate 5 repetitions of five contractions (no movement, flexion, extension, hand open, and hand closed).** These classes correspond to movement in the isofitts task (do nothing, and move left, right, up, and down). -
- - - -
-2. **Train and evaluate four classifiers in an offline setting (LDA, SVM, KNN (k=5), and RF).** For this step, the first three reps are used for training and the last two for testing. +1. **Accumulate 3 repetitions of five contractions (no movement, flexion, extension, hand open, and hand closed).** These classes correspond to movement in the isofitts task (do nothing, and move left, right, up, and down). + + + + + + + + + + +
Image 1Image 2
Image 3Image 4
+ +2. **Train and evaluate four classifiers in an offline setting (LDA, SVM, KNN (k=5), and RF).** For this step, the first 2 reps are used for training and the last for testing. 3. **Perform an Iso Fitts test to evaluate the online usability of each classifier trained in step 2.** These fitts law tests are useful for computing throughput, overshoots, and efficiency. Ultimately, these metrics provide an indication of the online usability of a model. The Iso Fitts test is useful for myoelectric control systems as it requires changes in degrees of freedom to complete sucessfully. - +4. **Repeat steps 1-3 using regressors instead of classifiers.** Select 'regression' from the radio buttons and redo data collection. You will now be shown a video of a point moving through a cartesian plane, which indicates the position along each DOF. Follow the point in real-time to provide the regressor with continuously-labelled training data (as opposed to classes in classification). This video will be repeated 3 times (i.e., 3 repetitions). Note that you can now perform simultaneous contractions (i.e., move the cursor along the diagonal) when using a regressor. -**Note:** We have made this example to work with the `Myo Armband`. However, it can easily be used for any hardware by simply switching the `streamer`, `WINDOW_SIZE`, and `INCREMENT`. +**Note:** We have made this example to work with the `Myo Armband`. However, it can easily be used for any hardware by simply switching the `streamer`, `WINDOW_SIZE`, and `WINDOW_INCREMENT`. # Menu ```Python from libemg.streamers import myo_streamer from libemg.gui import GUI -from libemg.data_handler import OnlineDataHandler, OfflineDataHandler, RegexFilter -from libemg.utils import make_regex +from libemg.data_handler import OnlineDataHandler, OfflineDataHandler, RegexFilter, FilePackager from libemg.feature_extractor import FeatureExtractor -from libemg.emg_predictor import OnlineEMGClassifier, EMGClassifier +from libemg.emg_predictor import OnlineEMGClassifier, EMGClassifier, EMGRegressor, OnlineEMGRegressor +from libemg.environments.isofitts import IsoFitts +from libemg.environments.controllers import ClassifierController, RegressorController +from libemg.animator import ScatterPlotAnimator ``` -Similarly to previous examples, we decided to create a simple menu to (1) leverage the training module and (2) enable the use of different classifiers. To do this, we have included two buttons in `menu.py`. When the "accumulate training data button" is clicked, we leverage the training UI module. For this example, we want five reps (3 training - 2 testing), and we point it to the "classes" folder as it contains images for each class. +Similarly to previous examples, we decided to create a simple menu to (1) leverage the training module and (2) enable the use of different models. To do this, we have included two buttons in `menu.py`. When the "accumulate training data button" is clicked, we leverage the training UI module. For this example, we want 3 reps (2 training - 1 testing), and we point it to the "images" folder as it contains images for each class. To instead evaluate a regressor, simply select the 'Regression' radio button and collect the required data. Note that launching training for regression will create a `collection.mp4` file using the `Animator` class. ```Python def launch_training(self): self.window.destroy() - training_ui = GUI(self.odh, width=700, height=700, gesture_height=300, gesture_width=300) + if self.regression_selected(): + args = {'media_folder': 'animation/', 'data_folder': Path('data', 'regression').absolute().as_posix(), 'rep_time': 50} + else: + args = {'media_folder': 'images/', 'data_folder': Path('data', 'classification').absolute().as_posix()} + training_ui = GUI(self.odh, args=args, width=700, height=700, gesture_height=300, gesture_width=300) training_ui.download_gestures([1,2,3,4,5], "images/") + self.create_animation() training_ui.start_gui() + self.initialize_ui() + +def create_animation(self): + output_filepath = Path('animation', 'collection.mp4').absolute() + if not self.regression_selected() or output_filepath.exists(): + return + + print('Creating regression training animation...') + period = 2 # period of sinusoid (seconds) + cycles = 10 + rest_time = 5 # (seconds) + fps = 24 + + coordinates = [] + total_duration = int(cycles * period + rest_time) + t = np.linspace(0, total_duration - rest_time, fps * (total_duration - rest_time)) + coordinates.append(np.sin(2 * np.pi * (1 / period) * t)) # add sinusoids + coordinates.append(np.zeros(fps * rest_time)) # add rest time + + # Convert into 2D (N x M) array with isolated sinusoids per DOF + coordinates = np.expand_dims(np.concatenate(coordinates, axis=0), axis=1) + dof1 = np.hstack((coordinates, np.zeros_like(coordinates))) + dof2 = np.hstack((np.zeros_like(coordinates), coordinates)) + coordinates = np.vstack((dof1, dof2)) + + axis_images = { + 'N': PILImage.open(Path('images', 'Hand_Open.png')), + 'S': PILImage.open(Path('images', 'Hand_Close.png')), + 'E': PILImage.open(Path('images', 'Wrist_Extension.png')), + 'W': PILImage.open(Path('images', 'Wrist_Flexion.png')) + } + animator = ScatterPlotAnimator(output_filepath=output_filepath.as_posix(), show_direction=True, show_countdown=True, axis_images=axis_images) + animator.save_plot_video(coordinates, title='Regression Training', save_coordinates=True, verbose=True) ``` -The next button option involves starting the Iso Fitts task. This occurs after the training data has been recorded. Note that in this step we create the online classifier and start the Fitts law test. We opted for 8 circles, but this can be varied easily with the constructor. +The next button option involves starting the Iso Fitts task. This occurs after the training data has been recorded. We opted for 8 circles in this task, but this can be varied easily with the constructor. Note that in this step we create the online model and start the Fitts law test, so please ensure that you have changed the text box to select the desired model type. If you are performing classification, recommended options are 'LDA', 'SVM', 'KNN', and 'RF'. If you are performing regression, recommended options are 'LR' and 'RF'. To see a full list of available options for classifiers and regressors in `LibEMG`, check out the `EMGClassifier` and `EMGRegressor` in the [source code](https://github.com/LibEMG/libemg). ```Python def start_test(self): self.window.destroy() - self.set_up_classifier() - FittsLawTest(num_trials=8, num_circles=8, savefile=self.model_str.get() + ".pkl").run() - # Its important to stop the classifier after the game has ended + self.set_up_model() + if self.regression_selected(): + controller = RegressorController() + save_file = Path('results', self.model_str.get() + '_reg' + ".pkl").absolute().as_posix() + else: + controller = ClassifierController(output_format=self.model.output_format, num_classes=5) + save_file = Path('results', self.model_str.get() + '_clf' + ".pkl").absolute().as_posix() + IsoFitts(controller, num_trials=8, num_circles=8, save_file=save_file).run() + # Its important to stop the model after the game has ended # Otherwise it will continuously run in a seperate process - self.classifier.stop_running() + self.model.stop_running() self.initialize_ui() ``` -Now, let's break this piece of code up. First, let's explore the `self.set_up_classifier()` function call. This step involves parsing the offline training data using the `OfflineDataHandler`. The file format for this example is R_<#>_C_<#>.csv. So to extract the reps the left bound is `R_` and the right bound is `_C_`. Similarly, to extract the classes, the left bound is `C_` and the right bound is `.csv`. Additionally, there are three training reps and five classes. Once we extract all this information, we create the `OfflineDataHandler` and extract the `train_windows` and `train_metadata` variables. +Now, let's break this piece of code up. First, let's explore the `self.set_up_model()` function call. This step involves parsing the offline training data using the `OfflineDataHandler`. The file format for this example is R_<#>_C_<#>.csv. So to extract the reps the left bound is `R_` and the right bound is `_C_`. Similarly, to extract the classes, the left bound is `C_` and the right bound is `.csv`. Additionally, there are three training reps and five classes. For regression, the labels are extracted from a separate text file instead of from the filename. The `collection.txt` file is used to create the labels for each data file via a `FilePackager`. Once we extract all this information, we create the `OfflineDataHandler` and extract the `train_windows` and `train_metadata` variables. ```Python # Step 1: Parse offline training data -dataset_folder = 'data/' -classes_values = ["0","1","2","3","4"] -reps_values = ["0", "1", "2"] -regex_filters = [ - RegexFilter(left_bound = "_C_", right_bound=".csv", values = classes_values, description='classes'), - RegexFilter(left_bound = "R_", right_bound="_C_", values = reps_values, description='reps') -] +if self.regression_selected(): + regex_filters = [ + RegexFilter(left_bound='regression/C_0_R_', right_bound='_emg.csv', values=['0', '1', '2'], description='reps') + ] + metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='animation/', right_bound='.txt', values=['collection'], description='labels'), package_function=lambda x, y: True) + ] + labels_key = 'labels' + metadata_operations = {'labels': 'last_sample'} +else: + regex_filters = [ + RegexFilter(left_bound = "classification/C_", right_bound="_R", values = ["0","1","2","3","4"], description='classes'), + RegexFilter(left_bound = "R_", right_bound="_emg.csv", values = ["0", "1", "2"], description='reps'), + ] + metadata_fetchers = None + labels_key = 'classes' + metadata_operations = None + odh = OfflineDataHandler() -odh.get_data(folder_location=dataset_folder, regex_filters=regex_filters, delimiter=",") -train_windows, train_metadata = odh.parse_windows(WINDOW_SIZE, WINDOW_INCREMENT) +odh.get_data('./', regex_filters, metadata_fetchers=metadata_fetchers, delimiter=",") +train_windows, train_metadata = odh.parse_windows(WINDOW_SIZE, WINDOW_INCREMENT, metadata_operations=metadata_operations) ``` -The next step involves extracting features from the training data. To do this we leverage the `FeatureExtractor` module. In this example, we use the `Low Sampling 4 (LS4)` feature set as it is a robust group for low sampling rate devices such as the Myo. +The next step involves extracting features from the training data. To do this we leverage the `FeatureExtractor` module. In this example, we use the `Low Sampling 4 (LS4)` feature set as it is a robust group for low sampling rate devices such as the Myo. ```Python # Step 2: Extract features from offline data @@ -91,96 +156,99 @@ We then split the training features and labels into a dataset dictionary for the # Step 3: Dataset creation data_set = {} data_set['training_features'] = training_features -data_set['training_labels'] = train_metadata['classes'] +data_set['training_labels'] = train_metadata[labels_key] ``` -Finally, we create the `EMGClassifier` and the `OnlineEMGClassifier` using the default options. Notice that when creating the classifier, we pass in the text from the menu text field. This enables the user to pass in `LDA`, `SVM`, etc. with ease. Once the classifier is created, the `.run()` function is called and predictions begin. +Finally, we create the offline and online models using the default options. Notice that when creating the model, we pass in the text from the menu text field. This enables the user to pass in `LDA`, `SVM`, etc. with ease. Once the model is created, the `.run()` function is called and predictions begin. ```Python -# Step 4: Create the EMG Classifier -o_classifier = EMGClassifier(self.model_str.get()) -o_classifier.fit(feature_dictionary=data_set) - -# Step 5: Create online EMG classifier and start classifying. -self.classifier = OnlineEMGClassifier(o_classifier, WINDOW_SIZE, WINDOW_INCREMENT, self.odh, feature_list) -self.classifier.run(block=False) # block set to false so it will run in a seperate process. +# Step 4: Create the EMG model +model = self.model_str.get() +if self.regression_selected(): + # Regression + emg_model = EMGRegressor(model=model) + emg_model.fit(feature_dictionary=data_set) + self.model = OnlineEMGRegressor(emg_model, WINDOW_SIZE, WINDOW_INCREMENT, self.odh, feature_list) +else: + # Classification + emg_model = EMGClassifier(model=model) + emg_model.fit(feature_dictionary=data_set) + emg_model.add_velocity(train_windows, train_metadata[labels_key]) + self.model = OnlineEMGClassifier(emg_model, WINDOW_SIZE, WINDOW_INCREMENT, self.odh, feature_list) + +# Step 5: Create online EMG model and start predicting. +self.model.run(block=False) # block set to false so it will run in a seperate process. ``` # Fitts Test -To create the Isofitts test, we leveraged `pygame`. The code for this module can be found in `isofitts.py`. The cursor moves based on the `OnlineEMGClassifier's` predictions: - -```Python -self.current_direction = [0,0] -data, _ = self.sock.recvfrom(1024) -data = str(data.decode("utf-8")) -if data: - input_class = float(data.split(' ')[0]) - # 0 = Hand Closed = down - if input_class == 0: - self.current_direction[1] += self.VEL - # 1 = Hand Open - elif input_class == 1: - self.current_direction[1] -= self.VEL - # 3 = Extension - elif input_class == 3: - self.current_direction[0] += self.VEL - # 4 = Flexion - elif input_class == 4: - self.current_direction[0] -= self.VEL -``` +To create the Isofitts test, we leveraged `pygame`. The code for this module can be found in `libemg.environments.isofitts.py`. -To increase the speed of the cursor we could do one of two things: (1) increase the velocity of the cursor (i.e., how many pixels it moves for each prediction), or (2) decrease the increment so that more predictions are made in the same amount of time. +To increase the speed of the cursor we could do one of two things: (1) increase the velocity of the cursor (i.e., how many pixels it moves for each prediction), or (2) decrease the increment so that more predictions are made in the same amount of time. Parameters like this can be modified by passing arguments to the `IsoFitts` constructor. # Data Analysis -After accumulating data from the experiment, we need a way to analyze the data. In `analyze_data.py`, we added the capability to evaluate each model's offline and online performance. +After accumulating data from the experiment, we need a way to analyze the data. In `analyze_data.py`, we added the capability to evaluate each model's offline and online performance. -To evaluate each model's offline performance, we took a similar approach to set up the online classifier. However, in this case, we have to split up the data into training and testing. To do this, we first extract each of the five reps of data. We will split this into training and testing in a little bit. +To evaluate each model's offline performance, we took a similar approach to set up the online model. However, in this case, we have to split up the data into training and testing. To do this, we first extract each of the 3 reps of data. We will split this into training and testing in a little bit. ```Python -dataset_folder = 'data' -classes_values = ["0","1","2","3","4"] -reps_values = ["0","1","2","3","4"] regex_filters = [ - RegexFilter(left_bound = "_C_", right_bound=".csv", values = classes_values, description='classes'), - RegexFilter(left_bound = "R_", right_bound="_C_", values = reps_values, description='reps') + RegexFilter(left_bound = "classification/C_", right_bound="_R", values = ["0","1","2","3","4"], description='classes'), + RegexFilter(left_bound = "R_", right_bound="_emg.csv", values = ["0", "1", "2"], description='reps'), ] -odh = OfflineDataHandler() -odh.get_data(folder_location=dataset_folder, filename_dic = dic, delimiter=",") + +clf_odh = OfflineDataHandler() +clf_odh.get_data('data/', regex_filters, delimiter=",") + +regex_filters = [ + RegexFilter(left_bound='data/regression/C_0_R_', right_bound='_emg.csv', values=['0', '1', '2'], description='reps') +] +metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='animation/', right_bound='.txt', values=['collection'], description='labels'), package_function=lambda x, y: True) +] +reg_odh = OfflineDataHandler() +reg_odh.get_data('./', regex_filters, metadata_fetchers=metadata_fetchers, delimiter=',') ``` -Using the `isolate_data` function, we can split the data into training and testing. In this specific case, we are splitting on the "reps" keyword and we want values with index 0-2 for training and 3-4 for testing. After isolating the data, we extract windows and associated metadata for both training and testing sets. + +Using the `isolate_data` function, we can split the data into training and testing. In this specific case, we are splitting on the "reps" keyword and we want values with index 0-1 for training and 2 for testing. After isolating the data, we extract windows and associated metadata for both training and testing sets. ```Python -train_odh = odh.isolate_data(key="reps", values=[0,1,2]) -train_windows, train_metadata = train_odh.parse_windows(WINDOW_SIZE,WINDOW_INCREMENT) -test_odh = odh.isolate_data(key="reps", values=[3,4]) -test_windows, test_metadata = test_odh.parse_windows(WINDOW_SIZE,WINDOW_INCREMENT) +train_odh = odh.isolate_data(key="reps", values=[0,1]) +train_windows, train_metadata = train_odh.parse_windows(WINDOW_SIZE,WINDOW_INCREMENT, metadata_operations=metadata_operations) +test_odh = odh.isolate_data(key="reps", values=[2]) +test_windows, test_metadata = test_odh.parse_windows(WINDOW_SIZE,WINDOW_INCREMENT, metadata_operations=metadata_operations) ``` -Next, we create a dataset dictionary consisting of testing and training features and labels. This dictionary is passed into an `OfflineDataHandler.` +Next, we create a dataset dictionary consisting of testing and training features and labels. This dictionary is passed into an `OfflineDataHandler`. ```Python data_set = {} data_set['testing_features'] = fe.extract_feature_group('HTD', test_windows) data_set['training_features'] = fe.extract_feature_group('HTD', train_windows) -data_set['testing_labels'] = test_metadata['classes'] -data_set['training_labels'] = train_metadata['classes'] +data_set['testing_labels'] = test_metadata[labels_key] +data_set['training_labels'] = train_metadata[labels_key] ``` -Finally, to extract the offline performance of each model, we leverage the `OfflineMetrics` module. We do this in a loop to easily evaluate a number of classifiers. We append the metrics to a dictionary for future use. +Finally, to extract the offline performance of each model, we leverage the `OfflineMetrics` module. We do this in a loop to easily evaluate a number of models. We append the metrics to a dictionary for future use. ```Python om = OfflineMetrics() -metrics = ['CA', 'AER', 'INS', 'CONF_MAT'] -# Normal Case - Test all different classifiers -for model in ['LDA', 'SVM', 'KNN', 'RF']: - classifier = EMGClassifier() - classifier.fit(model, data_set.copy()) - preds, probs = classifier.run(data_set['testing_features'], data_set['testing_labels']) +# Normal Case - Test all different models +for model in models: + if is_regression: + model = EMGRegressor(model) + model.fit(data_set.copy()) + preds = model.run(data_set['testing_features']) + else: + model = EMGClassifier(model) + model.fit(data_set.copy()) + preds, _ = model.run(data_set['testing_features']) out_metrics = om.extract_offline_metrics(metrics, data_set['testing_labels'], preds, 2) - offline_metrics['classifier'].append(model) + offline_metrics['model'].append(model) offline_metrics['metrics'].append(out_metrics) -return offline_metric +return offline_metrics ``` # Results -There are clear discrepancies between offline and online metrics. For example, RF outperforms LDA in the offline analysis, but it is clear in the online test that it is much worse. This highlights the need to evaluate EMG-based control systems in online settings with user-in-the-loop feedback. +There are clear discrepancies between offline and online metrics. For example, RF outperforms LDA in the offline classification analysis, but it is clear in the online test that it is much worse. Similarly, RF outperforms LR in the offline regression analysis, but the usability metrics again suggest that LR outperforms RF during an online task. This highlights the need to evaluate EMG-based control systems in online settings with user-in-the-loop feedback. + +These results also show that regressors had worse usability metrics than classifiers despite enabling simultaneous motions. The high number of overshoots indicate that this is likely due to the fact that the model stuggled to stay at rest without drifting, increasing the time each trial took. This example could be expanded by adding things like a threshold to the regressors (see `EMGRegressor.add_deadband`), which may improve regressor performance. **Visual Output:** diff --git a/docs/source/examples/offline_regression_example/offline_regression.md b/docs/source/examples/offline_regression_example/offline_regression.md new file mode 100644 index 00000000..7ecb798b --- /dev/null +++ b/docs/source/examples/offline_regression_example/offline_regression.md @@ -0,0 +1,98 @@ +[View Source Code](https://github.com/LibEMG/LibEMG_OfflineRegression_Showcase) + + + +This simple offline example showcases some of the offline capabilities for regression analysis. In this example, we will load in the OneSubjectEMaGerDataset and assess the performance of multiple regressors. All code can be found in `main.py`. + +## Step 1: Importing LibEMG + +The very first step involves importing the modules needed. In general, each of LibEMG's modules has its own import. Make sure that you have successfully installed libemg through pip. + +```Python +import numpy as np +import matplotlib.pyplot as plt +from libemg.offline_metrics import OfflineMetrics +from libemg.datasets import OneSubjectEMaGerDataset +from libemg.feature_extractor import FeatureExtractor +from libemg.emg_predictor import EMGRegressor +``` + +## Step 2: Setting up Constants + +Preprocessing parameters, such as window size, window increment, and the feature set must be decided before EMG data can be prepared for estimation. LibEMG defines window and increment sizes as the number of samples. In this case, the dataset was recorded from the EMaGer cuff, which samples at 1 kHz, so a window of 150 samples corresponds to 150ms. + +The window increment, window size, and feature set default to 40, 150, and 'HTD', respecively. These variables can be customized in this script using the provided CLI. Use `python main.py -h` for an explanation of the CLI. Example usage is also provided below: + +```Bash +python main.py --window_size 200 --window_increment 50 --feature_set MSWT +``` + +# Step 3: Loading in Dataset + +This example uses the `OneSubjectEMaGerDataset`. Instantiating the `Dataset` will automatically download the data into the specified directory, and calling the `prepare_data()` method will load EMG data and metadata (e.g., reps, movements, labels) into an `OfflineDataHandler`. This dataset consists of 5 repetitions, so we use 4 for training data and 1 for testing data. After splitting our data into training and test splits, we perform windowing on the raw EMG data. By default, the metadata assigned to each window will be based on the mode of that window. Since we are analyzing regression data, we pass in a function that tells the `OfflineDataHandler` to grab the label from the last sample in the window instead of taking the mode of the window. We can specify how we want to handle windowing of each type of metadata by passing in a `metadata_operations` dictionary. + +```Python +# Load data +odh = OneSubjectEMaGerDataset().prepare_data() + +# Split into train/test reps +train_odh = odh.isolate_data('reps', [0, 1, 2, 3]) +test_odh = odh.isolate_data('reps', [4]) + +# Extract windows +metadata_operations = {'labels': lambda x: x[-1]} # grab label of last sample in window +train_windows, train_metadata = train_odh.parse_windows(args.window_size, args.window_increment, metadata_operations=metadata_operations) +test_windows, test_metadata = test_odh.parse_windows(args.window_size, args.window_increment, metadata_operations=metadata_operations) +``` + +# Step 4: Feature Extraction + +We then extract features using the `FeatureExtractor` for our training and test data. The `fit()` method expects a dictionary with the keys `training_features` and `training_labels`, so we create one and pass in our extracted features and training labels. + +```Python +training_features = fe.extract_feature_group(args.feature_set, train_windows, array=True), +training_labels = train_metadata['labels'] +test_features = fe.extract_feature_group(args.feature_set, test_windows, array=True) +test_labels = test_metadata['labels'] + +training_set = { + 'training_features': training_features, + 'training_labels': training_labels +} +``` + +# Step 5: Regression + +`LibEMG` allows you to pass in custom models, but you can also pass in a string that will create a model for you. In this example, we compare a linear regressor to a gradient boosting regressor. We iterate through a list of the models we want to observe, fit the model to the training data, and calculate metrics based on predictions on the test data. We then store these metrics for plotting later. + +```Python +results = {metric: [] for metric in ['R2', 'NRMSE', 'MAE']} +for model in models: + reg = EMGRegressor(model) + + # Fit and run model + print(f"Fitting {model}...") + reg.fit(training_set.copy()) + predictions = reg.run(test_features) + + metrics = om.extract_offline_metrics(results.keys(), test_labels, predictions) + for metric in metrics: + results[metric].append(metrics[metric].mean()) +``` + +# Step 6: Visualization + +Finally, we visualize our results. We first plot the decision stream for each model. After each model is fitted, we plot the offline metrics for each type of model. + +```Python +# Note: this will block the main thread once the plot is shown. Close the plot to continue execution. +reg.visualize(test_labels, predictions) + +fig, axs = plt.subplots(nrows=len(results), layout='constrained', figsize=(8, 8), sharex=True) +for metric, ax in zip(results.keys(), axs): + ax.bar(models, np.array(results[metric]) * 100) + ax.set_ylabel(f"{metric} (%)") + +fig.suptitle('Metrics Summary') +plt.show() +``` diff --git a/docs/source/examples/offline_regression_example/offline_regression_example.rst b/docs/source/examples/offline_regression_example/offline_regression_example.rst new file mode 100644 index 00000000..7e8707f7 --- /dev/null +++ b/docs/source/examples/offline_regression_example/offline_regression_example.rst @@ -0,0 +1,4 @@ +Offline Regression Analysis +========================================== +.. include:: offline_regression.md + :parser: myst_parser.sphinx_ \ No newline at end of file diff --git a/docs/source/examples/reactive_example/reactive.md b/docs/source/examples/reactive_example/reactive.md new file mode 100644 index 00000000..f1faf059 --- /dev/null +++ b/docs/source/examples/reactive_example/reactive.md @@ -0,0 +1,159 @@ +This example builds a four-stage pipeline where each stage wakes the next, and then reads back the debug log to see exactly why each stage fired. It runs against a mock streamer, so no hardware is needed. + +The pipeline is `emg` to `filtered_emg` to `features` to `predictions`. Nothing polls. Each stage subscribes to the item before it and is notified when that item is written. + +The point of the example is the middle two stages, because they observe the same kind of item and disagree about what a change means. The filter is dirty on a single new sample. The feature stage is not dirty until a full window increment has accrued. + +# Setting up + +```Python +import numpy as np +from multiprocessing import Lock + +from libemg.shared_memory_manager import SharedMemoryManager, assign_shared_memory_locks +from libemg.reactive import (ReactiveGraph, FilterHook, FeatureHook, ProbeHook, + Hook, Input, Output, OnSamples, LATEST, WINDOW, + default_notifier_pool) +from libemg.event_log import EventLog +from libemg.filtering import Filter + +FS, CHANNELS = 1000, 8 +WINDOW_SIZE, INCREMENT = 200, 50 +FEATURES = ['MAV', 'RMS', 'WL', 'ZC'] +``` + +Everything is assembled in one process so that the streamer and the graph share a notifier pool. That is what lets a write in the streamer's process wake a hook in another. + +```Python +pool = default_notifier_pool() + +items = [['emg', (2000, CHANNELS), np.double], + ['emg_count', (1, 1), np.int32]] +assign_shared_memory_locks(items) + +writer = SharedMemoryManager(notifier_pool=pool) +for item in items: + writer.create_variable(*item) +``` + +# A model stage + +The classifier is written as a hook. It observes `features` and writes `predictions`. It loads the model in `setup` rather than in `__init__`, because the executor is a spawned process and a fitted model is not always picklable. + +```Python +class ModelHook(Hook): + def __init__(self, model_path, num_features): + super().__init__( + 'model', + inputs=[Input('features', OnSamples(1), mode=LATEST)], + outputs=[Output('predictions', (100, 2), np.double)], + ) + self.model_path = model_path + self.model = None + + def setup(self, context): + import pickle + with open(self.model_path, 'rb') as handle: + self.model = pickle.load(handle) + + def step(self, data, snapshots): + features = data['features'] + probabilities = self.model.predict_proba(features) + prediction = float(np.argmax(probabilities, axis=1)[0]) + confidence = float(np.max(probabilities)) + return {'predictions': np.array([[prediction, confidence]])} +``` + +# Wiring the stages + +```Python +fi = Filter(sampling_frequency=FS) +fi.install_common_filters() + +log = EventLog(path='reactive.log', keep=50000) +graph = ReactiveGraph(items, log=log, notifier_pool=pool) + +# dirty on a single new sample +graph.add(FilterHook('filter', 'emg', 'filtered_emg', fi=fi, + shape=(2000, CHANNELS), window=400, increment=1)) + +# dirty only once an increment of samples has accrued +graph.add(FeatureHook('features', 'filtered_emg', 'features', + feature_list=FEATURES, + window_size=WINDOW_SIZE, + window_increment=INCREMENT, + num_features=len(FEATURES) * CHANNELS), + executor='features') + +graph.add(ModelHook('mdl.pkl', len(FEATURES) * CHANNELS), executor='model') + +# A probe cannot stall what it watches, so it is safe to leave attached. +# in_process=True runs it as a thread here rather than in its own process, +# which a lambda requires, since a lambda cannot be pickled to reach one. +graph.add(ProbeHook('scope', 'predictions', + fn=lambda data, snaps: print('prediction', data['predictions']), + hz=10), + executor='probe', in_process=True) + +print(graph.describe()) +graph.start() +``` + +`describe` prints the wiring, including each observer's criterion, which is the quickest way to check a pipeline is hooked up the way you meant: + +``` +executor 'main': + filter: observes emg [OnSamples(1), window(400)] -> writes filtered_emg +executor 'features': + features: observes filtered_emg [OnSamples(50), window(200)] -> writes features +executor 'model': + model: observes features [OnSamples(1), latest] -> writes predictions +executor 'probe': + scope: observes predictions [Periodic(10), latest] -> writes - +``` + +# Feeding it + +```Python +rng = np.random.default_rng(0) +for _ in range(2000): + writer.commit('emg', rng.standard_normal((1, CHANNELS))) + time.sleep(1.0 / FS) + +graph.stop() +``` + +# Reading back what happened + +The counters on each item show the cascade converging, and they show the two stages disagreeing about what a change is. Two thousand samples produce two thousand filter runs and forty feature extractions. + +```Python +reader = SharedMemoryManager() +for item in graph.shared_memory_items: + reader.find_variable(*item) + +for tag in ['emg', 'filtered_emg', 'features', 'predictions']: + state = reader.snapshot(tag) + print(tag, 'generation', state.generation, 'total', state.total_samples) +``` + +| Item | Total samples | Why | +| --- | --- | --- | +| `emg` | 2000 | one per commit | +| `filtered_emg` | 2000 | the filter is dirty on every sample | +| `features` | 40 | 2000 divided by an increment of 50 | +| `predictions` | 40 | one per feature row | + +The log carries the reasoning, not just the outcome: + +```Python +print(log.summary()) +for event in log.events(observer='features')[:4]: + print(event.format()) +``` + +Each recorded decision names the item, the observer, the criterion applied and the counters it was applied to, so a stage that fires too often or never fires can be diagnosed without adding print statements. + +# Turning it off + +Nothing here is mandatory. Passing no log at all installs a null object that absorbs every call, and an existing script that uses `OnlineEMGClassifier` gets the event-driven streaming loop without any of this, because the classifier hooks its data handler itself. diff --git a/docs/source/examples/reactive_example/reactive_example.rst b/docs/source/examples/reactive_example/reactive_example.rst new file mode 100644 index 00000000..169a6571 --- /dev/null +++ b/docs/source/examples/reactive_example/reactive_example.rst @@ -0,0 +1,4 @@ +Reactive Pipeline Example +============================== +.. include:: reactive.md + :parser: myst_parser.sphinx_ diff --git a/docs/source/index.rst b/docs/source/index.rst index 90ff5a3d..41f98b72 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,55 +1,63 @@ -LibEMG -========================================= -.. include:: doc.md - :parser: myst_parser.sphinx_ - -.. toctree:: - :maxdepth: 1 - :caption: Introduction: - - documentation/introduction/introduction - -.. toctree:: - :maxdepth: 1 - :caption: Modules: - - documentation/data/data - documentation/filtering/filtering - documentation/features/features - documentation/feature_selection/feature_selection - documentation/classification/classification - documentation/evaluation/evaluation - -.. toctree:: - :maxdepth: 1 - :caption: Tools: - - documentation/screen_guided_training/sgt - documentation/supported_hardware/supported_hardware - documentation/visualization/visualization - -.. toctree:: - :maxdepth: 2 - :caption: API: - - emg_toolbox - - -.. toctree:: - :maxdepth: 1 - :caption: Offline Examples: - - examples/simple_offline_example/simple_offline_example - examples/features_and_group_example/features_and_group_example - examples/feature_optimization_example/feature_optimization_example - examples/deep_learning_example/deep_learning_example - -.. toctree:: - :maxdepth: 1 - :caption: Online Examples: - - examples/snake_example/snake_example - examples/unity_example/unity_example - examples/mouse_example/mouse_example - examples/mixed_reality_example/mixed_reality_example +LibEMG +========================================= +.. include:: doc.md + :parser: myst_parser.sphinx_ + +.. toctree:: + :maxdepth: 1 + :caption: Introduction: + + documentation/introduction/introduction + +.. toctree:: + :maxdepth: 1 + :caption: Modules: + + documentation/data/data + documentation/reactive/reactive + documentation/filtering/filtering + documentation/features/features + documentation/feature_selection/feature_selection + documentation/prediction/prediction + documentation/adaptation/adaptation + documentation/evaluation/evaluation + documentation/animation/animation + +.. toctree:: + :maxdepth: 1 + :caption: Tools: + + documentation/gui_workflow/gui_workflow + documentation/screen_guided_training/sgt + documentation/supported_hardware/supported_hardware + documentation/visualization/visualization + documentation/pipeline/pipeline + documentation/environments/environments + +.. toctree:: + :maxdepth: 2 + :caption: API: + + emg_toolbox + + +.. toctree:: + :maxdepth: 1 + :caption: Offline Examples: + + examples/simple_offline_example/simple_offline_example + examples/features_and_group_example/features_and_group_example + examples/feature_optimization_example/feature_optimization_example + examples/deep_learning_example/deep_learning_example + examples/offline_regression_example/offline_regression_example + +.. toctree:: + :maxdepth: 1 + :caption: Online Examples: + + examples/reactive_example/reactive_example + examples/snake_example/snake_example + examples/unity_example/unity_example + examples/mouse_example/mouse_example + examples/mixed_reality_example/mixed_reality_example examples/fitts_example/fitts_example \ No newline at end of file diff --git a/libemg/__init__.py b/libemg/__init__.py index 1c06465f..e7d9d582 100644 --- a/libemg/__init__.py +++ b/libemg/__init__.py @@ -10,3 +10,9 @@ from libemg import animator from libemg import gui from libemg import shared_memory_manager +from libemg import reactive +from libemg import event_log +from libemg import environments +from libemg import output_writer +from libemg import environments +from libemg import adaptation \ No newline at end of file diff --git a/libemg/_datasets/_3DC.py b/libemg/_datasets/_3DC.py new file mode 100644 index 00000000..41f2f4bf --- /dev/null +++ b/libemg/_datasets/_3DC.py @@ -0,0 +1,46 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class _3DCDataset(Dataset): + def __init__(self, dataset_folder="_3DCDataset/"): + Dataset.__init__(self, + 1000, + 10, + '3DC Armband (Prototype)', + 22, + {0: "Neutral", 1: "Radial Deviation", 2: "Wrist Flexion", 3: "Ulnar Deviation", 4: "Wrist Extension", 5: "Supination", 6: "Pronation", 7: "Power Grip", 8: "Open Hand", 9: "Chuck Grip", 10: "Pinch Grip"}, + '8 (4 Train, 4 Test)', + "The 3DC dataset including 11 classes.", + "https://doi.org/10.3389/fbioe.2020.00158") + self.url = "https://github.com/libemg/3DCDataset" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + subject_list = np.array(list(range(1,23))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + + + sets_values = ["train", "test"] + reps_values = ["0","1","2","3"] + classes_values = [str(i) for i in range(11)] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound = "/", right_bound="/EMG", values = sets_values, description='sets'), + RegexFilter(left_bound = "_", right_bound=".txt", values = classes_values, description='classes'), + RegexFilter(left_bound = "EMG_gesture_", right_bound="_", values = reps_values, description='reps'), + RegexFilter(left_bound="Participant", right_bound="/",values=subjects_values, description='subjects') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("sets", [0], fast=True), 'Test': odh.isolate_data("sets", [1], fast=True)} + + return data \ No newline at end of file diff --git a/libemg/_datasets/__init__.py b/libemg/_datasets/__init__.py new file mode 100644 index 00000000..975fd02b --- /dev/null +++ b/libemg/_datasets/__init__.py @@ -0,0 +1,17 @@ +from libemg._datasets import _3DC +from libemg._datasets import ciil +from libemg._datasets import continous_transitions +from libemg._datasets import dataset +from libemg._datasets import emg_epn612 +from libemg._datasets import fors_emg +from libemg._datasets import fougner_lp +from libemg._datasets import grab_myo +from libemg._datasets import hyser +from libemg._datasets import intensity +from libemg._datasets import kaufmann_md +from libemg._datasets import nina_pro +from libemg._datasets import one_subject_emager +from libemg._datasets import one_subject_myo +from libemg._datasets import radmand_lp +from libemg._datasets import tmr_shirleyryanabilitylab +from libemg._datasets import emg2pose \ No newline at end of file diff --git a/libemg/_datasets/ciil.py b/libemg/_datasets/ciil.py new file mode 100644 index 00000000..4614cbdd --- /dev/null +++ b/libemg/_datasets/ciil.py @@ -0,0 +1,159 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter, FilePackager +from pathlib import Path +import numpy as np + + +class CIIL_MinimalData(Dataset): + def __init__(self, dataset_folder='CIILData/'): + Dataset.__init__(self, + 200, + 8, + 'Myo Armband', + 11, + {0: 'Close', 1: 'Open', 2: 'Rest', 3: 'Flexion', 4: 'Extension'}, + '1 Train (1s), 15 Test', + "The goal of this Myo dataset is to explore how well models perform when they have a limited amount of training data (1s per class).", + 'https://ieeexplore.ieee.org/abstract/document/10394393') + self.url = "https://github.com/LibEMG/CIILData" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects=None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + subfolder = 'MinimalTrainingData' + subject_list = np.array(list(range(0, 11))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + + classes_values = [str(i) for i in range(0,5)] + reps_values = ["0","1","2"] + sets = ["train", "test"] + regex_filters = [ + RegexFilter(left_bound = "/", right_bound="/", values = sets, description='sets'), + RegexFilter(left_bound = "/subject", right_bound="/", values = subjects_values, description='subjects'), + RegexFilter(left_bound = "R_", right_bound="_", values = reps_values, description='reps'), + RegexFilter(left_bound = "C_", right_bound=".csv", values = classes_values, description='classes') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder + '/' + subfolder, regex_filters=regex_filters, delimiter=",") + + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("sets", [0], fast=True), 'Test': odh.isolate_data("sets", [1], fast=True)} + + return data + +class CIIL_ElectrodeShift(Dataset): + def __init__(self, dataset_folder='CIILData/'): + Dataset.__init__(self, + 200, + 8, + 'Myo Armband', + 21, + {0: 'Close', 1: 'Open', 2: 'Rest', 3: 'Flexion', 4: 'Extension'}, + '5 Train (Before Shift), 8 Test (After Shift)', + "An electrode shift confounding factors dataset.", + 'https://link.springer.com/article/10.1186/s12984-024-01355-4') + self.url = "https://github.com/LibEMG/CIILData" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects=None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + subfolder = 'ElectrodeShift' + subject_list = np.array(list(range(0, 21))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + + classes_values = [str(i) for i in range(0,5)] + reps_values = ["0","1","2","3","4"] + sets = ["training", "trial_1", "trial_2", "trial_3", "trial_4"] + regex_filters = [ + RegexFilter(left_bound = "/", right_bound="/", values = sets, description='sets'), + RegexFilter(left_bound = "/subject", right_bound="/", values = subjects_values, description='subjects'), + RegexFilter(left_bound = "R_", right_bound="_", values = reps_values, description='reps'), + RegexFilter(left_bound = "C_", right_bound=".csv", values = classes_values, description='classes') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder + '/' + subfolder, regex_filters=regex_filters, delimiter=",") + + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("sets", [0], fast=True), 'Test': odh.isolate_data("sets", [1,2,3,4], fast=True)} + + return data + + +class CIIL_WeaklySupervised(Dataset): + def __init__(self, dataset_folder='CIIL_WeaklySupervised/'): + Dataset.__init__(self, + 1000, + 8, + 'OyMotion gForcePro+ EMG Armband', + 16, + {0: 'Close', 1: 'Open', 2: 'Rest', 3: 'Flexion', 4: 'Extension'}, + '30 min weakly supervised, 1 rep calibration, 14 reps test', + "A weakly supervised environment with sparse supervised calibration.", + 'In Submission') + self.url = "https://unbcloud-my.sharepoint.com/:u:/g/personal/ecampbe2_unb_ca/EaABHYybhfJNslTVcvwPPwgB9WwqlTLCStui30maqY53kw?e=MbboMd" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, + subjects = None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download_via_onedrive(self.url, self.dataset_folder) + + # supervised odh loading + subject_list = np.array(list(range(0, 16))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + classes_values = [str(i) for i in range(0,5)] + reps_values = [str(i) for i in range(0,15)] + setting_values = [".csv", ""] # this is arbitrary to get a field that separates WS from S + regex_filters = [ + RegexFilter(left_bound = "", right_bound="", values = setting_values, description='settings'), + RegexFilter(left_bound = "/S", right_bound="/", values = subjects_values, description='subjects'), + RegexFilter(left_bound = "R", right_bound=".csv", values = reps_values, description='reps'), + RegexFilter(left_bound = "C", right_bound="_R", values = classes_values, description='classes') + ] + odh_s = OfflineDataHandler() + odh_s.get_data(folder_location=self.dataset_folder+"CIIL_WeaklySupervised/", + regex_filters=regex_filters, + delimiter=",") + + # weakly supervised odh loading + reps_values = [str(i) for i in range(3)] + setting_values = ["", ".csv"] # this is arbitrary to get a field that separates WS from S + regex_filters = [ + RegexFilter(left_bound = "", right_bound="", values = setting_values, description='settings'), + RegexFilter(left_bound = "/S", right_bound="/", values = subjects_values, description='subjects'), + RegexFilter(left_bound = "WS", right_bound=".csv", values = reps_values, description='reps'), + ] + metadata_fetchers = [ + FilePackager(regex_filter=RegexFilter(left_bound="", right_bound="targets.csv", values=["_"], description="classes"), + package_function=lambda x, y: (x.split("WS")[1][0] == y.split("WS")[1][0]) and (Path(x).parent == Path(y).parent) + ) + ] + odh_ws = OfflineDataHandler() + odh_ws.get_data(folder_location=self.dataset_folder+"CIIL_WeaklySupervised/", + regex_filters=regex_filters, + metadata_fetchers=metadata_fetchers, + delimiter=",") + + data = odh_s + odh_ws + if split: + data = {'All': data, + 'Pretrain': odh_ws, + 'Train': odh_s.isolate_data("reps", [0], fast=True), + 'Test': odh_s.isolate_data("reps", list(range(1,15)), fast=True)} + + return data diff --git a/libemg/_datasets/continous_transitions.py b/libemg/_datasets/continous_transitions.py new file mode 100644 index 00000000..ae9aa6e6 --- /dev/null +++ b/libemg/_datasets/continous_transitions.py @@ -0,0 +1,67 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler +import h5py +import numpy as np + +class ContinuousTransitions(Dataset): + def __init__(self, dataset_folder="ContinuousTransitions/"): + Dataset.__init__(self, + 2000, + 6, + 'Delsys', + 43, + {0: 'No Motion', 1: 'Wrist Flexion', 2: 'Wrist Extension', 3: 'Wrist Pronation', 4: 'Wrist Supination', 5: 'Hand Close', 6: 'Hand Open'}, + '6 Training (Ramp), 42 Transitions (All combinations of Transitions) x 6 Reps', + "The testing set in this dataset has continuous transitions between classes which is a more realistic offline evaluation standard for myoelectric control.", + "https://ieeexplore.ieee.org/document/10254242") + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects=None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + print("Please download the dataset from: https://unbcloud-my.sharepoint.com/:f:/g/personal/ecampbe2_unb_ca/EjgjhM9ZHJxOglKoAf062ngBf4wFj2Mn2bORKY1-aMYGRw?e=WkZNwI") + return + + # Training ODH + odh_tr = OfflineDataHandler() + odh_tr.subjects = [] + odh_tr.classes = [] + odh_tr.extra_attributes = ['subjects', 'classes'] + + # Testing ODH + odh_te = OfflineDataHandler() + odh_te.subjects = [] + odh_te.classes = [] + odh_te.extra_attributes = ['subjects', 'classes'] + + subject_list = np.array([2,3,4,5,6,7,8,9,10,11,12,13,14,15,17,18,19,20,21,22,23,25,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47]) + if subjects: + subject_list = subject_list[subjects] + + for s_i, s in enumerate(subject_list): + data = h5py.File(self.dataset_folder + '/P' + f"{s:02}" + '.hdf5', "r") + cont_labels = data['continuous']['emg']['prompt'][()] + cont_labels = np.hstack([np.ones((1000)) * cont_labels[0], cont_labels[0:len(cont_labels)-1000]]) # Rolling about 0.5s as per Shri's suggestion + cont_emg = data['continuous']['emg']['signal'][()] + cont_chg_idxs = np.insert(np.where(cont_labels[:-1] != cont_labels[1:])[0], 0, -1) + cont_chg_idxs = np.insert(cont_chg_idxs, len(cont_chg_idxs), len(cont_emg)) + for i in range(0, len(cont_chg_idxs)-1): + odh_te.data.append(cont_emg[cont_chg_idxs[i]+1:cont_chg_idxs[i+1]]) + odh_te.classes.append(np.expand_dims(cont_labels[cont_chg_idxs[i]+1:cont_chg_idxs[i+1]]-1, axis=1)) + odh_te.subjects.append(np.ones((len(odh_te.data[-1]), 1)) * s_i) + + ramp_emg = data['ramp']['emg']['signal'][()] + ramp_labels = data['ramp']['emg']['prompt'][()] + r_chg_idxs = np.insert(np.where(ramp_labels[:-1] != ramp_labels[1:])[0], 0, -1) + r_chg_idxs = np.insert(r_chg_idxs, len(r_chg_idxs), len(ramp_emg)) + for i in range(0, len(r_chg_idxs)-1): + odh_tr.data.append(ramp_emg[r_chg_idxs[i]+1:r_chg_idxs[i+1]]) + odh_tr.classes.append(np.expand_dims(ramp_labels[r_chg_idxs[i]+1:r_chg_idxs[i+1]]-1, axis=1)) + odh_tr.subjects.append(np.ones((len(odh_tr.data[-1]), 1)) * s_i) + + odh_all = odh_tr + odh_te + data = odh_all + if split: + data = {'All': odh_all, 'Train': odh_tr, 'Test': odh_te} + + return data diff --git a/libemg/_datasets/dataset.py b/libemg/_datasets/dataset.py new file mode 100644 index 00000000..df25be1c --- /dev/null +++ b/libemg/_datasets/dataset.py @@ -0,0 +1,55 @@ +import os +from libemg.data_handler import OfflineDataHandler +from onedrivedownloader import download as onedrive_download +# this assumes you have git downloaded (not pygit, but the command line program git) + +class Dataset: + def __init__(self, sampling, num_channels, recording_device, num_subjects, gestures, num_reps, description, citation): + # Every class should have this + self.sampling=sampling + self.num_channels=num_channels + self.recording_device=recording_device + self.num_subjects=num_subjects + self.gestures=gestures + self.num_reps=num_reps + self.description=description + self.citation=citation + + def download(self, url, dataset_name): + clone_command = "git clone " + url + " " + dataset_name + os.system(clone_command) + + def download_via_onedrive(self, url, dataset_name, unzip=True, clean=True): + onedrive_download(url=url, + filename = dataset_name, + unzip=unzip, + clean=clean) + + def remove_dataset(self, dataset_folder): + remove_command = "rm -rf " + dataset_folder + os.system(remove_command) + + def check_exists(self, dataset_folder): + return os.path.exists(dataset_folder) + + def prepare_data(self, split = True): + pass + + def get_info(self): + print(str(self.description) + '\n' + 'Sampling Rate: ' + str(self.sampling) + '\nNumber of Channels: ' + str(self.num_channels) + + '\nDevice: ' + self.recording_device + '\nGestures: ' + str(self.gestures) + '\nNumber of Reps: ' + str(self.num_reps) + '\nNumber of Subjects: ' + str(self.num_subjects) + + '\nCitation: ' + str(self.citation)) + +# given a directory, return a list of files in that directory matching a format +# can be nested +# this is just a handly utility +def find_all_files_of_type_recursively(dir, terminator): + files = os.listdir(dir) + file_list = [] + for file in files: + if file.endswith(terminator): + file_list.append(dir+file) + else: + if os.path.isdir(dir+file): + file_list += find_all_files_of_type_recursively(dir+file+'/',terminator) + return file_list \ No newline at end of file diff --git a/libemg/_datasets/emg2pose.py b/libemg/_datasets/emg2pose.py new file mode 100644 index 00000000..f696cd49 --- /dev/null +++ b/libemg/_datasets/emg2pose.py @@ -0,0 +1,234 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler +from libemg.feature_extractor import FeatureExtractor +from libemg.utils import * +import numpy as np +import pandas as pd +import h5py + +class EMG2POSE(Dataset): + def __init__(self, dataset_folder="Meta/emg2pose_data/"): + self.mapping = {'FingerPinches1': 'AllFingerPinchesThumbSwipeThumbRotate', 'Object1': 'CoffeePanicPete', 'Counting1': 'CountingUpDownFaceSideAway', 'Counting2': 'CountingUpDownFingerWigglingSpreading', 'DoorknobFingerGraspFistGrab': 'DoorknobFingerGraspFistGrab', 'Throwing': 'FastPongFronthandBackhandThrowing', 'Abduction': 'FingerAbductionSeries', 'FingerFreeform': 'FingerFreeform', 'FingerPinches2': 'FingerPinchesSingleFingerPinchesMultiple', 'HandHandInteractions': 'FingerTouchPalmClapmrburns', 'Wiggling1': 'FingerWigglingSpreading', 'Punch': 'GraspPunchCloseFar', 'Gesture1': 'HandClawGraspFlicks', 'StaticHands': 'HandDeskSeparateClaspedChest', 'FingerPinches3': 'HandOverHandAllFingerPinchesThumbSwipeThumbRotate', 'Wiggling2': 'HandOverHandCountingUpDownFingerWigglingSpreading', 'Unconstrained': 'unconstrained', 'Gesture2': 'HookEmHornsOKScissors', 'FingerPinches4': 'IndexPinchesMiddlePinchesThumbswipes', 'Pointing': 'IndividualFingerPointingSnap', 'Freestyle1': 'OneHandedFreeStyle', 'Object2': 'PlayBlocksChess', 'Draw': 'PokeDrawPinchRotateclosefar', 'Poke': 'PokePinchCloseFar', 'Gesture3': 'ShakaVulcanPeace', 'ThumbsSwipes': 'ThumbsSwipesWholeHand', 'ThumbRotations': 'ThumbsUpDownThumbRotationsCWCCWP', 'Freestyle2': 'TwoHandedFreeStyle', 'WristFlex': 'WristFlexionAbduction'} + + Dataset.__init__(self, + 2000, + 32, + 'Ctrl Labs Armband', + 193, + self.mapping, + 'N/A', + "A large dataset from ctrl-labs (Meta) for joint angle estimation. Note that not all subjects have all stages.", + "https://openreview.net/forum?id=b5n3lKRLzk") + self.dataset_folder = dataset_folder + + def check_files(self): + if not self.check_exists(self.dataset_folder): + print("Please download the dataset from: https://fb-ctrl-oss.s3.amazonaws.com/emg2pose/emg2pose_dataset.tar") + return False + + if not self.check_exists(self.dataset_folder + 'metadata.csv'): + print("Could not find metadata file... Please make sure this is downloaded and in the folder.") + return False + + return True + +class EMG2POSECU(EMG2POSE): + """ + The cross user version of emg2pose. We are testing generalization within across users within the same stage. + + Parameters + ---------- + stage: str (default='Wiggling2') + The stage to test. Will grab all subjects with that stage. + split: list (default=[80,20]) + Defaults to 80/20 split for train and test data respectively. + """ + def __init__(self, dataset_folder="Meta/emg2pose_data/", stage = 'Wiggling2', split = [0.8,0.2]): + EMG2POSE.__init__(self, dataset_folder=dataset_folder) + self.stage = stage + self.split = split + + def prepare_data(self, split = True, feature_list = None, window_size = None, window_inc = None, feature_dic = None): + """ + Use the features, window_size, and window_inc parameters to extract features directly so that you save on memory usage. + + Parameters + ---------- + feature_list: list (default=None) + List of featurs. + window_size: int (default=None) + Number of samples. + window_inc: int (default=None) + Number of samples. + feature_dic: dic (default=None) + Feature parameters. + """ + if feature_list or window_size or window_inc: + assert feature_list + assert window_size + assert window_inc + fe = FeatureExtractor() + + odh = OfflineDataHandler() + unique_subjects = [] + odh.subjects = [] + odh.labels = [] + odh.extra_attributes = ['subjects', 'labels'] + + self.check_files() + df = pd.read_csv(self.dataset_folder + 'metadata.csv') + subject_ids = list(np.unique(df['user'])) + + target_gesture = self.mapping[self.stage] + for s_i, s in enumerate(subject_ids): + sub_mask = df['user'] == s + gesture_mask = df['stage'] == target_gesture + + # Get all files for that subject + files = df['filename'][(sub_mask) & (gesture_mask)] + files = [f.replace('left', '') for f in files] + files = [f.replace('right', '') for f in files] + for f in np.unique(files): + unique_subjects.append(s_i) + # Check that files exists otherwise skip + if not (self.check_exists(self.dataset_folder + '/' + f + 'left.hdf5') and self.check_exists(self.dataset_folder + '/' + f + 'right.hdf5')): + continue + left = h5py.File(self.dataset_folder + '/' + f + 'left.hdf5', "r") + right = h5py.File(self.dataset_folder + '/' + f + 'right.hdf5', "r") + + emg_left = left['emg2pose']['timeseries']['emg'] + emg_right = right['emg2pose']['timeseries']['emg'] + min_idx = min([len(emg_left), len(emg_right)]) + + ja_left = left['emg2pose']['timeseries']['joint_angles'] + ja_right = right['emg2pose']['timeseries']['joint_angles'] + + if feature_list: + feats = fe.extract_features(feature_list, get_windows(np.hstack([emg_left[0:min_idx], emg_right[0:min_idx]]), window_size, window_inc), feature_dic=feature_dic, array=True) + odh.data.append(feats) + labels = get_windows(np.hstack([ja_left[0:min_idx], ja_right[0:min_idx]]), window_size, window_inc)[:,:,-1] + odh.labels.append(labels) + odh.subjects.append(np.ones((len(odh.data[-1]), 1)) * s_i) + else: + odh.data.append(np.hstack([emg_left[0:min_idx], emg_right[0:min_idx]])) + odh.labels.append(np.hstack([ja_left[0:min_idx], ja_right[0:min_idx]])) + odh.subjects.append(np.ones((len(odh.data[-1]), 1)) * s_i) + + unique_subjects = np.unique(unique_subjects) + tr_subjects = list(unique_subjects[0:int(len(unique_subjects)*self.split[0])]) + te_subjects = list(unique_subjects[-int(len(unique_subjects)*self.split[1]):]) + + if split: + odh = {'All': odh, 'Train': odh.isolate_data('subjects', tr_subjects), 'Test': odh.isolate_data('subjects', te_subjects)} + return odh + +class EMG2POSEUD(EMG2POSE): + """ + The user dependent version of emg2pose. We are testing generalization within user to unseen stages. + + Parameters + ---------- + train_stages: list (default = None) + If None, the training stages will be all of the ones not included in the test stages. + test_stages: list (default=['Wiggling2', 'Gesture3', 'Gesture2', 'Counting2', 'FingerFreeform', 'Counting1']) + A list of stages to use for training. See self.mapping for options. If a user doesn't have that testing stage then it is ignored. + """ + def __init__(self, dataset_folder="Meta/emg2pose_data/", train_stages = None, test_stages = None): + EMG2POSE.__init__(self, dataset_folder=dataset_folder) + self.num_subjects = 192 # One participant was too low - assuming something was off + self.train_stages = train_stages + self.test_stages = test_stages + + # This split works for stage generalization - takes the average across stages, though + def prepare_data(self, split = True, subjects = None): + if self.test_stages: + for t in self.test_stages: + assert t in self.mapping.keys() + else: + self.test_stages = ['Wiggling2', 'Gesture3', 'Gesture2', 'Counting2', 'FingerFreeform', 'Counting1'] + + if self.train_stages: + for t in self.train_stages: + assert t in self.mapping.keys() + else: + self.train_stages = [] + for k in self.mapping.keys(): + if k not in self.test_stages: + self.train_stages.append(k) + + # (1) Make sure everything is downloaded + self.check_files() + + # (2) Load metadata file + df = pd.read_csv(self.dataset_folder + 'metadata.csv') + subject_ids = np.delete(np.array(list(np.unique(df['user']))), 144) + if subjects: + subject_ids = subject_ids[subjects] + subject_ids = list(subject_ids) + + odh_tr = OfflineDataHandler() + odh_tr.subjects = [] + odh_tr.labels = [] + odh_tr.stages = [] + odh_tr.reps = [] + odh_tr.extra_attributes = ['subjects', 'labels', 'stages', 'reps'] + + odh_te = OfflineDataHandler() + odh_te.subjects = [] + odh_te.labels = [] + odh_te.stages = [] + odh_te.reps = [] + odh_te.extra_attributes = ['subjects', 'labels', 'stages', 'reps'] + + # (3) Iterate through subjects and grab all of the relevant files + for s_i, s in enumerate(subject_ids): + sub_mask = df['user'] == s + gestures = [self.mapping[v] for v in np.hstack([self.train_stages, self.test_stages])] + reps = [0] * len(gestures) + gesture_mask = df['stage'].isin(gestures) + + # Get all files for that subject + files = df['filename'][(sub_mask) & (gesture_mask)] + files = [f.replace('left', '') for f in files] + files = [f.replace('right', '') for f in files] + for f in np.unique(files): + # Check that files exists otherwise skip + if not (self.check_exists(self.dataset_folder + '/' + f + 'left.hdf5') and self.check_exists(self.dataset_folder + '/' + f + 'right.hdf5')): + continue + + left = h5py.File(self.dataset_folder + '/' + f + 'left.hdf5', "r") + right = h5py.File(self.dataset_folder + '/' + f + 'right.hdf5', "r") + gest = df[df['filename'] == f + 'right']['stage'].item() + gesture_name = list(self.mapping.keys())[list(self.mapping.values()).index(gest)] + + emg_left = left['emg2pose']['timeseries']['emg'] + emg_right = right['emg2pose']['timeseries']['emg'] + min_idx = min([len(emg_left), len(emg_right)]) + + ja_left = left['emg2pose']['timeseries']['joint_angles'] + ja_right = right['emg2pose']['timeseries']['joint_angles'] + + if gesture_name in self.train_stages: + odh_tr.data.append(np.hstack([emg_left[0:min_idx], emg_right[0:min_idx]])) + odh_tr.labels.append(np.hstack([ja_left[0:min_idx], ja_right[0:min_idx]])) + odh_tr.stages.append(np.ones((len(odh_tr.data[-1]), 1)) * gestures.index(gest)) + odh_tr.subjects.append(np.ones((len(odh_tr.data[-1]), 1)) * s_i) + odh_tr.reps.append(np.ones((len(odh_tr.data[-1]), 1)) * reps[gestures.index(gest)]) + reps[gestures.index(gest)] += 1 + if gesture_name in self.test_stages: + odh_te.data.append(np.hstack([emg_left[0:min_idx], emg_right[0:min_idx]])) + odh_te.labels.append(np.hstack([ja_left[0:min_idx], ja_right[0:min_idx]])) + odh_te.stages.append(np.ones((len(odh_te.data[-1]), 1)) * gestures.index(gest)) + odh_te.subjects.append(np.ones((len(odh_te.data[-1]), 1)) * s_i) + odh_te.reps.append(np.ones((len(odh_te.data[-1]), 1)) * reps[gestures.index(gest)]) + reps[gestures.index(gest)] += 1 + + if len(odh_tr.data) == 0 or len(odh_te.data) == 0: + print('Invalid Subject Information: Please confirm that the subject has the desired stages') + return None + + odh_all = odh_tr + odh_te + data = odh_all + if split: + data = {'All': odh_all, 'Train': odh_tr, 'Test': odh_te} + return data \ No newline at end of file diff --git a/libemg/_datasets/emg_epn612.py b/libemg/_datasets/emg_epn612.py new file mode 100644 index 00000000..5e7a35e8 --- /dev/null +++ b/libemg/_datasets/emg_epn612.py @@ -0,0 +1,119 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler +import pickle +import numpy as np +from libemg.feature_extractor import FeatureExtractor +from libemg.utils import * + +class EMGEPN612(Dataset): + def __init__(self, dataset_file='EMGEPN612.pkl', cross_user=True): + split = '50 Reps x 306 Users (Train), 25 Reps x 306 Users (Test) --> Cross User Split' + if not cross_user: + split = '20 Reps (Train), 5 Reps (Test) from the 612 Test Users --> User Dependent Split' + + Dataset.__init__(self, + 200, + 8, + 'Myo Armband', + 612, + {0: 'No Movement', 1: 'Hand Close', 2: 'Flexion', 3: 'Extension', 4: 'Hand Open', 5: 'Pinch'}, + split, + "A large 612 user dataset for developing cross user models.", + 'https://doi.org/10.5281/zenodo.4421500') + self.url = "https://unbcloud-my.sharepoint.com/:u:/g/personal/ecampbe2_unb_ca/EWf3sEvRxg9HuAmGoBG2vYkBLyFv6UrPYGwAISPDW9dBXw?e=vjCA14" + self.dataset_name = dataset_file + + def get_odh(self, subjects=None, feature_list = None, window_size = None, window_inc = None, feature_dic = None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_name)): + self.download_via_onedrive(self.url, self.dataset_name, unzip=False, clean=False) + + if feature_list or window_size or window_inc: + assert feature_list + assert window_size + assert window_inc + fe = FeatureExtractor() + + subject_list = np.array(list(range(0,612))) + if subjects: + subject_list = np.array(subjects) + + file = open(self.dataset_name, 'rb') + data = pickle.load(file) + + emg = data[0] + labels = data[2] + + odh_tr = OfflineDataHandler() + odh_tr.subjects = [] + odh_tr.classes = [] + odh_tr.reps = [] + tr_reps = [0,0,0,0,0,0] + odh_tr.extra_attributes = ['subjects', 'classes', 'reps'] + for i, e in enumerate(emg['training']): + if i // 300 not in subject_list: + continue + if feature_list: + odh_tr.data.append(fe.extract_features(feature_list, get_windows(e, window_size, window_inc), feature_dic=feature_dic, array=True)) + odh_tr.classes.append(np.ones((len(odh_tr.data[-1]), 1)) * labels['training'][i]) + odh_tr.subjects.append(np.ones((len(odh_tr.data[-1]), 1)) * i//300) + odh_tr.reps.append(np.ones((len(odh_tr.data[-1]), 1)) * tr_reps[labels['training'][i]]) + else: + odh_tr.data.append(e) + odh_tr.classes.append(np.ones((len(e), 1)) * labels['training'][i]) + odh_tr.subjects.append(np.ones((len(e), 1)) * i//300) + odh_tr.reps.append(np.ones((len(e), 1)) * tr_reps[labels['training'][i]]) + tr_reps[labels['training'][i]] += 1 + if i % 300 == 0: + tr_reps = [0,0,0,0,0,0] + odh_te = OfflineDataHandler() + odh_te.subjects = [] + odh_te.classes = [] + odh_te.reps = [] + te_reps = [0,0,0,0,0,0] + odh_te.extra_attributes = ['subjects', 'classes', 'reps'] + for i, e in enumerate(emg['testing']): + if (i // 150 + 306) not in subject_list: + continue + if feature_list: + odh_te.data.append(fe.extract_features(feature_list, get_windows(e, window_size, window_inc), feature_dic=feature_dic, array=True)) + odh_te.classes.append(np.ones((len(odh_te.data[-1]), 1)) * labels['testing'][i]) + odh_te.subjects.append(np.ones((len(odh_te.data[-1]), 1)) * (i//150 + 306)) + odh_te.reps.append(np.ones((len(odh_te.data[-1]), 1)) * te_reps[labels['testing'][i]]) + else: + odh_te.data.append(e) + odh_te.classes.append(np.ones((len(e), 1)) * labels['testing'][i]) + odh_te.subjects.append(np.ones((len(e), 1)) * (i//150 + 306)) + odh_te.reps.append(np.ones((len(e), 1)) * te_reps[labels['testing'][i]]) + te_reps[labels['testing'][i]] += 1 + if i % 150 == 0: + te_reps = [0,0,0,0,0,0] + + return odh_tr + odh_te + +class EMGEPN_UserDependent(EMGEPN612): + def __init__(self, dataset_file='EMGEPN612.pkl'): + EMGEPN612.__init__(self, dataset_file=dataset_file, cross_user=False) + + def prepare_data(self, split = True, subjects = None): + odh = self.get_odh(subjects) + odh_tr = odh.isolate_data('reps', list(range(0,20))) + odh_te = odh.isolate_data('reps', list(range(20,25))) + + if split: + data = {'All': odh, 'Train': odh_tr, 'Test': odh_te} + return data + +class EMGEPN_UserIndependent(EMGEPN612): + def __init__(self, dataset_file='EMGEPN612.pkl'): + EMGEPN612.__init__(self, dataset_file=dataset_file, cross_user=True) + + def prepare_data(self, split = True, subjects=None, feature_list = None, window_size = None, window_inc = None, feature_dic = None): + odh = self.get_odh(subjects, feature_list, window_size, window_inc, feature_dic) + odh_tr = odh.isolate_data('subjects', values=list(range(0,306))) + odh_te = odh.isolate_data('subjects', values=list(range(306,612))) + if split: + data = {'All': odh_tr + odh_te, 'Train': odh_tr, 'Test': odh_te} + return data + + \ No newline at end of file diff --git a/libemg/_datasets/fors_emg.py b/libemg/_datasets/fors_emg.py new file mode 100644 index 00000000..d8390c4b --- /dev/null +++ b/libemg/_datasets/fors_emg.py @@ -0,0 +1,56 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import scipy.io +import numpy as np + +class FORSEMG(Dataset): + def __init__(self, dataset_folder='FORS-EMG/'): + Dataset.__init__(self, + 985, + 8, + 'Experimental Device', + 19, + {0: 'Thump Up', 1: 'Index', 2: 'Right Angle', 3: 'Peace', 4: 'Index Little', 5: 'Thumb Little', 6: 'Hand Close', 7: 'Hand Open', 8: 'Wrist Flexion', 9: 'Wrist Extension', 10: 'Ulnar Deviation', 11: 'Radial Deviation'}, + '5 Train, 10 Test (2 Forarm Orientations x 5 Reps)', + "FORS-EMG: Twelve gestures elicited in three forearm orientations (neutral, pronation, and supination).", + 'https://arxiv.org/abs/2409.07484t') + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + print("Please download the dataset from: https://www.kaggle.com/datasets/ummerummanchaity/fors-emg-a-novel-semg-dataset?resource=download") + return + + odh = OfflineDataHandler() + odh.subjects = [] + odh.classes = [] + odh.reps = [] + odh.orientation = [] + odh.extra_attributes = ['subjects', 'classes', 'reps', 'orientation'] + + subject_list = np.array(list(range(1,20))) + if subjects: + subject_list = subject_list[subjects] + + for s in subject_list: + for g_i, g in enumerate(['Thumb_UP', 'Index', 'Right_Angle', 'Peace', 'Index_Little', 'Thumb_Little', 'Hand_Close', 'Hand_Open', 'Wrist_Flexion', 'Wrist_Extension', 'Radial_Deviation']): + for r in [1,2,3,4,5]: + for o_i, o in enumerate(['Rest', 'Pronation', 'Supination']): + try: + mat = scipy.io.loadmat('FORS-EMG/Subject' + str(s) + '/' + o + '/' + g + '-' + str(r) + '.mat') + except: + o = o.lower() + mat = scipy.io.loadmat('FORS-EMG/Subject' + str(s) + '/' + o + '/' + g + '-' + str(r) + '.mat') + + odh.data.append(mat['value'].T) + odh.classes.append(np.ones((len(odh.data[-1]), 1)) * g_i) + odh.subjects.append(np.ones((len(odh.data[-1]), 1)) * s-1) + odh.reps.append(np.ones((len(odh.data[-1]), 1)) * r-1) + odh.orientation.append(np.ones((len(odh.data[-1]), 1)) * o_i) + + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data('orientation', [0], fast=True), 'Test': odh.isolate_data('orientation', [1,2], fast=True)} + + return data diff --git a/libemg/_datasets/fougner_lp.py b/libemg/_datasets/fougner_lp.py new file mode 100644 index 00000000..4a287421 --- /dev/null +++ b/libemg/_datasets/fougner_lp.py @@ -0,0 +1,46 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class FougnerLP(Dataset): + def __init__(self, dataset_folder="LimbPosition/"): + Dataset.__init__(self, + 1000, + 8, + 'BE328 by Liberating Technologies, Inc.', + 12, + {0: 'Wrist Flexion', 1: 'Wrist Extension', 2: 'Pronation', 3: 'Supination', 4: 'Hand Open', 5: 'Power Grip', 6: 'Pinch Grip', 7: 'Rest'}, + '10 Reps (Train), 10 Reps x 4 Positions', + "A limb position dataset (with 5 static limb positions).", + "https://ieeexplore.ieee.org/document/5985538") + self.url = "https://github.com/libemg/LimbPosition" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + subject_list = np.array(list(range(1,13))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + + position_values = ["1", "2", "3", "4", "5"] + classes_values = ["1", "2", "3", "4", "5", "8", "9", "12"] + reps_values = ["1","2","3","4","5","6","7","8","9","10"] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound="S", right_bound="_C",values=subjects_values, description='subjects'), + RegexFilter(left_bound = "_P", right_bound="_R", values = position_values, description='positions'), + RegexFilter(left_bound = "_C", right_bound="_P", values = classes_values, description='classes'), + RegexFilter(left_bound = "_R", right_bound=".txt", values = reps_values, description='reps'), + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder + 'FougnerLimbPosition/', regex_filters=regex_filters, delimiter=",") + odh = odh.isolate_channels(list(range(0,8))) + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("positions", [0], fast=True), 'Test': odh.isolate_data("positions", list(range(1, len(position_values))), fast=True)} + + return data \ No newline at end of file diff --git a/libemg/_datasets/grab_myo.py b/libemg/_datasets/grab_myo.py new file mode 100644 index 00000000..232d26e7 --- /dev/null +++ b/libemg/_datasets/grab_myo.py @@ -0,0 +1,103 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class GRABMyo(Dataset): + """ + By default this just uses the 16 forearm electrodes. + """ + def __init__(self, dataset_folder='GRABMyo/', baseline=False, cross_user=False): + if not cross_user: + split = '7 Train, 14 Test (2 Seperate Days x 7 Reps) --> Cross Day Split' + if baseline: + split = '5 Train, 2 Test --> Baseline Split' + else: + split = '30 Subjects x 3 Sessions (Train) - 14 Subjects x 3 Sessions (Test) --> Cross User Split' + Dataset.__init__(self, + 2048, + 16, + 'EMGUSB2+ device (OT Bioelletronica, Italy)', + 43, + {0: 'Lateral Prehension', 1: 'Thumb Adduction', 2: 'Thumb and Little Finger Opposition', 3: 'Thumb and Index Finger Opposition', 4: 'Thumb and Index Finger Extension', 5: 'Thumb and Little Finger Extension', 6: 'Index and Middle Finger Extension', + 7: 'Little Finger Extension', 8: 'Index Finger Extension', 9: 'Thumb Finger Extension', 10: 'Wrist Extension', 11: 'Wrist Flexion', 12: 'Forearm Supination', 13: 'Forearm Pronation', 14: 'Hand Open', 15: 'Hand Close', 16: 'Rest'}, + split, + "GrabMyo: A large cross session dataset including 17 gestures elicited across 3 seperate sessions.", + 'https://www.nature.com/articles/s41597-022-01836-y') + self.dataset_folder = dataset_folder + + def get_odh(self, subjects = None): + self.check_if_exist() + + sessions = ["1", "2", "3"] + subject_list = np.array(list(range(1,44))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + classes_values = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"] + reps_values = ["1","2","3","4","5","6","7"] + + regex_filters = [ + RegexFilter(left_bound = "session", right_bound="_", values = sessions, description='sessions'), + RegexFilter(left_bound = "_gesture", right_bound="_", values = classes_values, description='classes'), + RegexFilter(left_bound = "trial", right_bound=".hea", values = reps_values, description='reps'), + RegexFilter(left_bound="participant", right_bound="_",values=subjects_values, description='subjects') + ] + + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") + + return odh.isolate_channels(list(range(0,16))) + + def check_if_exist(self): + if (not self.check_exists(self.dataset_folder)): + print("Please download the GRABMyo dataset from: https://physionet.org/content/grabmyo/1.0.2/") + return + print('\nPlease cite: ' + self.citation+'\n') + +class GrabMyoCrossUser(GRABMyo): + def __init__(self, dataset_folder="GRABMyo"): + GRABMyo.__init__(self, dataset_folder=dataset_folder, baseline=False) + + def prepare_data(self, split = True): + forearm_data = self.get_odh() + + train_data = forearm_data.isolate_data('subjects', list(range(0,30)), fast=True) + test_data = forearm_data.isolate_data('subjects', list(range(30,43)), fast=True) + + data = forearm_data + if split: + data = {'All': forearm_data, 'Train': train_data, 'Test': test_data} + + return data + +class GRABMyoCrossDay(GRABMyo): + def __init__(self, dataset_folder="GRABMyo"): + GRABMyo.__init__(self, dataset_folder=dataset_folder, baseline=False) + + def prepare_data(self, split = True, subjects = None): + forearm_data = self.get_odh(subjects) + train_data = forearm_data.isolate_data('sessions', [0], fast=True) + test_data = forearm_data.isolate_data('sessions', [1,2], fast=True) + + data = forearm_data + if split: + data = {'All': forearm_data, 'Train': train_data, 'Test': test_data} + + return data + +class GRABMyoBaseline(GRABMyo): + def __init__(self, dataset_folder="GRABMyo"): + GRABMyo.__init__(self, dataset_folder=dataset_folder, baseline=True) + + def prepare_data(self, split = True, subjects = None): + forearm_data = self.get_odh(subjects) + forearm_data = forearm_data.isolate_data('sessions', [0]) + + train_data = forearm_data.isolate_data('reps', [0,1,2,3,4], fast=True) + test_data = forearm_data.isolate_data('reps', [5,6], fast=True) + + data = forearm_data + if split: + data = {'All': forearm_data, 'Train': train_data, 'Test': test_data} + + return data \ No newline at end of file diff --git a/libemg/_datasets/hyser.py b/libemg/_datasets/hyser.py new file mode 100644 index 00000000..34b94188 --- /dev/null +++ b/libemg/_datasets/hyser.py @@ -0,0 +1,377 @@ +from abc import ABC, abstractmethod +from copy import deepcopy +from pathlib import Path +from typing import Sequence + +import numpy as np + +from libemg.data_handler import RegexFilter, FilePackager, OfflineDataHandler, MetadataFetcher +from libemg._datasets.dataset import Dataset + + +class _Hyser(Dataset, ABC): + def __init__(self, gestures, num_reps, description, dataset_folder, analysis = 'baseline'): + super().__init__( + sampling=2048, + num_channels=256, + recording_device='OT Bioelettronica Quattrocento', + num_subjects=20, + gestures=gestures, + num_reps=num_reps, + description=description, + citation='https://doi.org/10.13026/ym7v-bh53' + ) + subjects = [str(idx + 1).zfill(2) for idx in range(self.num_subjects)] # +1 due to Python indexing + + self.url = 'https://www.physionet.org/content/hd-semg/1.0.0/' + self.dataset_folder = dataset_folder + self.analysis = analysis + self.subjects = subjects + + @property + def common_regex_filters(self): + sessions_values = ['1', '2'] if self.analysis == 'sessions' else ['1'] # only grab first session unless both are desired + filters = [ + RegexFilter(left_bound='subject', right_bound='_session', values=self.subjects, description='subjects'), + RegexFilter(left_bound='_session', right_bound='/', values=sessions_values, description='sessions') + ] + return filters + + def prepare_data(self, split = True, subjects = None): + if (not self.check_exists(self.dataset_folder)): + raise FileNotFoundError(f"Didn't find Hyser data in {self.dataset_folder} directory. Please download the dataset and \ + store it in the appropriate directory before running prepare_data(). See {self.url} for download details.") + return self._prepare_data_helper(split=split, subjects = subjects) + + @abstractmethod + def _prepare_data_helper(self, split = True, subjects = None) -> dict | OfflineDataHandler: + ... + + +class Hyser1DOF(_Hyser): + def __init__(self, dataset_folder: str = 'Hyser1DOF', analysis: str = 'baseline'): + """1 degree of freedom (DOF) Hyser dataset. + + Parameters + ---------- + dataset_folder: str, default='Hyser1DOF' + Directory that contains Hyser 1 DOF dataset. + analysis: str, default='baseline' + Determines which type of data will be extracted and considered train/test splits. If 'baseline', only grabs data from the first session and splits based on + reps. If 'sessions', grabs data from both sessions and return the first session as train and the second session as test. + """ + gestures = {1: 'Thumb', 2: 'Index', 3: 'Middle', 4: 'Ring', 5: 'Little'} + description = 'Hyser 1 DOF dataset. Includes within-DOF finger movements. Ground truth finger forces are recorded for use in finger force regression.' + super().__init__(gestures=gestures, num_reps=3, description=description, dataset_folder=dataset_folder, analysis=analysis) + + def _prepare_data_helper(self, split = True, subjects = None): + subject_list = np.array(list(range(1,21))) + if subjects: + subject_list = subject_list[subjects] + self.subjects = [f'{s:02d}' for s in subject_list] + + filename_filters = deepcopy(self.common_regex_filters) + filename_filters.append(RegexFilter(left_bound='_sample', right_bound='.hea', values=[str(idx + 1) for idx in range(self.num_reps)], description='reps')) + filename_filters.append(RegexFilter(left_bound='_finger', right_bound='_sample', values=['1', '2', '3', '4', '5'], description='finger')) + + regex_filters = deepcopy(filename_filters) + regex_filters.append(RegexFilter(left_bound='1dof_', right_bound='_finger', values=['raw'], description='data_type')) + + metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='/1dof_', right_bound='_finger', values=['force'], description='labels'), + package_function=filename_filters, load='p_signal') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + data = odh + if split: + if self.analysis == 'sessions': + data = {'All': odh, 'Train': odh.isolate_data('sessions', [0], fast=True), 'Test': odh.isolate_data('sessions', [1], fast=True)} + elif self.analysis == 'baseline': + data = {'All': odh, 'Train': odh.isolate_data('reps', [0, 1], fast=True), 'Test': odh.isolate_data('reps', [2], fast=True)} + else: + raise ValueError(f"Unexpected value for analysis. Suported values are sessions, baseline. Got: {self.analysis}.") + return data + + +class HyserNDOF(_Hyser): + def __init__(self, dataset_folder: str = 'HyserNDOF', analysis: str = 'baseline'): + """N degree of freedom (DOF) Hyser dataset. + + Parameters + ---------- + dataset_folder: str, default='HyserNDOF' + Directory that contains Hyser N DOF dataset. + analysis: str, default='baseline' + Determines which type of data will be extracted and considered train/test splits. If 'baseline', only grabs data from the first session and splits based on + reps. If 'sessions', grabs data from both sessions and return the first session as train and the second session as test. + """ + self.finger_combinations = { + 1: 'Thumb + Index', + 2: 'Thumb + Middle', + 3: 'Thumg + Ring', + 4: 'Thumb + Little', + 5: 'Index + Middle', + 6: 'Thumb + Index + Middle', + 7: 'Index + Middle + Ring', + 8: 'Middle + Ring + Little', + 9: 'Index + Middle + Ring + Little', + 10: 'All Fingers', + 11: 'Thumb + Index (Opposing)', + 12: 'Thumb + Middle (Opposing)', + 13: 'Thumg + Ring (Opposing)', + 14: 'Thumb + Little (Opposing)', + 15: 'Index + Middle (Opposing)' + } + description = 'Hyser N DOF dataset. Includes combined finger movements. Ground truth finger forces are recorded for use in finger force regression.' + super().__init__(gestures=self.finger_combinations, num_reps=2, description=description, dataset_folder=dataset_folder, analysis=analysis) + + def _prepare_data_helper(self, split = True, subjects = None) -> dict | OfflineDataHandler: + subject_list = np.array(list(range(1,21))) + if subjects: + subject_list = subject_list[subjects] + self.subjects = [f'{s:02d}' for s in subject_list] + + filename_filters = deepcopy(self.common_regex_filters) + filename_filters.append(RegexFilter(left_bound='_sample', right_bound='.hea', values=[str(idx + 1) for idx in range(self.num_reps)], description='reps')) + filename_filters.append(RegexFilter(left_bound='_combination', right_bound='_sample', values=[str(idx + 1) for idx in range(len(self.finger_combinations))], description='finger_combinations')) + + regex_filters = deepcopy(filename_filters) + regex_filters.append(RegexFilter(left_bound='/ndof_', right_bound='_combination', values=['raw'], description='data_type')) + + metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='/ndof_', right_bound='_combination', values=['force'], description='labels'), + package_function=filename_filters, load='p_signal') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + data = odh + if split: + if self.analysis == 'sessions': + data = {'All': odh, 'Train': odh.isolate_data('sessions', [0], fast=True), 'Test': odh.isolate_data('sessions', [1], fast=True)} + elif self.analysis == 'baseline': + data = {'All': odh, 'Train': odh.isolate_data('reps', [0], fast=True), 'Test': odh.isolate_data('reps', [1], fast=True)} + else: + raise ValueError(f"Unexpected value for analysis. Suported values are sessions, baseline. Got: {self.analysis}.") + + return data + + +class HyserRandom(_Hyser): + def __init__(self, dataset_folder: str = 'HyserRandom', analysis: str = 'baseline'): + """Random task (DOF) Hyser dataset. + + Parameters + ---------- + dataset_folder: str, default='HyserRandom' + Directory that contains Hyser random task dataset. + analysis: str, default='baseline' + Determines which type of data will be extracted and considered train/test splits. If 'baseline', only grabs data from the first session and splits based on + reps. If 'sessions', grabs data from both sessions and return the first session as train and the second session as test. + """ + description = 'Hyser random dataset. Includes random motions performed by users. Ground truth finger forces are recorded for use in finger force regression.' + super().__init__(gestures={}, num_reps=5, description=description, dataset_folder=dataset_folder, analysis=analysis) + self.num_subjects = 19 + + + def _prepare_data_helper(self, split = True, subjects = None) -> dict | OfflineDataHandler: + subject_list = np.delete(np.array(list(range(1,21))), 9) + if subjects: + subject_list = subject_list[subjects] + self.subjects = [f'{s:02d}' for s in subject_list] + + filename_filters = deepcopy(self.common_regex_filters) + filename_filters.append(RegexFilter(left_bound='_sample', right_bound='.hea', values=[str(idx + 1) for idx in range(self.num_reps)], description='reps')) + + regex_filters = deepcopy(filename_filters) + regex_filters.append(RegexFilter(left_bound='/random_', right_bound='_sample', values=['raw'], description='data_type')) + + metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='/random_', right_bound='_sample', values=['force'], description='labels'), + package_function=filename_filters, load='p_signal') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + + data = odh + if split: + if self.analysis == 'sessions': + data = {'All': odh, 'Train': odh.isolate_data('sessions', [0], fast=True), 'Test': odh.isolate_data('sessions', [1], fast=True)} + elif self.analysis == 'baseline': + data = {'All': odh, 'Train': odh.isolate_data('reps', [0, 1, 2], fast=True), 'Test': odh.isolate_data('reps', [3, 4], fast=True)} + else: + raise ValueError(f"Unexpected value for analysis. Suported values are sessions, baseline. Got: {self.analysis}.") + + return data + + +class _PRLabelsFetcher(MetadataFetcher): + def __init__(self): + super().__init__(description='classes') + self.sample_regex = RegexFilter(left_bound='_sample', right_bound='.hea', values=[str(idx + 1) for idx in range(204)], description='samples') + + def _get_labels(self, filename): + label_filename_map = { + 'dynamic': 'label_dynamic.txt', + 'maintenance': 'label_maintenance.txt' + } + matches = [] + for task_type, labels_file in label_filename_map.items(): + if task_type in filename: + matches.append(labels_file) + + assert len(matches) == 1, f"Expected a single label file for this file, but got {len(matches)}. Got filename: {filename}. Filename should contain either 'dynamic' or 'maintenance'." + + labels_file = matches[0] + parent = Path(filename).absolute().parent + labels_file = Path(parent, labels_file).as_posix() + return np.loadtxt(labels_file, delimiter=',', dtype=int) + + def __call__(self, filename, file_data, all_files): + labels = self._get_labels(filename) + sample_idx = self.sample_regex.get_metadata(filename) + return labels[sample_idx] - 1 # -1 to produce 0-indexed labels + + +class _PRRepFetcher(_PRLabelsFetcher): + def __init__(self): + super().__init__() + self.description = 'reps' + + def __call__(self, filename, file_data, all_files): + label = super().__call__(filename, file_data, all_files) + 1 # +1 b/c this returns 0-indexed labels, but the files are 1-indexed + labels = self._get_labels(filename) + same_label_mask = np.where(labels == label)[0] + sample_idx = self.sample_regex.get_metadata(filename) + rep_idx = list(same_label_mask).index(sample_idx) + if 'dynamic' in filename: + # Each trial is 3 dynamic reps, 1 maintenance rep + rep_idx = rep_idx // 3 + + assert rep_idx <= 1, f"Rep values should be 0 or 1 (2 total reps). Got: {rep_idx}." + return np.array(rep_idx) + + +class HyserPR(_Hyser): + def __init__(self, dataset_folder: str = 'HyserPR', analysis: str = 'baseline'): + """Pattern recognition (PR) Hyser dataset. + + Parameters + ---------- + dataset_folder: str, default='HyserPR' + Directory that contains Hyser PR dataset. + analysis: str, default='baseline' + Determines which type of data will be extracted and considered train/test splits. If 'baseline', only grabs data from the first session and splits based on + reps. If 'sessions', grabs data from both sessions and return the first session as train and the second session as test. + """ + gestures = { + 1: 'Thumb Extension', + 2: 'Index Finger Extension', + 3: 'Middle Finger Extension', + 4: 'Ring Finger Extension', + 5: 'Little Finger Extension', + 6: 'Wrist Flexion', + 7: 'Wrist Extension', + 8: 'Wrist Radial', + 9: 'Wrist Ulnar', + 10: 'Wrist Pronation', + 11: 'Wrist Supination', + 12: 'Extension of Thumb and Index Fingers', + 13: 'Extension of Index and Middle Fingers', + 14: 'Wrist Flexion Combined with Hand Close', + 15: 'Wrist Extension Combined with Hand Close', + 16: 'Wrist Radial Combined with Hand Close', + 17: 'Wrist Ulnar Combined with Hand Close', + 18: 'Wrist Pronation Combined with Hand Close', + 19: 'Wrist Supination Combined with Hand Close', + 20: 'Wrist Flexion Combined with Hand Open', + 21: 'Wrist Extension Combined with Hand Open', + 22: 'Wrist Radial Combined with Hand Open', + 23: 'Wrist Ulnar Combined with Hand Open', + 24: 'Wrist Pronation Combined with Hand Open', + 25: 'Wrist Supination Combined with Hand Open', + 26: 'Extension of Thumb, Index and Middle Fingers', + 27: 'Extension of Index, Middle and Ring Fingers', + 28: 'Extension of Middle, Ring and Little Fingers', + 29: 'Extension of Index, Middle, Ring and Little Fingers', + 30: 'Hand Close', + 31: 'Hand Open', + 32: 'Thumb and Index Fingers Pinch', + 33: 'Thumb, Index and Middle Fingers Pinch', + 34: 'Thumb and Middle Fingers Pinch' + } + description = 'Hyser pattern recognition (PR) dataset. Includes dynamic and maintenance tasks for 34 hand gestures.' + super().__init__(gestures=gestures, num_reps=2, description=description, dataset_folder=dataset_folder, analysis=analysis) # num_reps=2 b/c 2 trials + self.num_subjects = 18 # Removed 2 subjects because they're missing classes + + def _prepare_data_helper(self, split = True, subjects = None) -> dict | OfflineDataHandler: + # Need to remove subjects 3 and 11 b/c they're missing classes + subject_list = np.delete(np.array(list(range(1,21))), [2,10]) + if subjects: + subject_list = subject_list[subjects] + + self.subjects = [f'{s:02d}' for s in subject_list] + + filename_filters = deepcopy(self.common_regex_filters) + filename_filters.append(RegexFilter(left_bound='_sample', right_bound='.hea', values=[str(idx + 1) for idx in range(204)], description='samples')) # max # of dynamic tasks + filename_filters.append(RegexFilter(left_bound='/', right_bound='_', values=['dynamic', 'maintenance'], description='tasks')) + + regex_filters = deepcopy(filename_filters) + regex_filters.append(RegexFilter(left_bound='_', right_bound='_sample', values=['raw'], description='data_type')) + + metadata_fetchers = [ + _PRLabelsFetcher(), + _PRRepFetcher() + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + + data = odh + if split: + if self.analysis == 'sessions': + data = {'All': odh, 'Train': odh.isolate_data('sessions', [0], fast=True), 'Test': odh.isolate_data('sessions', [1], fast=True)} + elif self.analysis == 'baseline': + data = {'All': odh, 'Train': odh.isolate_data('reps', [0], fast=True), 'Test': odh.isolate_data('reps', [1], fast=True)} + else: + raise ValueError(f"Unexpected value for analysis. Suported values are sessions, baseline. Got: {self.analysis}.") + + return data + + +class HyserMVC(_Hyser): + def __init__(self, dataset_folder: str = 'HyserMVC'): + """Maximum voluntary contraction (MVC) Hyser dataset. + + Parameters + ---------- + dataset_folder: str, default='HyserMVC' + Directory that contains the Hyser MVC dataset. + """ + gestures = {1: 'Thumb', 2: 'Index', 3: 'Middle', 4: 'Ring', 5: 'Little'} + description = 'Hyser maximum voluntary contraction (MVC) dataset. Includes MVC for flexion and extension of each finger. Typically used for normalization of other Hyser datasets.' + super().__init__(gestures=gestures, num_reps=5, description=description, dataset_folder=dataset_folder, analysis='sessions') + + def _prepare_data_helper(self, split=True, subjects=None): + subject_list = np.array(list(range(1,21))) + if subjects: + subject_list = subject_list[subjects] + self.subjects = [f'{s:02d}' for s in subject_list] + + filename_filters = deepcopy(self.common_regex_filters) + filename_filters.append(RegexFilter(left_bound='_', right_bound='.hea', values=['flexion', 'extension'], description='movement')) + filename_filters.append(RegexFilter(left_bound='_finger', right_bound='_', values=['1', '2', '3', '4', '5'], description='finger')) + + regex_filters = deepcopy(filename_filters) + regex_filters.append(RegexFilter(left_bound='mvc_', right_bound='_finger', values=['raw'], description='data_type')) + + metadata_fetchers = [ + FilePackager(RegexFilter(left_bound='/mvc_', right_bound='_finger', values=['force'], description='labels'), + package_function=filename_filters, load='p_signal') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + data = odh + if split: + # Split on different sessions (no split for within-session) + data = {'All': odh, 'Train': odh.isolate_data('sessions', [0], fast=True), 'Test': odh.isolate_data('sessions', [1], fast=True)} + return data diff --git a/libemg/_datasets/intensity.py b/libemg/_datasets/intensity.py new file mode 100644 index 00000000..863a40de --- /dev/null +++ b/libemg/_datasets/intensity.py @@ -0,0 +1,44 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class ContractionIntensity(Dataset): + def __init__(self, dataset_folder="ContractionIntensity/"): + Dataset.__init__(self, + 1000, + 8, + 'BE328 by Liberating Technologies, Inc', + 10, + {0: "No Motion", 1: "Wrist Flexion", 2: "Wrist Extension", 3: "Wrist Pronation", 4: "Wrist Supination", 5: "Chuck Grip", 6: "Hand Open"}, + '4 Ramp Reps (Train), 4 Reps x 20%, 30%, 40%, 50%, 60%, 70%, 80%, MVC (Test)', + "A contraction intensity dataset.", + "https://pubmed.ncbi.nlm.nih.gov/23894224/") + self.url = "https://github.com/libemg/ContractionIntensity" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + subject_list = np.array(list(range(1,11))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + intensity_values = ["Ramp", "20P", "30P", "40P", "50P", "60P", "70P", "80P", "MVC"] + classes_values = [str(i) for i in range(1,8)] + reps_values = ["1","2","3","4"] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound="/S", right_bound="/",values=subjects_values, description='subjects'), + RegexFilter(left_bound = "_", right_bound="_C", values = intensity_values, description='intensities'), + RegexFilter(left_bound = "_C", right_bound="_R", values = classes_values, description='classes'), + RegexFilter(left_bound = "_R", right_bound=".csv", values = reps_values, description='reps'), + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("intensities", [0], fast=True), 'Test': odh.isolate_data("intensities", list(range(1, len(intensity_values))), fast=True)} + + return data \ No newline at end of file diff --git a/libemg/_datasets/kaufmann_md.py b/libemg/_datasets/kaufmann_md.py new file mode 100644 index 00000000..368ea203 --- /dev/null +++ b/libemg/_datasets/kaufmann_md.py @@ -0,0 +1,40 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter + +class KaufmannMD(Dataset): + def __init__(self, dataset_folder="MultiDay/"): + Dataset.__init__(self, + 2048, + 4, + 'MindMedia', + 1, + {0: "No Motion", 1:"Wrist Extension", 2:"Wrist Flexion", 3:"Wrist Adduction", + 4:"Wrist Abduction", 5:"Wrist Supination", 6:"Wrist Pronation", 7:"Hand Open", + 8:"Hand Closed", 9:"Key Grip", 10:"Index Point"}, + '1 rep per day, 120 days total. 60/60 train-test split', + "A single subject, multi-day (120) collection.", + "https://ieeexplore.ieee.org/document/5627288") + self.url = "https://github.com/LibEMG/MultiDay" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + subjects_values = ["0"] + day_values = [str(i) for i in range(1,122)] + classes_values = [str(i) for i in range(11)] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound="/S", right_bound="_D",values=subjects_values, description='subjects'), + RegexFilter(left_bound = "_D", right_bound="_C", values = day_values, description='days'), + RegexFilter(left_bound = "_C", right_bound=".csv", values = classes_values, description='classes'), + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=" ") + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("days", list(range(60)), fast=True), 'Test': odh.isolate_data("days", list(range(60,121)), fast=True)} + + return data \ No newline at end of file diff --git a/libemg/_datasets/nina_pro.py b/libemg/_datasets/nina_pro.py new file mode 100644 index 00000000..0a9e30a6 --- /dev/null +++ b/libemg/_datasets/nina_pro.py @@ -0,0 +1,260 @@ +from pathlib import Path + +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter, ColumnFetcher +import os +import scipy.io as sio +import zipfile +import numpy as np +from sklearn.preprocessing import MinMaxScaler + +def find_all_files_of_type_recursively(dir, terminator): + files = os.listdir(dir) + file_list = [] + for file in files: + if file.endswith(terminator): + file_list.append(dir+file) + else: + if os.path.isdir(dir+file): + file_list += find_all_files_of_type_recursively(dir+file+'/',terminator) + return file_list + +class Ninapro(Dataset): + def __init__(self, + sampling, num_channels, recording_device, num_subjects, gestures, num_reps, description, citation, + dataset_folder="Ninapro"): + # downloading the Ninapro dataset is not supported (no permission given from the authors)' + # however, you can download it from http://ninapro.hevs.ch/DB8 + # the subject zip files should be placed at: /NinaproDB8/DB8_s#.zip + Dataset.__init__(self, sampling, num_channels, recording_device, num_subjects, gestures, num_reps, description, citation) + self.dataset_folder = dataset_folder + self.exercise_step = [] + + def convert_to_compatible(self): + # get the zip files (original format they're downloaded in) + zip_files = find_all_files_of_type_recursively(self.dataset_folder,".zip") + # unzip the files -- if any are there (successive runs skip this) + for zip_file in zip_files: + with zipfile.ZipFile(zip_file, 'r') as zip_ref: + zip_ref.extractall(zip_file[:-4]+'/') + os.remove(zip_file) + # get the mat files (the files we want to convert to csv) + mat_files = find_all_files_of_type_recursively(self.dataset_folder,".mat") + for mat_file in mat_files: + self.convert_to_csv(mat_file) + + def convert_to_csv(self, mat_file): + # read the mat file + mat_file = mat_file.replace("\\", "/") + mat_dir = mat_file.split('/') + mat_dir = os.path.join(*mat_dir[:-1],"") + mat = sio.loadmat(mat_file) + # get the data + exercise = int(mat_file.split('_')[-1][1]) + exercise_offset = self.exercise_step[exercise-1] # 0 reps already included + data = mat['emg'] + restimulus = mat['restimulus'] + rerepetition = mat['rerepetition'] + try: + cyberglove_data = mat['glove'] + cyberglove_directory = 'cyberglove' + except KeyError: + # No cyberglove data + cyberglove_data = None + cyberglove_directory = '' + if data.shape[0] != restimulus.shape[0]: # this happens in some cases + min_shape = min([data.shape[0], restimulus.shape[0]]) + data = data[:min_shape,:] + restimulus = restimulus[:min_shape,] + rerepetition = rerepetition[:min_shape,] + if cyberglove_data is not None: + cyberglove_data = cyberglove_data[:min_shape,] + # remove 0 repetition - collection buffer + remove_mask = (rerepetition != 0).squeeze() + data = data[remove_mask,:] + restimulus = restimulus[remove_mask] + rerepetition = rerepetition[remove_mask] + if cyberglove_data is not None: + cyberglove_data = cyberglove_data[remove_mask, :] + # important little not here: + # the "rest" really is only the rest between motions, not a dedicated rest class. + # there will be many more rest repetitions (as it is between every class) + # so usually we really care about classifying rest as its important (most of the time we do nothing) + # but for this dataset it doesn't make sense to include (and not its just an offline showcase of the library) + # I encourage you to plot the restimulus to see what I mean. -> plt.plot(restimulus) + # so we remove the rest class too + remove_mask = (restimulus != 0).squeeze() + data = data[remove_mask,:] + restimulus = restimulus[remove_mask] + rerepetition = rerepetition[remove_mask] + if cyberglove_data is not None: + cyberglove_data = cyberglove_data[remove_mask, :] + tail = 0 + while tail < data.shape[0]-1: + rep = rerepetition[tail][0] # remove the 1 offset (0 was the collection buffer) + motion = restimulus[tail][0] # remove the 1 offset (0 was between motions "rest") + # find head + head = np.where(rerepetition[tail:] != rep)[0] + if head.shape == (0,): # last segment of data + head = data.shape[0] -1 + else: + head = head[0] + tail + if cyberglove_data is not None: + # Combine cyberglove and EMG data + data_for_file = np.concatenate((data[tail:head, :], cyberglove_data[tail:head, :]), axis=1) + else: + data_for_file = data[tail:head,:] + + # downsample to 1kHz from 2kHz using decimation + data_for_file = data_for_file[::2, :] + # write to csv + csv_file = Path(mat_dir, cyberglove_directory, f"C{motion - 1}R{rep - 1 + exercise_offset}.csv") + csv_file.parent.mkdir(parents=True, exist_ok=True) + np.savetxt(csv_file, data_for_file, delimiter=',') + tail = head + os.remove(mat_file) + + +class NinaproDB2(Ninapro): + def __init__(self, dataset_folder="NinaProDB2/", use_cyberglove: bool = False): + Ninapro.__init__(self, + 2000, + 12, + 'Delsys', + 40, + {0: 'See Exercises B and C from: https://ninapro.hevs.ch/instructions/DB2.html'}, + '4 Train, 2 Test', + "NinaProb DB2.", + 'https://ninapro.hevs.ch/', + dataset_folder = dataset_folder) + self.exercise_step = [0,0,0] + self.num_cyberglove_dofs = 22 + self.use_cyberglove = use_cyberglove # needed b/c some files have EMG but no cyberglove + + def prepare_data(self, split = True, subjects = None): + subject_list = np.array(list(range(1,41))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + reps_values = [str(i) for i in range(6)] + classes_values = [str(i) for i in range(50)] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + raise FileNotFoundError("Please download the NinaProDB2 dataset from: https://ninapro.hevs.ch/instructions/DB2.html") + self.convert_to_compatible() + regex_filters = [ + RegexFilter(left_bound = "/C", right_bound="R", values = classes_values, description='classes'), + RegexFilter(left_bound="R", right_bound=".csv", values=reps_values, description='reps'), + RegexFilter(left_bound="DB2_s", right_bound="/",values=subjects_values, description='subjects') + ] + + if self.use_cyberglove: + # Only want cyberglove files + regex_filters.append(RegexFilter(left_bound="/", right_bound="/C", values=['cyberglove'], description='')) + metadata_fetchers = [ + ColumnFetcher('cyberglove', column_mask=[idx for idx in range(self.num_channels, self.num_channels + self.num_cyberglove_dofs)]) + ] + else: + metadata_fetchers = None + + emg_column_mask = [idx for idx in range(self.num_channels)] # first columns should be EMG + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers, delimiter=",", data_column=emg_column_mask) + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data('reps', [0,1,2,3], fast=True), 'Test': odh.isolate_data('reps', [4,5], fast=True)} + + return data + +class NinaproDB8(Ninapro): + def __init__(self, dataset_folder="NinaProDB8/", map_to_finger_dofs = True, normalize_labels = True): + # NOTE: This expects each subject's data to be in its own zip file, so the data files for one subject end up in a single directory once we unzip them (e.g., DB8_s1) + gestures = { + 0: "rest", + 1: "thumb flexion/extension", + 2: "thumb abduction/adduction", + 3: "index finger flexion/extension", + 4: "middle finger flexion/extension", + 5: "combined ring and little fingers flexion/extension", + 6: "index pointer", + 7: "cylindrical grip", + 8: "lateral grip", + 9: "tripod grip" + } + + super().__init__( + sampling=1111, + num_channels=16, + recording_device='Delsys Trigno', + num_subjects=12, + gestures=gestures, + num_reps=22, + description='Ninapro DB8 - designed for regression of finger kinematics. Ground truth labels are provided via cyberglove data.', + citation='https://ninapro.hevs.ch/', + dataset_folder=dataset_folder + ) + self.exercise_step = [0,10,20] + self.num_cyberglove_dofs = 18 + self.map_to_finger_dofs = map_to_finger_dofs + self.normalize_labels = normalize_labels + + def _remap_labels(self, odh): + # Linear mapping matrix pulled from original paper: https://www.frontiersin.org/journals/neuroscience/articles/10.3389/fnins.2019.00891/full + finger_map_matrix = np.array([ + [0.639, 0, 0, 0, 0], + [0.383, 0, 0, 0, 0], + [0, 1, 0, 0, 0], + [-0.639, 0, 0, 0, 0], + [0, 0, 0.4, 0, 0], + [0, 0, 0.6, 0, 0], + [0, 0, 0, 0.4, 0], + [0, 0, 0, 0.6, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0.1667], + [0, 0, 0, 0, 0.3333], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0.1667], + [0, 0, 0, 0, 0.3333], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [-0.19, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ]) + + remapped_labels = [] + for labels in odh.labels: + finger_labels = np.copy(labels) + if self.map_to_finger_dofs: + finger_labels = labels @ finger_map_matrix + if self.normalize_labels: + finger_labels = MinMaxScaler().fit_transform(finger_labels) + remapped_labels.append(finger_labels) + odh.labels = remapped_labels + return odh + + def prepare_data(self, split = True, subjects = None): + subjects_values = np.array([str(i) for i in range(1,self.num_subjects + 1)]) + if subjects: + subjects_values = subjects_values[subjects] + reps_values = [str(i) for i in range(self.num_reps)] + classes_values = [str(i) for i in range(9)] + + self.convert_to_compatible() + + regex_filters = [ + RegexFilter(left_bound = "/C", right_bound="R", values = classes_values, description='classes'), + RegexFilter(left_bound = "R", right_bound=".csv", values = reps_values, description='reps'), + RegexFilter(left_bound="DB8_s", right_bound="/",values=list(subjects_values), description='subjects') + ] + metadata_fetchers = [ + ColumnFetcher('labels', column_mask=[idx for idx in range(self.num_channels, self.num_channels + self.num_cyberglove_dofs)]) + ] + emg_column_mask = [idx for idx in range(self.num_channels)] # first columns should be EMG + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers, delimiter=",", data_column=emg_column_mask) + odh = self._remap_labels(odh) + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data('reps', [0, 1, 2, 3], fast=True), 'Test': odh.isolate_data('reps', [4, 5], fast=True)} + return data diff --git a/libemg/_datasets/one_subject_emager.py b/libemg/_datasets/one_subject_emager.py new file mode 100644 index 00000000..54dceebd --- /dev/null +++ b/libemg/_datasets/one_subject_emager.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import numpy as np +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter, FilePackager + + +class OneSubjectEMaGerDataset(Dataset): + def __init__(self, dataset_folder = 'OneSubjectEMaGerDataset/'): + super().__init__( + sampling=1010, + num_channels=64, + recording_device='EMaGer', + num_subjects=1, + gestures={0: 'Hand Close (-) / Hand Open (+)', 1: 'Pronation (-) / Supination (+)'}, + num_reps=5, + description='A simple EMaGer dataset used for regression examples in LibEMG demos.', + citation='N/A' + ) + self.url = 'https://github.com/LibEMG/OneSubjectEMaGerDataset' + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + regex_filters = [ + RegexFilter(left_bound='/', right_bound='/', values=['open-close', 'pro-sup'], description='movements'), + RegexFilter(left_bound='_R_', right_bound='_emg.csv', values=[str(idx) for idx in range(self.num_reps)], description='reps') + ] + package_function = lambda x, y: Path(x).parent.absolute() == Path(y).parent.absolute() + metadata_fetchers = [FilePackager(RegexFilter(left_bound='/', right_bound='.txt', values=['labels'], description='labels'), package_function)] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + odh.subjects = [] + odh.subjects = [np.zeros((len(d), 1)) for d in odh.data] + odh.extra_attributes.append('subjects') + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data('reps', [0, 1, 2, 3], fast=True), 'Test': odh.isolate_data('reps', [4], fast=True)} + + return data diff --git a/libemg/_datasets/one_subject_myo.py b/libemg/_datasets/one_subject_myo.py new file mode 100644 index 00000000..1c833e06 --- /dev/null +++ b/libemg/_datasets/one_subject_myo.py @@ -0,0 +1,40 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class OneSubjectMyoDataset(Dataset): + def __init__(self, dataset_folder="OneSubjectMyoDataset/"): + Dataset.__init__(self, + 200, + 8, + 'Myo Armband', + 1, + {0: 'Close', 1: 'Open', 2: 'Rest', 3: 'Flexion', 4: 'Extension'}, + '6 (4 Train, 2 Test)', + "A simple Myo dataset that is used for some of the LibEMG offline demos.", + 'N/A') + self.url = "https://github.com/libemg/OneSubjectMyoDataset" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects=None): + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + sets_values = ["1","2","3","4","5","6"] + classes_values = ["0","1","2","3","4"] + reps_values = ["0","1"] + regex_filters = [ + RegexFilter(left_bound = "/trial_", right_bound="/", values = sets_values, description='sets'), + RegexFilter(left_bound = "C_", right_bound=".csv", values = classes_values, description='classes'), + RegexFilter(left_bound = "R_", right_bound="_", values = reps_values, description='reps') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") + odh.subjects = [] + odh.subjects = [np.zeros((len(d), 1)) for d in odh.data] + odh.extra_attributes.append('subjects') + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("sets", [0,1,2,3,4], fast=True), 'Test': odh.isolate_data("sets", [5,6], fast=True)} + + return data diff --git a/libemg/_datasets/radmand_lp.py b/libemg/_datasets/radmand_lp.py new file mode 100644 index 00000000..c40883c2 --- /dev/null +++ b/libemg/_datasets/radmand_lp.py @@ -0,0 +1,44 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class RadmandLP(Dataset): + def __init__(self, dataset_folder="LimbPosition/"): + Dataset.__init__(self, + 1000, + 6, + 'DelsysTrigno', + 10, + {'N/A': 'Uncertain'}, + '4 Reps (Train), 4 Reps x 15 Positions', + "A large limb position dataset (with 16 static limb positions).", + "https://pubmed.ncbi.nlm.nih.gov/25570046/") + self.url = "https://github.com/libemg/LimbPosition" + self.dataset_folder = dataset_folder + + def prepare_data(self, split = True, subjects = None): + subject_list = np.array(list(range(1,11))) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + position_values = ["P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "P10", "P11", "P12", "P13", "P14", "P15", "P16"] + classes_values = [str(i) for i in range(1,9)] + reps_values = ["1","2","3","4"] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound="/S", right_bound="/",values=subjects_values, description='subjects'), + RegexFilter(left_bound = "_", right_bound="_R", values = position_values, description='positions'), + RegexFilter(left_bound = "_C", right_bound="_P", values = classes_values, description='classes'), + RegexFilter(left_bound = "_R", right_bound=".csv", values = reps_values, description='reps'), + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder + 'RadmandLimbPosition/', regex_filters=regex_filters, delimiter=",") + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("positions", [0], fast=True), 'Test': odh.isolate_data("positions", list(range(1, len(position_values))), fast=True)} + + return data \ No newline at end of file diff --git a/libemg/_datasets/tmr_shirleyryanabilitylab.py b/libemg/_datasets/tmr_shirleyryanabilitylab.py new file mode 100644 index 00000000..56c001db --- /dev/null +++ b/libemg/_datasets/tmr_shirleyryanabilitylab.py @@ -0,0 +1,94 @@ +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter +import numpy as np + +class TMRShirleyRyanAbilityLab(Dataset): + def __init__(self, dataset_folder="TMR/", desc=''): + Dataset.__init__(self, + 1000, + 32, + 'Ag/AgCl', + 6, + {0:"HandOpen", + 1:"KeyGrip", + 2:"PowerGrip", + 3:"FinePinchOpened", + 4:"FinePinchClosed", + 5:"TripodOpened", + 6:"TripodClosed", + 7:"Tool", + 8:"Hook", + 9:"IndexPoint", + 10:"ThumbFlexion", + 11:"ThumbExtension", + 12:"ThumbAbduction", + 13:"ThumbAdduction", + 14:"IndexFlexion", + 15:"RingFlexion", + 16:"PinkyFlexion", + 17:"WristSupination", + 18:"WristPronation", + 19:"WristFlexion", + 20:"WristExtension", + 21:"RadialDeviation", + 22:"UlnarDeviation", + 23:"NoMotion"}, + 8, + desc, + "https://pmc.ncbi.nlm.nih.gov/articles/PMC9879512/") + self.url = "https://github.com/LibEMG/TMR_ShirleyRyanAbilityLab" + self.dataset_folder = dataset_folder + + def get_odh(self, subjects = None): + subject_list = np.array([1,2,3,4,7,10]) + if subjects: + subject_list = subject_list[subjects] + subjects_values = [str(s) for s in subject_list] + + reps_values = [str(i) for i in range(8)] + classes_values = [str(i) for i in range(24)] + intervention_values = ["preTMR","postTMR"] + + print('\nPlease cite: ' + self.citation+'\n') + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound="/S", right_bound="/",values=subjects_values, description='subjects'), + RegexFilter(left_bound = "_R", right_bound=".txt", values = reps_values, description='reps'), + RegexFilter(left_bound = "/C", right_bound="_R", values = classes_values, description='classes'), + RegexFilter(left_bound = "/", right_bound="/C", values = intervention_values, description='intervention') + ] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") + return odh + +class TMR_Pre(TMRShirleyRyanAbilityLab): + """ + Data from participants pre TMR surgery. + """ + def __init__(self, dataset_folder="TMR/"): + TMRShirleyRyanAbilityLab.__init__(self, dataset_folder=dataset_folder, desc='TMR Dataset: 6 subjects, 8 reps, 24 motions, pre intervention') + + def prepare_data(self, split=True, subjects=None): + odh = self.get_odh(subjects) + odh = odh.isolate_data('intervention', [0]) + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("reps", list(range(6)), fast=True), 'Test': odh.isolate_data("reps", list(range(6,8)), fast=True)} + return data + +class TMR_Post(TMRShirleyRyanAbilityLab): + """ + Data from participants post TMR surgery. + """ + def __init__(self, dataset_folder="TMR/"): + TMRShirleyRyanAbilityLab.__init__(self, dataset_folder=dataset_folder, desc='TMR Dataset: 6 subjects, 8 reps, 24 motions, post intervention') + + def prepare_data(self, split=True, subjects=None): + odh = self.get_odh(subjects) + odh = odh.isolate_data('intervention', [1]) + data = odh + if split: + data = {'All': odh, 'Train': odh.isolate_data("reps", list(range(6)), fast=True), 'Test': odh.isolate_data("reps", list(range(6,8)), fast=True)} + return data \ No newline at end of file diff --git a/libemg/_datasets/user_compliance.py b/libemg/_datasets/user_compliance.py new file mode 100644 index 00000000..a9258844 --- /dev/null +++ b/libemg/_datasets/user_compliance.py @@ -0,0 +1,56 @@ +import numpy as np +from pathlib import Path +from libemg._datasets.dataset import Dataset +from libemg.data_handler import OfflineDataHandler, RegexFilter, FilePackager + +class UserComplianceDataset(Dataset): + def __init__(self, dataset_folder = 'UserComplianceDataset/', analysis = 'baseline'): + super().__init__( + sampling=1010, + num_channels=64, + recording_device='EMaGer', + num_subjects=6, + gestures={0: 'Hand Close (-) / Hand Open (+)', 1: 'Pronation (-) / Supination (+)'}, + num_reps=5, + description='Regression dataset used for investigation into user compliance during mimic training.', + citation='https://conferences.lib.unb.ca/index.php/mec/article/view/2507' + ) + self.url = 'https://github.com/LibEMG/UserComplianceDataset' + self.dataset_folder = dataset_folder + self.analysis = analysis + self.subject_list = np.array(['subject-001', 'subject-002', 'subject-003', 'subject-006', 'subject-007', 'subject-008']) + + def prepare_data(self, split = True, subjects = None): + subject_list = self.subject_list + if subjects: + subject_list = subject_list[subjects] + + if (not self.check_exists(self.dataset_folder)): + self.download(self.url, self.dataset_folder) + + regex_filters = [ + RegexFilter(left_bound='/', right_bound='/', values=['open-close', 'pro-sup'], description='movements'), + RegexFilter(left_bound='_R_', right_bound='.csv', values=[str(idx) for idx in range(self.num_reps)], description='reps'), + RegexFilter(left_bound='/', right_bound='/', values=['anticipation', 'all-or-nothing', 'baseline'], description='behaviours'), + RegexFilter(left_bound='/', right_bound='/', values=list(subject_list), description='subjects') + ] + package_function = lambda x, y: Path(x).parent.absolute() == Path(y).parent.absolute() + metadata_fetchers = [FilePackager(RegexFilter(left_bound='/', right_bound='.txt', values=['labels'], description='labels'), package_function)] + odh = OfflineDataHandler() + odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) + data = odh + if split: + if self.analysis == 'baseline': + data = { + 'All': odh, + 'Train': odh.isolate_data('behaviours', [0, 1], fast=True), + 'Test': odh.isolate_data('behaviours', [2], fast=True) + } + elif self.analysis == 'all-or-nothing': + data = {'All': odh, 'Train': odh.isolate_data('behaviours', [1], fast=True), 'Test': odh.isolate_data('behaviours', [2], fast=True)} + elif self.analysis == 'anticipation': + data = {'All': odh, 'Train': odh.isolate_data('behaviours', [0], fast=True), 'Test': odh.isolate_data('behaviours', [2], fast=True)} + else: + raise ValueError(f"Unexpected value for analysis. Got: {self.analysis}.") + + return data diff --git a/libemg/_gui/_data_collection_panel.py b/libemg/_gui/_data_collection_panel.py index 26a9f7a0..c2c65c58 100644 --- a/libemg/_gui/_data_collection_panel.py +++ b/libemg/_gui/_data_collection_panel.py @@ -1,20 +1,20 @@ +import shutil from pathlib import Path import dearpygui.dearpygui as dpg -import numpy as np import os from itertools import compress import time -import csv import json from datetime import datetime -from ._utils import Media, set_texture, init_matplotlib_canvas, matplotlib_to_numpy +from ._utils import Media, set_texture +from ._visualization_panel import VisualizationPanel import threading -import matplotlib.pyplot as plt class DataCollectionPanel: def __init__(self, + online_data_handler, num_reps=3, rep_time=3, media_folder='media/', @@ -22,10 +22,12 @@ def __init__(self, rest_time=2, auto_advance=True, exclude_files=[], - gui = None, + timestamps=False, + visualize_num_samples=500, video_player_width = 720, video_player_height = 480): + self.online_data_handler = online_data_handler self.num_reps = num_reps self.rep_time = rep_time self.media_folder = media_folder @@ -33,7 +35,8 @@ def __init__(self, self.rest_time = rest_time self.auto_advance=auto_advance self.exclude_files = exclude_files - self.gui = gui + self.timestamps = timestamps + self.visualize_num_samples = visualize_num_samples self.video_player_width = video_player_width self.video_player_height = video_player_height @@ -41,7 +44,13 @@ def __init__(self, '__dc_auto_advance'], "collection": ['__dc_collection_window', '__dc_prompt_spacer', '__dc_prompt', '__dc_progress', '__dc_redo_button'], "visualization": ['__vls_visualize_window']} - + # The texture registry set_texture parents its textures to + # (_utils.TEXTURE_REGISTRY_TAG) is deliberately absent from these lists. + # It is process-global and shared with every other panel, so deleting it + # on this panel's teardown would take out the textures those panels are + # still holding. It is a single item that is reused for the life of the + # process, so leaving it alive leaks nothing. + def cleanup_window(self, window_name): widget_list = self.widget_tags[window_name] @@ -107,15 +116,17 @@ def spawn_configuration_window(self): def start_callback(self): - if self.gui.online_data_handler and sum(list(self.gui.online_data_handler.get_data()[1].values())): - self.get_settings() - dpg.delete_item("__dc_configuration_window") - self.cleanup_window("configuration") - media_list = self.gather_media() + if not (self.online_data_handler and sum(list(self.online_data_handler.get_data()[1].values()))): + raise ConnectionError('Attempted to start data collection, but data are not being received. Please ensure the OnlineDataHandler is receiving data.') - self.spawn_collection_thread = threading.Thread(target=self.spawn_collection_window, args=(media_list,)) - self.spawn_collection_thread.start() - # self.spawn_collection_window(media_list) + self.get_settings() + dpg.delete_item("__dc_configuration_window") + self.cleanup_window("configuration") + media_list = self.gather_media() + + self.spawn_collection_thread = threading.Thread(target=self.spawn_collection_window, args=(media_list,)) + self.spawn_collection_thread.start() + # self.spawn_collection_window(media_list) def get_settings(self): self.num_reps = int(dpg.get_value("__dc_num_reps")) @@ -129,8 +140,8 @@ def gather_media(self): # find everything in the media folder files = os.listdir(self.media_folder) files = sorted(files) - valid_files = [file.endswith((".gif",".png",".mp4","jpg")) for file in files] - files = list(compress(files, valid_files)) + labels_files = [file for file in files if file.endswith(('.txt', '.csv'))] + files = [file for file in files if file.endswith((".gif",".png",".mp4","jpg"))] self.num_motions = len(files) collection_conf = [] # make the collection_details.json file @@ -145,18 +156,35 @@ def gather_media(self): with open(Path(self.output_folder, "collection_details.json").absolute().as_posix(), 'w') as f: json.dump(collection_details, f) + for media_file in files: + matching_labels_files = [labels_file for labels_file in labels_files if Path(labels_file).stem == Path(media_file).stem] + if len(matching_labels_files) == 1: + # Copy labels file to data directory + labels_file = matching_labels_files[0] + class_index = [idx for idx, filename in collection_details['class_map'].items() if filename == Path(labels_file).stem] + assert len(class_index) == 1, f"Expected a single matching filename in collection_details.json, but got {len(class_index)} for {labels_file}." + class_index = class_index[0] + labels_new_filename = Path(labels_file).with_stem(f"C_{class_index}").name + shutil.copy(Path(self.media_folder, labels_file).absolute(), Path(self.data_folder, labels_new_filename).absolute()) + # make the media list for SGT progression for rep_index in range(self.num_reps): for class_index, motion_class in enumerate(files): # entry for collection of rep media = Media() media.from_file(Path(self.media_folder, motion_class).absolute().as_posix()) - collection_conf.append([media,motion_class.split('.')[0],class_index,rep_index,self.rep_time]) + + if media.type in ('mp4', 'gif'): + # Automatically calculate length of video + rep_time = media.n_frames / media.fps + else: + rep_time = self.rep_time + collection_conf.append([media, motion_class.split('.')[0], class_index, rep_index, rep_time]) return collection_conf def spawn_collection_window(self, media_list): # open first frame of gif - self.gui.online_data_handler.prepare_smm() + self.online_data_handler.prepare_smm() texture = media_list[0][0].get_dpg_formatted_texture(width=self.video_player_width,height=self.video_player_height) set_texture("__dc_collection_visual", texture, width=self.video_player_width, height=self.video_player_height) @@ -170,6 +198,9 @@ def spawn_collection_window(self, media_list): with dpg.group(horizontal=True): dpg.add_spacer(height=20,width=self.video_player_width/2+30-(7*len("Collection Menu"))/2) dpg.add_text(default_value="Collection Menu") + with dpg.group(horizontal=True): + dpg.add_spacer(tag="__dc_rep_spacer",height=20,width=self.video_player_width/2+30 - (7*len(media_list[0][1]))/2) + dpg.add_text(f"Rep 1 of {self.num_reps}", tag="__dc_rep") with dpg.group(horizontal=True): dpg.add_spacer(tag="__dc_prompt_spacer",height=20,width=self.video_player_width/2+30 - (7*len(media_list[0][1]))/2) dpg.add_text(media_list[0][1], tag="__dc_prompt") @@ -202,27 +233,41 @@ def spawn_collection_window(self, media_list): def run_sgt(self, media_list): self.i = 0 self.advance = True - self.gui.online_data_handler.reset() + self.online_data_handler.reset() while self.i < len(media_list): - self.rep_buffer = {mod:[] for mod in self.gui.online_data_handler.modalities} - self.rep_count = {mod:0 for mod in self.gui.online_data_handler.modalities} # do the rest if self.rest_time and self.i < len(media_list): self.play_collection_visual(media_list[self.i], active=False) media_list[self.i][0].reset() - self.gui.online_data_handler.reset() - - self.play_collection_visual(media_list[self.i], active=True) - - output_path = Path(self.output_folder, "C_" + str(media_list[self.i][2]) + "_R_" + str(media_list[self.i][3]) + ".csv").absolute().as_posix() - self.save_data(output_path) + + file_prefix = Path(self.output_folder, "C_" + str(media_list[self.i][2]) + "_R_" + str(media_list[self.i][3]) + "_").absolute().as_posix() + self.clear_rep_files(file_prefix) + self.online_data_handler.reset() + # Arm the logger before the prompt goes up: start_log only returns + # once shared memory is being watched, so the file covers the whole + # collection phase and nothing else. + self.online_data_handler.start_log(file_path=file_prefix, timestamps=self.timestamps) + try: + self.play_collection_visual(media_list[self.i], active=True) + finally: + logged = self.online_data_handler.stop_log() + self.verify_rep_logged(logged, file_prefix) + last_rep = media_list[self.i][3] self.i = self.i+1 - if self.i == len(media_list): - break - current_rep = media_list[self.i][3] + is_final_media = self.i == len(media_list) + if is_final_media: + # At the end of the list, so we must be finished a rep + rep_is_finished = True + current_rep = self.num_reps - 1 + else: + # Check if we've finished a rep + current_rep = media_list[self.i][3] + rep_is_finished = last_rep != current_rep + # pause / redo goes here! - if last_rep != current_rep or (not self.auto_advance): + if rep_is_finished or (not self.auto_advance): + # Show redo / continue buttons self.advance = False dpg.show_item(item="__dc_redo_button") dpg.show_item(item="__dc_continue_button") @@ -232,6 +277,8 @@ def run_sgt(self, media_list): jobs = dpg.get_callback_queue() dpg.run_callbacks(jobs) dpg.configure_app(manual_callback_management=False) + if not is_final_media: + dpg.set_value('__dc_rep', value=f"Rep {media_list[self.i][3] + 1} of {self.num_reps}") def redo_collection_callback(self): if self.auto_advance: @@ -249,7 +296,7 @@ def continue_collection_callback(self): def play_collection_visual(self, media, active=True): if active: - timer_duration = self.rep_time + timer_duration = media[-1] dpg.set_value("__dc_prompt", value=media[1]) dpg.set_item_width("__dc_prompt_spacer",width=self.video_player_width/2+30 - (7*len(media[1]))/2) else: @@ -270,30 +317,38 @@ def play_collection_visual(self, media, active=True): set_texture("__dc_collection_visual", texture, self.video_player_width, self.video_player_height) # update progress bar progress = min(1,(time.perf_counter_ns() - motion_timer)/(1e9*timer_duration)) - # grab incoming new data - if active: - vals, count = self.gui.online_data_handler.get_data() - for mod in self.gui.online_data_handler.modalities: - new_samples = count[mod][0][0]-self.rep_count[mod] - self.rep_buffer[mod] = [vals[mod][:new_samples,:]] + self.rep_buffer[mod] - self.rep_count[mod] = self.rep_count[mod] + new_samples - dpg.set_value("__dc_progress", value = progress) - def save_data(self, filename): - file_parts = filename.split('.') - - for mod in self.rep_buffer: - filename = file_parts[0] + "_" + mod + "." + file_parts[1] - data = np.vstack(self.rep_buffer[mod])[::-1,:] - with open(filename, "w", newline='', encoding='utf-8') as file: - writer = csv.writer(file) - for row in data: - writer.writerow(row) + def clear_rep_files(self, file_prefix): + """Remove a previous take of this rep, since the logger appends to its files.""" + for mod in self.online_data_handler.modalities: + path = Path(file_prefix + mod + ".csv") + if path.exists(): + path.unlink() + + def verify_rep_logged(self, logged, file_prefix=''): + """Confirm every modality contributed samples to the rep that just finished. + + A rep can also come back complete-looking but short: if the logger is + held off the shared memory long enough for the device to lap the buffer, + the samples it missed are simply absent from the file, with the samples + either side of the gap written adjacent to each other. Nothing in the + file marks that, so it is called out here while the rep can still be + redone. + """ + empty = [mod for mod in self.online_data_handler.modalities if not logged.get(mod, 0)] + if empty: + raise ConnectionError(f'Attempting to store data, but received 0 samples during repetition for {", ".join(empty)}, suggesting that the data stream from the device has been interrupted. Please check the device connection and verify that previous files are not missing samples.') + dropped = getattr(self.online_data_handler, 'log_dropped', {}) + incomplete = {mod: n for mod, n in dropped.items() if n} + if incomplete: + detail = ", ".join(f"{n} {mod}" for mod, n in sorted(incomplete.items())) + print(f'LibEMG -> DataCollectionPanel (this repetition is missing samples: {detail} ' + f'were overwritten in shared memory before they could be written to ' + f'{file_prefix}*.csv. The gap is not marked in the file. Redo the repetition ' + f'if the recording has to be continuous.)') def visualize_callback(self): - self.visualization_thread = threading.Thread(target=self._run_visualization_helper) - self.visualization_thread.start() - - def _run_visualization_helper(self): - self.gui.online_data_handler.visualize(block=False) + self.visualization_panel = VisualizationPanel(self.online_data_handler, + num_samples=self.visualize_num_samples) + self.visualization_panel.spawn_window() diff --git a/libemg/_gui/_environments/__init__.py b/libemg/_gui/_environments/__init__.py new file mode 100644 index 00000000..48324334 --- /dev/null +++ b/libemg/_gui/_environments/__init__.py @@ -0,0 +1,19 @@ +"""Running LibEMG's pygame environments inside the DearPyGui window. + +An environment draws offscreen in its own process and writes its frames into +memory the GUI's texture is backed by, so the game appears in a panel rather +than in a window of its own. See +:mod:`~libemg._gui._environments.frame_bridge` for how frames and input cross +the boundary, and :mod:`~libemg._gui._environments.registry` for how each +environment's setup screen is generated from its own configuration. +""" + +from libemg._gui._environments.embedded import EmbeddedEnvironment, EnvironmentRunner +from libemg._gui._environments.factories import ControllerSpec, build_factory +from libemg._gui._environments.frame_bridge import FORWARDED_KEYS, FrameBridge +from libemg._gui._environments.registry import (EnvironmentSpec, Setting, + build_registry, default_registry) + +__all__ = ["EmbeddedEnvironment", "EnvironmentRunner", "ControllerSpec", + "build_factory", "FrameBridge", "FORWARDED_KEYS", + "EnvironmentSpec", "Setting", "build_registry", "default_registry"] diff --git a/libemg/_gui/_environments/embedded.py b/libemg/_gui/_environments/embedded.py new file mode 100644 index 00000000..0ff3e6e3 --- /dev/null +++ b/libemg/_gui/_environments/embedded.py @@ -0,0 +1,272 @@ +"""Run a LibEMG environment offscreen and publish its frames to the GUI. + +The environments are pygame games with their own window and their own loop. +Embedding one means taking away the window without taking away the game. + +SDL's dummy video driver does exactly that: ``pygame.display.set_mode`` still +returns a real surface and everything still draws onto it, there is simply no +window on screen. The environment is otherwise untouched. Its +:meth:`game_setup` and :meth:`_run_loop` run exactly as they always did, so an +embedded game behaves the same as a windowed one and a new environment needs no +special support to be embeddable. + +What has to be replaced is the two things a window used to provide. Frames go +out through a :class:`~libemg._gui._environments.frame_bridge.FrameBridge` into +the texture the GUI is drawing. Input comes back the same way, because with no +window there are no keyboard events, and a keyboard-driven environment asks +pygame for key state rather than reading the queue. +""" + +import os +import traceback +from multiprocessing import Process + +from libemg._gui._environments.frame_bridge import FORWARDED_KEYS, FrameBridge + + +class EnvironmentRunner(Process): + """Runs one environment offscreen, publishing every frame it draws. + + Parameters + ---------- + bridge: FrameBridge + Where frames go and input comes from. + factory: callable + Called in this process to build the environment. It must be picklable, + so it should be a module-level function or a small class holding + configuration rather than a constructed environment: a pygame object + cannot cross a process boundary, and neither can a socket. + forward_keys: bool (optional), default=True + Make ``pygame.key.get_pressed`` reflect the keys the GUI forwards. + Needed by keyboard-driven environments; harmless otherwise. + """ + + def __init__(self, bridge, factory, forward_keys=True): + super().__init__(daemon=True) + self.bridge = bridge + self.factory = factory + self.forward_keys = forward_keys + + def run(self): + # Set before pygame is imported anywhere in this process: the driver is + # chosen when the display module initialises, and the dummy driver is + # what makes set_mode produce a surface with no window. + os.environ["SDL_VIDEODRIVER"] = "dummy" + os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" + import pygame + + bridge = self.bridge + try: + pygame.init() + pygame.font.init() + try: + pygame.mixer.init() + except Exception: + # There is no audio device on a headless machine, and a game + # that cannot play a sound should still be playable. + pass + + if self.forward_keys: + _forward_key_state(pygame, bridge) + + environment = self.factory() + environment.game_setup() + surface = pygame.display.get_surface() + clock = pygame.time.Clock() + fps = int(getattr(environment, "fps", 60) or 60) + bridge.mark_running(True) + + while not environment.done: + if bridge.quit_requested(): + break + _pump(pygame, bridge) + environment._run_loop() + pygame.display.update() + # get_surface again each frame, because an environment is free + # to call set_mode itself and replace it. + surface = pygame.display.get_surface() or surface + if surface is not None: + bridge.publish(surface) + clock.tick(fps) + + if environment.done: + bridge.mark_finished() + try: + environment.save_results() + except Exception: + pass + except Exception as error: + # The reason goes back through the bridge as well as to the + # console. An environment validates its own settings and raises + # here, in a process with no window and no terminal anybody is + # watching, so without this the window that launched it simply + # stays black with nothing to explain why. + bridge.report_error(f"{type(error).__name__}: {error}") + print("LibEMG -> embedded environment failed:\n" + + traceback.format_exc()) + finally: + bridge.mark_running(False) + try: + import pygame as _pygame + _pygame.quit() + except Exception: + pass + bridge.close() + + +def _pump(pygame, bridge): + """Keep SDL's own queue drained and deliver a quit as a real event. + + Environments read ``pygame.event.get()`` and look for QUIT, so asking one + to stop is best done in the language it already speaks. + """ + pygame.event.pump() + if bridge.quit_requested(): + pygame.event.post(pygame.event.Event(pygame.QUIT)) + + +def _forward_key_state(pygame, bridge): + """Make pygame report the keys the GUI is forwarding. + + A keyboard-driven environment calls ``pygame.key.get_pressed`` and indexes + the result by key code. With no window, SDL has no key state to report, so + that call is replaced by one that reads what the GUI wrote into the bridge. + + Replacing the function rather than posting synthetic events is deliberate. + ``get_pressed`` reflects SDL's own view of the physical keyboard, which + posted events do not reach, so posting them would look right and do + nothing. The replacement lives only in this process, so the library behaves + exactly as before anywhere else. + """ + codes = {} + for name in FORWARDED_KEYS: + # pygame names the letter and digit keys in lower case (K_a, K_1) and + # the named ones in upper (K_LEFT, K_SPACE), so both spellings are + # tried. Getting this wrong silently drops the arrow keys, which are + # the ones a Fitts task is actually driven with. + code = getattr(pygame, f"K_{name}", None) + if code is None: + code = getattr(pygame, f"K_{name.upper()}", None) + if code is not None: + codes[name] = code + + def get_pressed(): + return _KeyState({codes[name] for name in bridge.held_keys() + if name in codes}) + + pygame.key.get_pressed = get_pressed + + +class _KeyState: + """Key state indexed by key code, for any code that is asked for. + + The real ``get_pressed`` returns a sequence covering every scancode SDL + knows, and callers index it with whatever key code they care about. A list + sized to the keys being forwarded looks equivalent and is not: indexing it + with an arrow key, whose code is in the millions, raises IndexError instead + of answering "not held". + """ + + __slots__ = ("_held",) + + def __init__(self, held): + self._held = held + + def __getitem__(self, code): + return code in self._held + + def __contains__(self, code): + return code in self._held + + def __len__(self): + # Large enough that a caller checking bounds is satisfied, and never + # actually iterated over in practice. + return 1 << 31 + + def __iter__(self): + raise TypeError("Key state is meant to be indexed by key code, not iterated.") + + +class EmbeddedEnvironment: + """An environment running offscreen, and the frame the GUI should draw. + + Owns the bridge and the process, so stopping this stops both and releases + the shared memory. + + Parameters + ---------- + name: str + Unique name for this run, used for the shared segments. + factory: callable + Builds the environment, in the child process. Must be picklable. + width, height: int + Frame size, which must match what the environment sets up. + forward_keys: bool (optional), default=True + Whether to make forwarded keys visible to the environment. + + Examples + --------- + >>> embedded = EmbeddedEnvironment('fitts_1', factory, 800, 600).start() + >>> texture = embedded.pixels() + >>> embedded.stop() + """ + + def __init__(self, name, factory, width, height, forward_keys=True): + self.name = name + self.factory = factory + self.width, self.height = int(width), int(height) + self.bridge = FrameBridge(name, self.width, self.height, create=True) + self.runner = EnvironmentRunner(self.bridge, factory, + forward_keys=forward_keys) + self._started = False + + def start(self): + if not self._started: + self.runner.start() + self._started = True + return self + + @property + def alive(self): + return self._started and self.runner.is_alive() + + def pixels(self): + """The frame array to back a raw texture with.""" + return self.bridge.pixels() + + def status(self): + """What the environment is doing. + + Returns + ---------- + dict + ``running``, ``finished``, ``frames``, ``fps``, ``generation`` and + ``error``, the last being why it stopped when it stopped badly. + """ + return {"running": self.bridge.running(), + "finished": self.bridge.finished(), + "frames": self.bridge.frames(), + "fps": self.bridge.fps(), + "generation": self.bridge.generation(), + "error": self.bridge.error()} + + def send_input(self, keys, mouse=None, mouse_down=False): + self.bridge.send_input(keys, mouse=mouse, mouse_down=mouse_down) + + def stop(self, timeout=4.0): + """Ask it to stop, wait, and release everything.""" + if self._started: + self.bridge.request_quit() + self.runner.join(timeout=timeout) + if self.runner.is_alive(): + self.runner.terminate() + self.runner.join(timeout=1.0) + self._started = False + self.bridge.close(unlink=True) + + def __enter__(self): + return self.start() + + def __exit__(self, *exc): + self.stop() + return False diff --git a/libemg/_gui/_environments/factories.py b/libemg/_gui/_environments/factories.py new file mode 100644 index 00000000..2c59bb68 --- /dev/null +++ b/libemg/_gui/_environments/factories.py @@ -0,0 +1,211 @@ +"""Builds an environment inside the process that will run it. + +Nothing here is constructed in the GUI. An environment holds pygame objects and +a controller holds an open socket, and neither survives being sent to another +process. So what crosses the boundary is a small description of what to build, +and the building happens on the other side. + +That is also why these are module-level classes rather than closures. A lambda +capturing a configuration cannot be pickled, and the failure it produces names +an anonymous function rather than anything a user could act on. +""" + + +class ControllerSpec: + """What controller an environment should be driven by. + + Parameters + ---------- + kind: str + ``'keyboard'``, ``'classifier'`` or ``'regressor'``. + ip: str (optional), default='127.0.0.1' + Address a socket controller listens on. + port: int (optional), default=12346 + Port a socket controller listens on. + num_classes: int (optional), default=5 + Classes a classifier controller should expect. + output_format: str (optional), default='predictions' + What the classifier is sending. + """ + + def __init__(self, kind="keyboard", ip="127.0.0.1", port=12346, + num_classes=5, output_format="predictions"): + self.kind = kind + self.ip = ip + self.port = port + self.num_classes = num_classes + self.output_format = output_format + + def build(self): + from libemg.environments.controllers import (ClassifierController, + KeyboardController, + RegressorController) + if self.kind == "classifier": + return ClassifierController(output_format=self.output_format, + num_classes=self.num_classes, + ip=self.ip, port=self.port) + if self.kind == "regressor": + return RegressorController(ip=self.ip, port=self.port) + return KeyboardController() + + +def emg_hero_keyboard_map(): + """Map keys to the lane commands EMG Hero understands. + + EMG Hero does not speak in directions. Its prediction map has to carry the + values 0, 1, 2, 3 and -1, which it asserts on construction, so the + direction map a Fitts task wants is rejected outright. The four number keys + are the natural fit for four lanes, with nothing held meaning no command. + + Returns + ---------- + dict + Key code to lane command, including the idle command. + """ + import pygame + return { + pygame.K_1: 0, + pygame.K_2: 1, + pygame.K_3: 2, + pygame.K_4: 3, + -1: -1, + } + + +def keyboard_prediction_map(): + """Map arrow keys to the directions a Fitts task understands. + + A Fitts task turns a prediction into a direction through a prediction map, + and the default one maps class indices 0 to 4. The keyboard controller does + not produce class indices; it produces pygame key codes, and -1 when + nothing is held. So choosing keyboard control without also supplying a map + fails on the first frame with a KeyError for a key code. + + Building the map here is what makes picking Keyboard in the menu simply + work, which is what somebody launching a task from a menu expects. + + Returns + ---------- + dict + Key code to direction, including no-motion for nothing held. + """ + import pygame + mapping = { + pygame.K_UP: "N", + pygame.K_DOWN: "S", + pygame.K_RIGHT: "E", + pygame.K_LEFT: "W", + -1: "NM", + } + # Every other key the GUI forwards maps to no motion. The keyboard + # controller reports whichever of its keys is held, and a Fitts task looks + # the result up with no fallback, so a digit pressed while playing would + # otherwise end the task with a KeyError rather than being ignored. + for key in (pygame.K_1, pygame.K_2, pygame.K_3, pygame.K_4, + pygame.K_SPACE, pygame.K_ESCAPE, + pygame.K_w, pygame.K_a, pygame.K_s, pygame.K_d): + mapping.setdefault(key, "NM") + return mapping + + +class _Factory: + """Common shape: hold settings, build on the other side.""" + + def __init__(self, controller, settings): + self.controller = controller + self.settings = dict(settings) + + def __call__(self): + raise NotImplementedError + + def _prediction_map(self): + """A map suited to the controller, or None to keep the default.""" + return keyboard_prediction_map() if self.controller.kind == "keyboard" else None + + def _split(self, config_class): + """Separate settings the config accepts from the rest.""" + import dataclasses + names = {f.name for f in dataclasses.fields(config_class)} + accepted = {k: v for k, v in self.settings.items() if k in names} + remainder = {k: v for k, v in self.settings.items() if k not in names} + return accepted, remainder + + +class FittsFactory(_Factory): + """Builds a Fitts task.""" + + def __call__(self): + from libemg.environments.fitts import Fitts, FittsConfig + accepted, _ = self._split(FittsConfig) + return Fitts(self.controller.build(), FittsConfig(**accepted), + prediction_map=self._prediction_map()) + + +class ISOFittsFactory(_Factory): + """Builds an ISO Fitts task, whose ring settings sit outside the config.""" + + def __call__(self): + from libemg.environments.fitts import FittsConfig, ISOFitts + accepted, extra = self._split(FittsConfig) + return ISOFitts(self.controller.build(), FittsConfig(**accepted), + prediction_map=self._prediction_map(), + num_targets=int(extra.get("num_targets", 8)), + target_distance_radius=int( + extra.get("target_distance_radius", 275))) + + +class CurricularFittsFactory(_Factory): + """Builds a curricular Fitts task.""" + + def __call__(self): + from libemg.environments.curricular_fitts import (CurricularFitts, + CurricularFittsConfig) + accepted, extra = self._split(CurricularFittsConfig) + return CurricularFitts(self.controller.build(), + CurricularFittsConfig(**accepted), + save_file=extra.get("save_file")) + + +class EMGHeroFactory(_Factory): + """Builds the rhythm game, which takes its settings as arguments.""" + + def __call__(self): + import inspect + from libemg.environments.emg_hero import EMGHero + names = set(inspect.signature(EMGHero.__init__).parameters) + accepted = {k: v for k, v in self.settings.items() if k in names} + # Its own map, not the Fitts one: see emg_hero_keyboard_map. + mapping = emg_hero_keyboard_map() if self.controller.kind == "keyboard" else None + return EMGHero(self.controller.build(), prediction_map=mapping, **accepted) + + +#: Factory per environment id, matching EnvironmentSpec.factory_name. +FACTORIES = { + "fitts": FittsFactory, + "iso_fitts": ISOFittsFactory, + "curricular_fitts": CurricularFittsFactory, + "emg_hero": EMGHeroFactory, +} + + +def build_factory(spec, controller_spec, settings): + """Make a picklable factory for an environment. + + Parameters + ---------- + spec: EnvironmentSpec + Which environment. + controller_spec: ControllerSpec + How it should be driven. + settings: dict + Configured values, already coerced. + + Returns + ---------- + callable + Call it in the child process to get the environment. + """ + factory_class = FACTORIES.get(spec.factory_name) + if factory_class is None: + raise KeyError(f"No factory for environment '{spec.id}'.") + return factory_class(controller_spec, settings) diff --git a/libemg/_gui/_environments/frame_bridge.py b/libemg/_gui/_environments/frame_bridge.py new file mode 100644 index 00000000..ba99bd86 --- /dev/null +++ b/libemg/_gui/_environments/frame_bridge.py @@ -0,0 +1,309 @@ +"""Carries video frames and input between an environment and the window showing it. + +An environment draws with pygame in its own process, so its frames have to +reach the GUI somehow. They do it by both sides pointing at the same memory. + +The GUI hands DearPyGui a raw texture backed by a shared-memory segment, and +DearPyGui draws from the array it was given rather than from a copy taken when +the texture was made. The environment writes its frame into that same segment. +Nothing is copied on the GUI side and nothing is uploaded per frame; the +environment paints and the window shows it. + +Why the pixels are not locked +----------------------------- +One writer, one reader, and a frame that is replaced whole several times a +second. The worst a race can do is show one frame that is half new and half +old, for one sixtieth of a second, which is invisible. Taking a lock on two +megabytes sixty times a second to prevent that would cost more than it saves, +and would let a stalled reader hold up the game. The small control block beside +the pixels *is* guarded, because a torn integer there would be a real fault. + +Input goes the other way through the same mechanism. The environment has no +window of its own, so it receives no keyboard or mouse events; the GUI writes +what it saw into a small block and the environment reads it. +""" + +import time +from multiprocessing import Lock +from multiprocessing.shared_memory import SharedMemory + +import numpy as np + +# Control block layout. Small, fixed, and read far more often than written. +_GENERATION = 0 # bumped once per published frame +_WIDTH = 1 +_HEIGHT = 2 +_RUNNING = 3 # the environment is alive and drawing +_QUIT = 4 # the GUI has asked it to stop +_FRAMES = 5 # diagnostics: frames published +_FPS_MILLI = 6 # measured frames per second, times 1000 +_FINISHED = 7 # the environment ended on its own terms +CONTROL_FIELDS = 8 + +#: Bytes reserved for a failure message. An environment that refuses its +#: settings does so in its own process, where nothing would otherwise be seen; +#: this is how the reason gets back to the window that launched it. +ERROR_BYTES = 2048 + +# Input block layout: a slot per key we forward, then the pointer. +MAX_KEYS = 32 +_MOUSE_X = MAX_KEYS +_MOUSE_Y = MAX_KEYS + 1 +_MOUSE_DOWN = MAX_KEYS + 2 +INPUT_FIELDS = MAX_KEYS + 3 + +#: The keys an embedded environment can be driven with. The index into this +#: tuple is the slot in the input block, so the two sides agree without having +#: to ship a pygame key code across. +FORWARDED_KEYS = ("left", "right", "up", "down", + "1", "2", "3", "4", + "space", "escape", "w", "a", "s", "d") + + +class FrameBridge: + """Shared memory holding one video frame, plus control and input. + + Parameters + ---------- + name: str + Prefix for the segments. Unique per running environment. + width, height: int + Frame size in pixels. Only meaningful when creating. + create: bool (optional), default=False + Create the segments rather than attaching to existing ones. + + Examples + --------- + >>> bridge = FrameBridge('fitts_1', 800, 600, create=True) + >>> texture = bridge.pixels() # hand this to add_raw_texture + >>> bridge.close() + """ + + def __init__(self, name, width=0, height=0, create=False, lock=None): + self.name = name + self.lock = lock or Lock() + control_size = CONTROL_FIELDS * 8 + input_size = INPUT_FIELDS * 4 + + if create: + if width <= 0 or height <= 0: + raise ValueError("Creating a bridge needs a frame size.") + self.width, self.height = int(width), int(height) + pixel_size = self.width * self.height * 4 * 4 # float32 RGBA + self._pixels_sm = _fresh(name + "_pixels", pixel_size) + self._control_sm = _fresh(name + "_control", control_size) + self._input_sm = _fresh(name + "_input", input_size) + self._error_sm = _fresh(name + "_error", ERROR_BYTES) + self._bind() + self._pixel_view[:] = 0.0 + self._control[:] = 0 + self._control[_WIDTH] = self.width + self._control[_HEIGHT] = self.height + self._input[:] = 0 + self._error[:] = 0 + else: + self._control_sm = SharedMemory(name + "_control") + self._control = np.ndarray((CONTROL_FIELDS,), dtype=np.int64, + buffer=self._control_sm.buf) + self.width = int(self._control[_WIDTH]) + self.height = int(self._control[_HEIGHT]) + pixel_size = self.width * self.height * 4 * 4 + self._pixels_sm = SharedMemory(name + "_pixels") + self._input_sm = SharedMemory(name + "_input") + self._error_sm = SharedMemory(name + "_error") + self._bind(attached=True) + + self._last_published = time.perf_counter() + + def _bind(self, attached=False): + if not attached: + self._control = np.ndarray((CONTROL_FIELDS,), dtype=np.int64, + buffer=self._control_sm.buf) + self._pixel_view = np.ndarray((self.height * self.width * 4,), + dtype=np.float32, buffer=self._pixels_sm.buf) + self._input = np.ndarray((INPUT_FIELDS,), dtype=np.int32, + buffer=self._input_sm.buf) + self._error = np.ndarray((ERROR_BYTES,), dtype=np.uint8, + buffer=self._error_sm.buf) + + # ------------------------------------------------------------------ + # the GUI side + # ------------------------------------------------------------------ + def pixels(self): + """The frame, flat, as DearPyGui's raw textures want it. + + Hand this array straight to ``add_raw_texture``. DearPyGui keeps the + array rather than copying it, so once the texture exists the + environment's writes into this same memory are what the window shows. + """ + return self._pixel_view + + def generation(self): + """How many frames have been published. Cheap enough to read per frame.""" + return int(self._control[_GENERATION]) + + def running(self): + return bool(self._control[_RUNNING]) + + def finished(self): + """Whether the environment ended on its own, rather than being stopped.""" + return bool(self._control[_FINISHED]) + + def fps(self): + """Frames per second the environment is actually achieving.""" + return self._control[_FPS_MILLI] / 1000.0 + + def frames(self): + return int(self._control[_FRAMES]) + + def request_quit(self): + """Ask the environment to stop at its next frame.""" + with self.lock: + self._control[_QUIT] = 1 + + def send_input(self, keys, mouse=None, mouse_down=False): + """Forward what the window saw to the environment. + + Parameters + ---------- + keys: iterable of str + Names from :data:`FORWARDED_KEYS` that are currently held. + mouse: tuple or None (optional) + Pointer position within the frame, in pixels. + mouse_down: bool (optional) + Whether a mouse button is held. + """ + held = {k for k in keys} + with self.lock: + for index, name in enumerate(FORWARDED_KEYS): + self._input[index] = 1 if name in held else 0 + if mouse is not None: + self._input[_MOUSE_X] = int(mouse[0]) + self._input[_MOUSE_Y] = int(mouse[1]) + self._input[_MOUSE_DOWN] = 1 if mouse_down else 0 + + # ------------------------------------------------------------------ + # the environment side + # ------------------------------------------------------------------ + def publish(self, surface): + """Write a pygame surface into the shared frame. + + Parameters + ---------- + surface: pygame.Surface + What the environment just drew. + """ + import pygame + size = surface.get_size() + if size != (self.width, self.height): + # The texture was sized before the first frame arrived, from what + # the environment said it would draw. An environment that sets up a + # different size cannot be shown, and saying so beats a shape error + # from deep inside numpy that names neither side. + raise ValueError( + f"This environment drew a {size[0]} by {size[1]} frame, but the " + f"window was prepared for {self.width} by {self.height}. Its " + "declared frame size has to match the size it sets up.") + raw = pygame.image.tobytes(surface, "RGBA") + # One pass, straight into the texture's own memory: read the bytes as + # uint8, scale into the float view. No intermediate array survives. + flat = np.frombuffer(raw, dtype=np.uint8) + np.divide(flat, 255.0, out=self._pixel_view) + now = time.perf_counter() + elapsed = now - self._last_published + self._last_published = now + with self.lock: + self._control[_GENERATION] += 1 + self._control[_FRAMES] += 1 + if elapsed > 0: + # Smoothed, because a per-frame reciprocal jitters too much to + # read on a status line. + previous = self._control[_FPS_MILLI] + instant = 1000.0 / elapsed + self._control[_FPS_MILLI] = int(0.9 * previous + 0.1 * instant) \ + if previous else int(instant) + + def mark_running(self, running=True): + with self.lock: + self._control[_RUNNING] = 1 if running else 0 + + def mark_finished(self): + """Record that the environment ended on its own terms.""" + with self.lock: + self._control[_FINISHED] = 1 + self._control[_RUNNING] = 0 + + def report_error(self, message): + """Record why the environment could not run, for the GUI to show. + + An environment validates its own settings and raises when they + conflict, but it does that in its own process where the traceback goes + nowhere a user will look. Writing the reason here is what turns a + window that stayed black into a sentence explaining what to change. + """ + encoded = str(message).encode("utf-8")[:ERROR_BYTES - 1] + with self.lock: + self._error[:] = 0 + self._error[:len(encoded)] = np.frombuffer(encoded, dtype=np.uint8) + + def error(self): + """The failure message, or an empty string. + + Returns + ---------- + str + Why the environment stopped, when it stopped because of a problem. + """ + raw = bytes(self._error) + end = raw.find(bytes([0])) + return raw[:end if end >= 0 else len(raw)].decode("utf-8", "replace") + + def quit_requested(self): + return bool(self._control[_QUIT]) + + def held_keys(self): + """Key names the GUI reports as held. + + Returns + ---------- + set + Names from :data:`FORWARDED_KEYS`. + """ + return {name for index, name in enumerate(FORWARDED_KEYS) + if self._input[index]} + + def pointer(self): + """Pointer position and button state, as ``(x, y, down)``.""" + return (int(self._input[_MOUSE_X]), int(self._input[_MOUSE_Y]), + bool(self._input[_MOUSE_DOWN])) + + # ------------------------------------------------------------------ + def close(self, unlink=False): + """Release the segments. ``unlink`` destroys them, so only the owner.""" + for handle in (self._pixels_sm, self._control_sm, self._input_sm, + self._error_sm): + try: + handle.close() + if unlink: + handle.unlink() + except Exception: + pass + + def __getstate__(self): + # Only the name and the lock cross a process boundary; the child + # attaches for itself, because a mapped segment is not portable. + return {"name": self.name, "lock": self.lock} + + def __setstate__(self, state): + self.__init__(state["name"], create=False, lock=state["lock"]) + + +def _fresh(name, size): + """Create a segment, discarding one left behind by a previous run.""" + try: + stale = SharedMemory(name, create=False) + stale.close() + stale.unlink() + except Exception: + pass + return SharedMemory(name, create=True, size=size) diff --git a/libemg/_gui/_environments/panel.py b/libemg/_gui/_environments/panel.py new file mode 100644 index 00000000..60f03c3d --- /dev/null +++ b/libemg/_gui/_environments/panel.py @@ -0,0 +1,391 @@ +"""The Environments window: pick a task, set it up, play it in the GUI. + +Two screens. The setup screen is generated from the environment's own +configuration, so every setting it accepts appears with the author's own +description beside it. The play screen is a single image, backed by the memory +the environment is drawing into, plus what it is doing and a way to stop it. + +Nothing about the game runs here. The environment is a process of its own, and +this window forwards the keyboard to it and shows the frames it produces. That +separation is what keeps a game that stalls from taking the interface with it. +""" + +import time +import traceback + +import dearpygui.dearpygui as dpg + +from libemg._gui._environments.embedded import EmbeddedEnvironment +from libemg._gui._environments.factories import ControllerSpec, build_factory +from libemg._gui._environments.frame_bridge import FORWARDED_KEYS +from libemg._gui._environments.registry import (BOOL, COLOR, ENUM, FLOAT, INT, + PATH, STR, default_registry) + +WINDOW_TAG = "__env_window" +PLAY_TAG = "__env_play" +STATUS_TAG = "__env_status" +MESSAGE_TAG = "__env_message" +TEXTURE_TAG = "__env_texture" + +#: How a forwarded key is spelled on each side. DearPyGui names the space bar +#: differently from pygame, which is exactly the kind of mismatch that silently +#: drops a key, so the mapping is written out rather than derived. +_DPG_KEYS = { + "left": "mvKey_Left", "right": "mvKey_Right", "up": "mvKey_Up", + "down": "mvKey_Down", "1": "mvKey_1", "2": "mvKey_2", "3": "mvKey_3", + "4": "mvKey_4", "space": "mvKey_Spacebar", "escape": "mvKey_Escape", + "w": "mvKey_W", "a": "mvKey_A", "s": "mvKey_S", "d": "mvKey_D", +} + + +class EnvironmentsPanel: + """Launch and play LibEMG's environments inside the GUI. + + Parameters + ---------- + registry: dict or None (optional), default=None + Environment descriptions. Defaults to the generated registry. + online_data_handler: OnlineDataHandler or None (optional) + Not required. Kept so a caller can pass one through without a branch. + + Examples + --------- + >>> panel = EnvironmentsPanel() + >>> panel.spawn_window() + """ + + + #: The window this panel owns, so a caller can ask if it is open. + window_tag = WINDOW_TAG + + def __init__(self, registry=None, online_data_handler=None, width=1280, + height=800): + self.registry = registry if registry is not None else default_registry() + self.online_data_handler = online_data_handler + self.width, self.height = width, height + self.selected = next(iter(self.registry)) + self.values = {} + self.controller = {"kind": "keyboard", "ip": "127.0.0.1", "port": 12346, + "num_classes": 5, "output_format": "predictions"} + self.embedded = None + self._counter = 0 + self._frame_size = (0, 0) + + # ================================================================== + # setup screen + # ================================================================== + def spawn_window(self): + """Build the setup window for the selected environment.""" + self.cleanup() + self.values = self.registry[self.selected].defaults() + with dpg.window(label="Environments", tag=WINDOW_TAG, + width=self.width, height=self.height, + on_close=lambda: self.cleanup()): + with dpg.group(horizontal=True): + dpg.add_text("Task") + dpg.add_combo([s.title for s in self.registry.values()], + default_value=self.registry[self.selected].title, + tag="__env_choice", width=240, + callback=self._choose) + dpg.add_button(label="Launch", callback=self._launch) + dpg.add_button(label="Stop", callback=self.stop) + dpg.add_button(label="Reset settings", callback=self._reset) + dpg.add_text("", tag=MESSAGE_TAG, wrap=self.width - 40) + dpg.add_separator() + with dpg.child_window(tag="__env_body", autosize_x=True, autosize_y=True): + self._build_body() + self._describe() + return self + + def _build_body(self): + spec = self.registry[self.selected] + dpg.add_text(spec.help, wrap=self.width - 60, color=(170, 190, 210)) + dpg.add_separator() + + dpg.add_text("Control") + with dpg.group(horizontal=True): + dpg.add_combo(spec.controllers, default_value=_title(self.controller["kind"]), + tag="__env_controller", width=160, + callback=self._controller_changed) + dpg.add_input_text(label="Address", default_value=self.controller["ip"], + tag="__env_ip", width=120, + callback=lambda s, a: self.controller.update(ip=a)) + dpg.add_input_int(label="Port", default_value=self.controller["port"], + tag="__env_port", width=110, + callback=lambda s, a: self.controller.update(port=a)) + dpg.add_input_int(label="Classes", default_value=self.controller["num_classes"], + tag="__env_classes", width=110, + callback=lambda s, a: self.controller.update(num_classes=a)) + with dpg.tooltip("__env_controller"): + dpg.add_text("Keyboard drives the task with the arrow keys, for trying it " + "out. Classifier and Regressor listen on the address below " + "for a running model's output.", wrap=320) + self._controller_fields() + + dpg.add_separator() + dpg.add_text("Settings") + plain = [s for s in spec.settings if not _is_appearance(s)] + appearance = [s for s in spec.settings if _is_appearance(s)] + # One column, not two. A second column has to be given a width, and + # whatever is chosen is wrong for some window size, which shows up as + # settings that are simply not on screen. A single list always fits, + # and the window scrolls. + for setting in plain: + self._draw_setting(setting) + if appearance: + with dpg.tree_node(label="Appearance", default_open=False): + for setting in appearance: + self._draw_setting(setting) + + def _draw_setting(self, setting): + """One control, chosen by the setting's kind. + + The kind is what decides this, so a new setting on an environment gets + the right control without anything here changing. + """ + tag = f"__env_set_{setting.name}" + value = self.values.get(setting.name, setting.default) + data = setting.name + common = dict(tag=tag, user_data=data, callback=self._setting_changed, + label=setting.label, width=150) + with dpg.group(): + if setting.kind == ENUM: + dpg.add_combo(list(setting.choices), + default_value=value or setting.default, **common) + elif setting.kind == BOOL: + dpg.add_checkbox(default_value=bool(value), tag=tag, user_data=data, + callback=self._setting_changed, label=setting.label) + elif setting.kind == INT: + dpg.add_input_int(default_value=int(value or 0), step=1, **common) + elif setting.kind == FLOAT: + dpg.add_input_float(default_value=float(value or 0.0), step=0.0, + format="%.3f", **common) + elif setting.kind == COLOR: + colour = list(value or (255, 255, 255)) + [255] + dpg.add_color_edit(default_value=colour[:4], tag=tag, user_data=data, + callback=self._setting_changed, + label=setting.label, width=150, + no_alpha=True) + else: + dpg.add_input_text(default_value="" if value is None else str(value), + hint="leave empty for none" if setting.optional else "", + **common) + if setting.help: + with dpg.tooltip(tag): + dpg.add_text(setting.help, wrap=360) + + def _controller_fields(self): + """Show only the fields the chosen controller actually uses.""" + socket_based = self.controller["kind"] in ("classifier", "regressor") + for tag in ("__env_ip", "__env_port"): + if dpg.does_item_exist(tag): + dpg.configure_item(tag, show=socket_based) + if dpg.does_item_exist("__env_classes"): + dpg.configure_item("__env_classes", + show=self.controller["kind"] == "classifier") + + # ------------------------------------------------------------------ + def _choose(self, sender, app_data): + for key, spec in self.registry.items(): + if spec.title == app_data: + self.selected = key + break + # Keep the chooser showing what is actually selected. Reset settings + # and any programmatic change come through here too, and a combo left + # showing the previous task is worse than no label at all. + if dpg.does_item_exist("__env_choice"): + dpg.set_value("__env_choice", self.registry[self.selected].title) + self.values = self.registry[self.selected].defaults() + if dpg.does_item_exist("__env_body"): + dpg.delete_item("__env_body", children_only=True) + dpg.push_container_stack("__env_body") + self._build_body() + dpg.pop_container_stack() + self._describe() + + def _reset(self): + self.values = self.registry[self.selected].defaults() + self._choose(None, self.registry[self.selected].title) + self._say("Settings reset to the environment's own defaults.") + + def _controller_changed(self, sender, app_data): + self.controller["kind"] = app_data.strip().lower() + self._controller_fields() + + def _setting_changed(self, sender, app_data, user_data): + spec = self.registry[self.selected] + setting = spec.setting(user_data) + if setting is None: + return + if setting.kind == COLOR: + # The colour control works in 0-1 floats; the environments want + # 0-255 integers. + app_data = [c * 255 if c <= 1.0 else c for c in app_data[:3]] + self.values[user_data] = setting.coerce(app_data) + self._describe() + + def _describe(self): + spec = self.registry[self.selected] + width, height = spec.frame_size(self.values) + self._say(f"{spec.title} will draw at {width} by {height} pixels, " + f"driven by the {_title(self.controller['kind'])} controller.") + + # ================================================================== + # playing + # ================================================================== + def _launch(self): + if self.embedded is not None: + self._say("Already running. Stop it first.") + return + spec = self.registry[self.selected] + width, height = spec.frame_size(self.values) + settings = {name: spec.setting(name).coerce(value) + for name, value in self.values.items() + if spec.setting(name) is not None} + controller = ControllerSpec(kind=self.controller["kind"], + ip=self.controller["ip"], + port=int(self.controller["port"]), + num_classes=int(self.controller["num_classes"]), + output_format=self.controller["output_format"]) + try: + factory = build_factory(spec, controller, settings) + except Exception: + self._say("Could not prepare that task:\n" + + traceback.format_exc().strip().splitlines()[-1]) + return + + self._counter += 1 + name = f"libemg_env_{spec.id}_{self._counter}" + try: + self.embedded = EmbeddedEnvironment(name, factory, width, height).start() + except Exception: + self._say("Could not start that task:\n" + + traceback.format_exc().strip().splitlines()[-1]) + self.embedded = None + return + self._frame_size = (width, height) + self._open_play_window(spec, width, height) + self._say(f"{spec.title} is running. Click the game to give it the keyboard.") + + def _open_play_window(self, spec, width, height): + for tag in (PLAY_TAG, TEXTURE_TAG): + if dpg.does_alias_exist(tag): + dpg.delete_item(tag) + # The texture is backed by the very memory the environment draws into, + # so there is no per-frame upload and nothing to copy here. What the + # game paints is what this shows. + with dpg.texture_registry(): + dpg.add_raw_texture(width, height, self.embedded.pixels(), + format=dpg.mvFormat_Float_rgba, tag=TEXTURE_TAG) + # Offset from the setup window rather than opening on top of it, so the + # settings stay readable while the task runs and can be compared + # against what is happening on screen. + offset = (min(self.width, 1600) - width - 40, 40) + position = (max(20, offset[0]), offset[1]) + with dpg.window(label=f"{spec.title}", tag=PLAY_TAG, + width=width + 20, height=height + 80, pos=position, + on_close=lambda: self.stop()): + with dpg.group(horizontal=True): + dpg.add_text("", tag=STATUS_TAG) + dpg.add_spacer(width=20) + dpg.add_button(label="Stop", callback=self.stop) + dpg.add_image(TEXTURE_TAG, tag="__env_image") + dpg.focus_item(PLAY_TAG) + + def stop(self, message="Stopped."): + """Stop the running environment and release its memory. + + Parameters + ---------- + message: str (optional), default='Stopped.' + What to leave on screen. The default is replaced when the reason + for stopping is worth keeping: reporting why a task refused its + settings and then immediately overwriting it with "Stopped." tells + the user nothing at all. + """ + if self.embedded is None: + return + try: + self.embedded.stop() + except Exception: + pass + self.embedded = None + if dpg.does_alias_exist(PLAY_TAG): + dpg.delete_item(PLAY_TAG) + if dpg.does_alias_exist(TEXTURE_TAG): + dpg.delete_item(TEXTURE_TAG) + self._say(message) + + def cleanup(self): + """Stop anything running and remove the windows.""" + self.stop() + for tag in (WINDOW_TAG,): + if dpg.does_alias_exist(tag): + dpg.delete_item(tag) + + # ================================================================== + # per-frame, on the render thread + # ================================================================== + def poll(self): + """Forward input and refresh status. Called once per rendered frame. + + The keyboard is read here rather than through key handlers because a + game wants to know what is held right now, every frame, not to be told + once when a key goes down. + """ + if self.embedded is None: + return + held = {name for name, key in _DPG_KEYS.items() + if _key_down(key)} + pointer = None + if dpg.does_item_exist("__env_image"): + try: + x, y = dpg.get_drawing_mouse_pos() if False else dpg.get_mouse_pos(local=False) + origin = dpg.get_item_rect_min("__env_image") + pointer = (int(x - origin[0]), int(y - origin[1])) + except Exception: + pointer = None + self.embedded.send_input(held, mouse=pointer, + mouse_down=dpg.is_mouse_button_down(dpg.mvMouseButton_Left)) + + status = self.embedded.status() + if dpg.does_item_exist(STATUS_TAG): + dpg.set_value(STATUS_TAG, + "frames %d %.0f fps %s" + % (status["frames"], status["fps"], + "running" if status["running"] else "stopping")) + if not status["running"] and status["frames"] == 0 and not self.embedded.alive: + # It never drew anything, so it failed to start. The reason comes + # back through the bridge, because the traceback happened in a + # process nobody is watching. + reason = status["error"] or "it stopped before drawing anything" + self.stop(f"That task could not start: {reason}") + return + if status["finished"] or not self.embedded.alive: + self.stop("The task finished." if status["finished"] + else f"The task stopped. {status['error']}".strip()) + + # ------------------------------------------------------------------ + def _say(self, message): + if dpg.does_item_exist(MESSAGE_TAG): + dpg.set_value(MESSAGE_TAG, message) + + +def _key_down(name): + key = getattr(dpg, name, None) + if key is None: + return False + try: + return dpg.is_key_down(key) + except Exception: + return False + + +def _title(kind): + return str(kind).strip().title() + + +def _is_appearance(setting): + """Whether a setting is about how the task looks rather than what it does.""" + return setting.kind == COLOR or "color" in setting.name + diff --git a/libemg/_gui/_environments/registry.py b/libemg/_gui/_environments/registry.py new file mode 100644 index 00000000..295d02ee --- /dev/null +++ b/libemg/_gui/_environments/registry.py @@ -0,0 +1,372 @@ +"""What environments can be launched, and what each one can be set up with. + +As with the pipeline editor, the setup screens are generated rather than +hand-written. An environment's settings are already declared: Fitts and +Curricular Fitts have configuration dataclasses with typed fields and +documented defaults, and EMG Hero declares its settings as constructor +arguments. Reading those is what keeps a setup screen correct when somebody +adds a setting, instead of correct until somebody adds a setting. + +The per-field help text comes from the same docstrings the API documentation is +built from, so what a user reads beside a control is what the author wrote +about it. +""" + +import dataclasses +import inspect +import re +import typing + +# Field kinds. The panel maps each to one control. +INT = "int" +FLOAT = "float" +BOOL = "bool" +STR = "str" +ENUM = "enum" +COLOR = "color" +PATH = "path" + + +class Setting: + """One configurable value on an environment. + + Attributes + ---------- + name: str + Keyword it is passed as. + kind: str + One of the module-level kinds, deciding the control drawn. + label: str + What the setup screen shows. + default: Any + Starting value, taken from the environment's own default. + choices: list or None + Allowed values, for an enumeration. + minimum, maximum: float or None + Bounds. + help: str + Taken from the environment's own docstring. + optional: bool + Whether None is a meaningful value, as it is for a timeout that can be + switched off. + """ + + def __init__(self, name, kind, default=None, label=None, choices=None, + minimum=None, maximum=None, help="", optional=False, + required=False): + self.name = name + self.kind = kind + self.default = default + self.label = label or name.replace("_", " ").strip().title() + self.choices = choices + self.minimum = minimum + self.maximum = maximum + self.help = help + self.optional = optional + #: The environment declares no default for this, so it is the user's + #: to choose. A usable starting value is offered anyway, and the label + #: says so, because a control showing zero looks like a setting rather + #: than a blank. + self.required = required + if required: + self.label += " (required)" + + def coerce(self, value): + """Bring a value from a control into this setting's type. + + An optional number is the interesting case. A timeout or a time limit + that can be switched off has no number meaning "off", so a numeric + control has to spell that somehow; zero is the only value a spinner + can offer that is not a real duration, so zero means off. Without this + a timeout left at zero would fail every trial the instant it began. + """ + if value is None or value == "": + return None if self.optional else self.default + if self.optional and self.kind in (INT, FLOAT): + try: + if float(value) <= 0: + return None + except (TypeError, ValueError): + return None + try: + if self.kind == INT: + value = int(float(value)) + elif self.kind == FLOAT: + value = float(value) + elif self.kind == BOOL: + value = bool(value) if not isinstance(value, str) \ + else value.strip().lower() in ("1", "true", "yes", "on") + elif self.kind == COLOR: + value = tuple(int(max(0, min(255, round(float(c))))) for c in value[:3]) + elif self.kind in (STR, PATH): + value = str(value) + except (TypeError, ValueError): + return self.default + if self.kind in (INT, FLOAT): + if self.minimum is not None: + value = max(value, type(value)(self.minimum)) + if self.maximum is not None: + value = min(value, type(value)(self.maximum)) + if self.kind == ENUM and self.choices and value not in self.choices: + return self.default + return value + + def __repr__(self): + return f"Setting({self.name!r}, {self.kind!r}, default={self.default!r})" + + +class EnvironmentSpec: + """An environment that can be launched from the menu. + + Attributes + ---------- + id: str + Stable identifier. + title: str + Shown in the menu and on the setup screen. + help: str + A sentence describing the task. + settings: list of Setting + What can be configured. + controllers: list of str + Which controller kinds make sense for it. + """ + + def __init__(self, id, title, factory_name, settings, help="", + controllers=("Keyboard", "Classifier", "Regressor"), + size_from=("width", "height"), default_size=(1000, 700)): + self.id = id + self.title = title + self.factory_name = factory_name + self.settings = settings + self.help = help + self.controllers = list(controllers) + self.size_from = size_from + self.default_size = default_size + + def defaults(self): + return {s.name: s.default for s in self.settings} + + def setting(self, name): + for s in self.settings: + if s.name == name: + return s + return None + + def frame_size(self, values): + """The frame this environment will draw. + + Read from the settings where the environment takes its size from them, + and fixed where the environment decides for itself. The texture has to + be made before the first frame arrives, so this has to agree with what + the environment will actually set up. + """ + if not self.size_from: + return tuple(int(v) for v in self.default_size) + width_key, height_key = self.size_from + width = values.get(width_key) or self.default_size[0] + height = values.get(height_key) or self.default_size[1] + return int(width), int(height) + + +# ---------------------------------------------------------------------- +# Generation +# ---------------------------------------------------------------------- +def _docstring_help(owner): + """Map parameter name to its description, from a numpydoc docstring. + + The environments already document every setting where it is declared, so + the setup screen shows the author's own words rather than a second + description that could drift from the first. + """ + text = inspect.getdoc(owner) or "" + if "Parameters" not in text: + return {} + body = text.split("Parameters", 1)[1] + body = re.split(r"\n\s*-{3,}\s*\n", body, maxsplit=1) + body = body[1] if len(body) > 1 else body[0] + found, current, buffer = {}, None, [] + for line in body.splitlines(): + header = re.match(r"^(\w+)\s*[:(]", line) + if header and not line.startswith((" ", "\t")): + if current: + found[current] = " ".join(buffer).strip() + current, buffer = header.group(1), [] + elif current: + buffer.append(line.strip()) + if current: + found[current] = " ".join(buffer).strip() + return {k: v for k, v in found.items() if v} + + +def _kind_for(name, annotation, default): + """Decide what control a field wants, from its type and its name.""" + text = str(annotation) + optional = "None" in text or default is None + if name.endswith("color") or name.startswith("color"): + return COLOR, optional + if "bool" in text or isinstance(default, bool): + return BOOL, optional + if "int" in text and "float" not in text: + return INT, optional + if "float" in text: + return FLOAT, optional + if isinstance(default, bool): + return BOOL, optional + if isinstance(default, int): + return INT, optional + if isinstance(default, float): + return FLOAT, optional + return STR, optional + + +def _settings_from_dataclass(config_class, skip=()): + """Turn a configuration dataclass into settings.""" + help_text = _docstring_help(config_class) + settings = [] + for field in dataclasses.fields(config_class): + if field.name in skip: + continue + default = field.default + if default is dataclasses.MISSING: + default = (field.default_factory() + if field.default_factory is not dataclasses.MISSING else None) + if callable(default) and not isinstance(default, (tuple, list)): + # A behaviour, not a value. Nothing sensible to draw for it, and + # the environment's own default is the right one. + continue + if isinstance(default, tuple) and len(default) == 3 and \ + all(isinstance(v, int) for v in default): + kind, optional = COLOR, False + elif isinstance(default, (tuple, list)): + continue + else: + kind, optional = _kind_for(field.name, field.type, default) + minimum = 0 if kind in (INT, FLOAT) and not field.name.startswith("color") else None + required = field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING + if required: + # A field with no default looks optional to the type sniffing + # above, because it has no default to inspect. It is the opposite: + # the environment cannot run without it, so None is not a value it + # may take. + optional = False + if kind in (INT, FLOAT) and default is None: + # Offered so the task is runnable straight away; the label + # marks it as the user's to set. + default = 1 + settings.append(Setting(field.name, kind, default=default, + minimum=minimum, optional=optional, + required=required, + help=help_text.get(field.name, ""))) + return settings + + +def _settings_from_signature(owner, skip=()): + """Turn a constructor's keyword arguments into settings.""" + # A class may document its parameters on itself or on its constructor, and + # both are common. Looking in only one place left every EMG Hero setting + # with no help beside it, because that class documents its arguments where + # they are declared. + help_text = _docstring_help(owner) + if not help_text: + help_text = _docstring_help(owner.__init__) + settings = [] + signature = inspect.signature(owner.__init__) + for name, parameter in signature.parameters.items(): + if name in skip or name == "self": + continue + if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): + continue + default = None if parameter.default is inspect.Parameter.empty else parameter.default + if callable(default) or isinstance(default, (list, tuple, dict)): + continue + kind, optional = _kind_for(name, parameter.annotation, default) + settings.append(Setting(name, kind, default=default, + minimum=0 if kind in (INT, FLOAT) else None, + optional=optional, help=help_text.get(name, ""))) + return settings + + +def build_registry(): + """Describe every environment that can be launched. + + Returns + ---------- + dict + Mapping from spec id to :class:`EnvironmentSpec`. + """ + from libemg.environments.curricular_fitts import CurricularFittsConfig + from libemg.environments.emg_hero import EMGHero + from libemg.environments.fitts import Fitts, FittsConfig, ISOFitts + + specs = {} + + fitts_settings = _settings_from_dataclass(FittsConfig) + mapping = next((s for s in fitts_settings if s.name == "mapping"), None) + if mapping is not None: + # A free text box would let somebody type a mapping that is rejected + # only once the environment starts. Note that bare 'polar' is not one + # of them: the environment accepts 'polar+' and 'polar-', which say + # which way up maps, and raises on anything else. + mapping.kind = ENUM + mapping.choices = ["cartesian", "polar+", "polar-"] + + specs["fitts"] = EnvironmentSpec( + id="fitts", title="Fitts' Law", factory_name="fitts", + settings=fitts_settings, + help="A cursor and a single target. The classic test of how quickly and " + "accurately a control scheme can acquire a target.", + default_size=(1250, 750)) + + iso_settings = list(fitts_settings) + [ + Setting("num_targets", INT, default=8, minimum=2, maximum=24, + label="Number Of Targets", + help="Targets arranged around the ring."), + Setting("target_distance_radius", INT, default=275, minimum=10, + label="Ring Radius", + help="Distance in pixels from the centre to each target."), + ] + specs["iso_fitts"] = EnvironmentSpec( + id="iso_fitts", title="ISO Fitts' Law", factory_name="iso_fitts", + settings=iso_settings, + help="Targets arranged in a ring, acquired in the standard ISO 9241-9 " + "order. The usual way to report a throughput figure.", + default_size=(1250, 750)) + + specs["curricular_fitts"] = EnvironmentSpec( + id="curricular_fitts", title="Curricular Fitts", + factory_name="curricular_fitts", + settings=_settings_from_dataclass( + CurricularFittsConfig, skip=("controller_fields", "controller_map", + "feedback_handle")), + help="A Fitts task whose difficulty adapts as the user improves. The task " + "used for user-in-the-loop adaptation. Needs a two degree of freedom " + "controller, so a regressor rather than a keyboard.", + # Only a regressor. This task moves its cursor on two axes at once and + # indexes both, so a controller that yields a single value per frame, + # which is what the keyboard gives, fails on the first frame it moves. + controllers=("Regressor",), + default_size=(1000, 1080)) + + specs["emg_hero"] = EnvironmentSpec( + id="emg_hero", title="EMG Hero", factory_name="emg_hero", + settings=_settings_from_signature( + EMGHero, skip=("controller", "prediction_map", "img_files")), + help="Notes fall down the screen and are hit with the matching gesture. " + "A rhythm game for practising discrete control.", + # EMG Hero sets its own display size and offers no width or height + # setting, so the frame size is fixed rather than read from settings. + size_from=(), default_size=(525, 700)) + + return specs + + +_DEFAULT = None + + +def default_registry(refresh=False): + """The environment registry, built once per process.""" + global _DEFAULT + if _DEFAULT is None or refresh: + _DEFAULT = build_registry() + return _DEFAULT diff --git a/libemg/_gui/_pipeline/__init__.py b/libemg/_gui/_pipeline/__init__.py new file mode 100644 index 00000000..158d3aa6 --- /dev/null +++ b/libemg/_gui/_pipeline/__init__.py @@ -0,0 +1,25 @@ +"""Visual pipeline construction for LibEMG. + +Three layers, deliberately separated so that only the last one needs a display: + +- :mod:`~libemg._gui._pipeline.registry` describes the blocks that can be + placed, generated from the library so it does not rot as LibEMG gains + features and devices. +- :mod:`~libemg._gui._pipeline.document` is the pipeline itself as plain data, + which is what gets saved and loaded. +- :mod:`~libemg._gui._pipeline.compile` turns a document into something that + runs. + +A document can be built, saved, loaded, validated and run without DearPyGui +ever being imported. The editor is a view onto it, not the thing itself. +""" + +from libemg._gui._pipeline.document import (Link, Node, PipelineDocument, Probe, + SCHEMA_VERSION) +from libemg._gui._pipeline.registry import (NodeSpec, ParamSpec, PortSpec, + PortType, build_registry, + default_registry) + +__all__ = ["Link", "Node", "PipelineDocument", "Probe", "SCHEMA_VERSION", + "NodeSpec", "ParamSpec", "PortSpec", "PortType", "build_registry", + "default_registry"] diff --git a/libemg/_gui/_pipeline/compile.py b/libemg/_gui/_pipeline/compile.py new file mode 100644 index 00000000..a1c0c4cf --- /dev/null +++ b/libemg/_gui/_pipeline/compile.py @@ -0,0 +1,957 @@ +"""Turn a pipeline document into something that runs. + +Online, the document becomes a reactive graph: a streamer writes into shared +memory and each stage is woken by the one before it. Offline, the same document +is walked once over a recording, which is a sequential run rather than a graph +because there is no stream to react to and a progress bar wants to know how far +through it is. + +Which of the two happens is inferred from the document's sources, never +configured. A user who had to declare it could declare it wrongly and get a +pipeline that silently did nothing. + +Fusing the window +----------------- +A window block does not become a stage of its own. What it configures is *how +the next stage observes the one before it*: its increment becomes that stage's +criterion and its size becomes the number of rows delivered. So a window +between a filter and a feature block compiles to the feature block observing +the filter with ``OnSamples(increment)`` in window mode. That is why the block +has no online-or-offline mode to set, and why nothing has to copy an enframed +array into shared memory just to hand it along. +""" + +import os +import time + +import numpy as np + +from libemg._gui._pipeline.registry import (FEATURES, MODEL, SINK, SOURCE, + TRANSFORM, WINDOW) +from libemg.reactive import (DELTA, LATEST, WINDOW as WINDOW_MODE, Hook, Input, + OnCommit, OnSamples, Output, Periodic, + ReactiveGraph, default_notifier_pool) + + +def tag_for(node_id, port): + """The shared-memory item a node's output port publishes to.""" + return f"pipe_{node_id}_{port}" + + +def _as_floats(text, default=None): + """Parse a comma-separated parameter into numbers.""" + if isinstance(text, (list, tuple)): + return [float(v) for v in text] + parts = [p.strip() for p in str(text).split(",") if p.strip()] + if not parts: + return default + try: + return [float(p) for p in parts] + except ValueError: + return default + + +def _as_ints(text, default=None): + values = _as_floats(text, None) + return [int(v) for v in values] if values is not None else default + + +class CompileError(Exception): + """Raised when a document cannot be turned into a runnable pipeline.""" + + +# ====================================================================== +# Hooks the compiler builds +# ====================================================================== +class _FilterHook(Hook): + """Apply a filter to a stream, once, for everything downstream.""" + + def __init__(self, name, source, target, filter_config, shape, window, increment): + super().__init__( + name, + inputs=[Input(source, OnSamples(increment), mode=WINDOW_MODE, size=window)], + outputs=[Output(target, shape, np.double)], + ) + self.source, self.target = source, target + self.filter_config = filter_config + self.increment = increment + self._filter = None + + def setup(self, context): + # Built here, not in __init__: a spawned executor receives this hook by + # pickling, and a configured filter object need not survive that. + from libemg.filtering import Filter + self._filter = Filter(sampling_frequency=self.filter_config["sampling_rate"]) + self._filter.install_filters(self.filter_config["dictionary"]) + + def step(self, data, snapshots): + samples = data[self.source] + if samples.shape[0] == 0: + return None + filtered = self._filter.filter(samples) + # Only the newest increment is new. The rest was emitted by an earlier + # run and re-publishing it would duplicate rows downstream; it is read + # at all so the filter sees enough history for its edges to settle. + return {self.target: filtered[-self.increment:]} + + +class _ChannelMaskHook(Hook): + """Narrow a stream to a subset of channels.""" + + def __init__(self, name, source, target, channels, shape, increment=1): + super().__init__( + name, + inputs=[Input(source, OnSamples(increment), mode=DELTA)], + outputs=[Output(target, shape, np.double)], + ) + self.source, self.target, self.channels = source, target, channels + + def step(self, data, snapshots): + samples = data[self.source] + if samples.shape[0] == 0: + return None + return {self.target: samples[:, self.channels]} + + +class _FeatureHook(Hook): + """Extract features from each window.""" + + def __init__(self, name, source, target, feature_list, window_size, + window_increment, num_features, rows=200): + super().__init__( + name, + inputs=[Input(source, OnSamples(window_increment), + mode=WINDOW_MODE, size=window_size)], + outputs=[Output(target, (rows, num_features), np.double)], + ) + self.source, self.target = source, target + self.feature_list = list(feature_list) + self.window_size = window_size + self._extractor = None + + def setup(self, context): + from libemg.feature_extractor import FeatureExtractor + self._extractor = FeatureExtractor() + + def step(self, data, snapshots): + samples = data[self.source] + if samples.shape[0] < self.window_size: + return None + window = samples.transpose()[np.newaxis, :, :] + return {self.target: self._extractor.extract_features( + self.feature_list, window, array=True)} + + +class _ModelHook(Hook): + """Run a fitted predictor on whatever its input delivers.""" + + def __init__(self, name, source, target, model_path, width, rows=200, + criterion=None, mode=LATEST, size=None): + super().__init__( + name, + inputs=[Input(source, criterion or OnSamples(1), mode=mode, size=size)], + outputs=[Output(target, (rows, width), np.double)], + ) + self.source, self.target = source, target + self.model_path = model_path + self.width = width + self._predictor = None + + def setup(self, context): + import pickle + # A fitted model can be large and is not always picklable in the way a + # spawned process needs, so the hook carries a path and opens it here. + with open(self.model_path, "rb") as handle: + self._predictor = pickle.load(handle) + + def step(self, data, snapshots): + model_input = np.atleast_2d(data[self.source]) + if model_input.size == 0 or self._predictor is None: + return None + row = self._predict(model_input) + if row is None: + return None + padded = np.zeros((1, self.width), dtype=np.double) + row = np.asarray(row, dtype=np.double).ravel()[:self.width] + padded[0, :row.size] = row + return {self.target: padded} + + def _predict(self, model_input): + raise NotImplementedError + + +class _ClassifierHook(_ModelHook): + """Predict a class, with its confidence.""" + + def _predict(self, model_input): + probabilities = self._predictor._predict_proba(model_input) + probabilities = np.atleast_2d(probabilities) + index = int(np.argmax(probabilities, axis=1)[0]) + return [float(index), float(np.max(probabilities))] + + +class _RegressorHook(_ModelHook): + """Predict continuous outputs.""" + + def _predict(self, model_input): + return np.atleast_2d(self._predictor._predict(model_input))[0] + + +class _SinkHook(Hook): + """Hand each output to a LibEMG output writer.""" + + def __init__(self, name, source, kind, config): + super().__init__(name, inputs=[Input(source, OnCommit(), mode=LATEST)]) + self.source, self.kind, self.config = source, kind, config + self._writer = None + + def setup(self, context): + from libemg.output_writer import (ConsoleOutputWriter, FileOutputWriter, + SocketOutputWriter) + # Sockets and file handles are exactly the things that cannot be + # pickled into a spawned process, so they are opened here. + if self.kind == "socket": + self._writer = SocketOutputWriter("value", ip=self.config["ip"], + port=self.config["port"], + protocol=self.config["protocol"]) + elif self.kind == "file": + path = self.config["file_path"] + folder = os.path.dirname(os.path.abspath(path)) + os.makedirs(folder, exist_ok=True) + self._writer = FileOutputWriter("value", folder + os.sep, + os.path.basename(path)) + else: + self._writer = ConsoleOutputWriter("value") + + def step(self, data, snapshots): + row = np.asarray(data[self.source]).ravel() + self._writer.write({"timestamp": time.time(), + "value": row.tolist(), + "prediction": row[0] if row.size else None, + "probability": row[1] if row.size > 1 else None, + "velocity": None}) + return None + + +class _ProbeHook(Hook): + """Publish a rate-limited copy of a port for the editor to draw. + + The probe writes into its own shared-memory item rather than calling back, + because the thing drawing it is a GUI in another process and a probe must + never be able to stall what it watches. + """ + + def __init__(self, name, source, target, shape, hz, mode, size): + super().__init__( + name, + inputs=[Input(source, Periodic(hz), mode=mode, size=size)], + outputs=[Output(target, shape, np.double)], + ) + self.source, self.target, self.rows = source, target, shape[0] + self.width = shape[1] + + def step(self, data, snapshots): + block = np.atleast_2d(np.asarray(data[self.source], dtype=np.double)) + out = np.zeros((min(block.shape[0], self.rows), self.width), dtype=np.double) + usable = min(block.shape[1], self.width) + out[:, :usable] = block[:out.shape[0], :usable] + return {self.target: out} + + +# ====================================================================== +# The compiled results +# ====================================================================== +class OnlinePipeline: + """A running, or runnable, live pipeline. + + Produced by :func:`compile_pipeline`. Owns the streamer process and the + reactive graph, so stopping it stops both. + """ + + def __init__(self, document, graph, shared_memory_items, streamer_specs, + probe_items, source_tags, log=None): + self.document = document + self.graph = graph + self.shared_memory_items = shared_memory_items + self.streamer_specs = streamer_specs + self.probe_items = probe_items + self.source_tags = source_tags + self.log = log + self.streamers = [] + self._running = False + + @property + def running(self): + return self._running + + def start(self): + """Start the devices, then the graph.""" + if self._running: + return self + from libemg import streamers as streamer_module + from libemg._gui._pipeline import synthetic + pool = default_notifier_pool() + for spec in self.streamer_specs: + function = getattr(streamer_module, spec["function"], None) + if function is None: + function = getattr(synthetic, spec["function"]) + result = function(shared_memory_items=spec["items"], **spec["kwargs"]) + handle = result[0] if isinstance(result, tuple) else result + if handle is not None: + handle.notifier_pool = pool + self.streamers.append(handle) + self.graph.start() + self._running = True + return self + + def stop(self, timeout=5.0): + """Stop the graph, then the devices.""" + if not self._running: + return + self.graph.stop(timeout=timeout) + for handle in self.streamers: + try: + if hasattr(handle, "signal"): + handle.signal.set() + elif hasattr(handle, "terminate"): + handle.terminate() + except Exception: + pass + self.streamers = [] + self._running = False + + def status(self): + """Throughput and lag for each item the pipeline publishes. + + Returns + ---------- + dict + Mapping from item tag to its + :class:`~libemg.shared_memory_manager.Snapshot`. + """ + from libemg.shared_memory_manager import SharedMemoryManager + reader = SharedMemoryManager() + out = {} + declared = {item[0]: item for item in self.shared_memory_items} + for tag, item in declared.items(): + if tag.endswith("_count"): + continue + if reader.find_variable(*item): + out[tag] = reader.snapshot(tag) + reader.cleanup(parent=False) + return out + + def __enter__(self): + return self.start() + + def __exit__(self, *exc): + self.stop() + return False + + +class OfflinePipeline: + """A pipeline over stored data, run once from start to finish.""" + + def __init__(self, document, plan): + self.document = document + self.plan = plan + self.progress = 0.0 + self.results = {} + #: True when the last run was cut short. Metrics from a stopped run are + #: real but computed over only the recordings that were read, so a + #: caller has to be able to tell the two apart. + self.stopped = False + self.files_read = 0 + self.files_total = 0 + self._stop = False + + def request_stop(self): + """Ask a run in progress to finish early.""" + self._stop = True + + def run(self, on_progress=None): + """Walk the recording through every stage and score the result. + + Parameters + ---------- + on_progress: callable or None (optional), default=None + Called as ``on_progress(fraction)`` as the run advances. A run over + stored data knows its own total, which is why this can report a + real fraction rather than a spinner. + + Returns + ---------- + dict + Metric name to value, from the metrics block. Empty if there is + none. + """ + from libemg.offline_metrics import OfflineMetrics + + plan = self.plan + windows, features, truth = self._gather(on_progress) + if windows is None: + self.results = {} + return self.results + + predictions = None + if plan["model_path"]: + import pickle + with open(plan["model_path"], "rb") as handle_: + predictor = pickle.load(handle_) + model_input = features if features is not None else windows + predictions = np.asarray(predictor._predict(model_input)) + # Classification metrics compare indices, and an index that came + # back as a float would make every comparison and every confusion + # matrix row wrong. Which one to do follows from what was saved. + from libemg.emg_predictor import EMGClassifier as _Classifier + if isinstance(predictor, _Classifier): + predictions = predictions.ravel().astype(int) + if truth is not None: + truth = np.asarray(truth).astype(int) + elif predictions.ndim == 1: + predictions = predictions.reshape(-1, 1) + + self.results = {} + if plan["metrics"] and predictions is not None and truth is not None: + count = min(len(predictions), len(truth)) + self.results = OfflineMetrics().extract_offline_metrics( + plan["metrics"], truth[:count], predictions[:count], + null_label=plan["null_label"]) + # A run that was asked to stop must not claim to have finished. Setting + # the fraction to one and reporting it would leave a progress bar full + # and a caller unable to tell a cancelled run from a complete one, even + # though the metrics below cover only part of the recording. + if not self.stopped: + self.progress = 1.0 + if on_progress is not None: + on_progress(1.0) + return self.results + + def _gather(self, on_progress=None): + """Walk the recording into windows, features and labels. + + Shared by scoring and by training, because they read the recording the + same way and differ only in what they do at the end. One walk means a + model is fitted on exactly the data it will later be scored on. + + Returns + ---------- + windows: numpy.ndarray or None + Enframed samples, or None when the recording produced none. + features: numpy.ndarray or None + Extracted features, or None when there is no features block. + truth: numpy.ndarray or None + Windowed labels, or None when the recording carries none. + """ + from libemg.data_handler import OfflineDataHandler, RegexFilter + from libemg.feature_extractor import FeatureExtractor + from libemg.filtering import Filter + from libemg.utils import get_windows + + plan = self.plan + handler = OfflineDataHandler() + filters = [RegexFilter(**spec) for spec in plan["regex_filters"]] + handler.get_data(folder_location=plan["folder"], regex_filters=filters, + delimiter=plan["delimiter"]) + total = max(1, len(handler.data)) + self.progress = 0.0 + self.stopped = False + self.files_total = len(handler.data) + self.files_read = 0 + + windows, labels = [], [] + window_size, increment = plan["window_size"], plan["window_increment"] + conditioner = None + if plan["filters"]: + conditioner = Filter(sampling_frequency=plan["sampling_rate"]) + for dictionary in plan["filters"]: + if dictionary.get("name") == "standardize": + # Standardizing means subtracting a mean and dividing by a + # deviation, and those have to be measured from data. Here + # the recording is already loaded, so it supplies them. + dictionary = dict(dictionary, data=handler) + conditioner.install_filters(dictionary) + + for index, block in enumerate(handler.data): + if self._stop: + self.stopped = True + break + if plan["channels"] is not None: + block = block[:, plan["channels"]] + if conditioner is not None: + block = conditioner.filter(block) + enframed = get_windows(block, window_size, increment) + if enframed.shape[0]: + windows.append(enframed) + labels.append(self._labels_for(handler, plan["label_key"], + index, window_size, increment)) + self.files_read = index + 1 + self.progress = (index + 1) / total + if on_progress is not None: + on_progress(self.progress) + + if not windows: + return None, None, None + + windows = np.vstack(windows) + truth = np.concatenate([l for l in labels if l is not None]) \ + if any(l is not None for l in labels) else None + + features = None + if plan["features"]: + features = FeatureExtractor().extract_features( + plan["features"], windows, array=True) + return windows, features, truth + + def train(self, on_progress=None, model_path=None): + """Fit the pipeline's model on this recording and save it. + + The last thing that still needed a script. A model block names a model + and a file; this reads the recording exactly as a scoring run does, + fits that model to the features and labels it produced, and writes it + where the block expects to find it. The same document can then be + pointed at a device and run live. + + Parameters + ---------- + on_progress: callable or None (optional), default=None + Called as ``on_progress(fraction)`` while the recording is read. + model_path: str or None (optional), default=None + Where to save. Defaults to the model block's own Fitted Model path, + which is what makes the trained model immediately usable by the + pipeline that trained it. + + Returns + ---------- + dict + ``model_path`` where it was written, ``windows`` and ``features`` + it was fitted on, ``classes`` it learned for a classifier, and + ``dofs`` it learned for a regressor. + + Raises + ---------- + CompileError + If the block names no model or no file, if the model is one this + version cannot fit, if the recording yields no data or carries no + labels, or if the run was stopped before it finished. + """ + import pickle + from libemg.emg_predictor import (CLASSIFIER_MODELS, EMGClassifier, + EMGRegressor, REGRESSOR_MODELS) + + plan = self.plan + destination = model_path or plan["model_path"] + if not destination: + raise CompileError( + "The model block has no Fitted Model path, so there is nowhere " + "to save what training produces. Set that parameter first.") + name = plan.get("model_name") or "" + if not name: + raise CompileError("This pipeline has no model block to train.") + if name not in CLASSIFIER_MODELS and name not in REGRESSOR_MODELS: + # Checked before the recording is read, not after. A misspelled + # model would otherwise be reported only once the whole folder had + # been walked, which for a real session is minutes of waiting for + # an answer that was available immediately. + raise CompileError( + f"'{name}' is not a model this version can fit. Choose one of " + f"{sorted(set(CLASSIFIER_MODELS) | set(REGRESSOR_MODELS))}.") + + windows, features, truth = self._gather(on_progress) + if self.stopped: + # A model fitted on the part of the recording that was read before + # the stop would be saved under the same name as one fitted on all + # of it, and nothing afterwards could tell them apart. Stopping + # therefore leaves whatever file was there untouched. + raise CompileError( + "Training was stopped part way, so nothing was saved. The " + "model file is unchanged.") + if windows is None: + raise CompileError( + "That recording produced no windows. Check the folder, the " + "regex filters, and that the window is not longer than the " + "recordings.") + if truth is None: + raise CompileError( + "That recording carries no labels, so there is nothing to learn " + "from. Set the Stored Data block's Label Key to a metadata " + "field its regex filters produce.") + model_input = features if features is not None else \ + windows.reshape(windows.shape[0], -1) + count = min(len(model_input), len(truth)) + model_input, truth = model_input[:count], truth[:count] + + # Which kind of predictor follows from which model was named, so the + # user picks a model rather than picking a model and a kind that could + # disagree with each other. + if name in CLASSIFIER_MODELS: + predictor = EMGClassifier(name) + labels = truth.astype(int) + elif name in REGRESSOR_MODELS: + predictor = EMGRegressor(name) + # A regressor predicts one value per degree of freedom, so its + # targets are a matrix with a column per DOF. A label field that + # holds a single value per sample is one DOF, and saying so here is + # what lets a single-DOF recording train at all: scikit-learn's + # multi-output wrapper rejects a flat vector outright. + labels = np.asarray(truth, dtype=float) + if labels.ndim == 1: + labels = labels.reshape(-1, 1) + + predictor.fit({"training_features": model_input, + "training_labels": labels}) + folder = os.path.dirname(os.path.abspath(destination)) + if folder: + os.makedirs(folder, exist_ok=True) + with open(destination, "wb") as handle: + pickle.dump(predictor, handle) + return {"model_path": destination, + "windows": int(windows.shape[0]), + "features": int(model_input.shape[1]), + "classes": sorted(set(np.asarray(labels).ravel().tolist())) + if name in CLASSIFIER_MODELS else None, + "dofs": None if name in CLASSIFIER_MODELS else int(labels.shape[1])} + + @staticmethod + def _labels_for(handler, key, index, window_size, increment): + """Window a metadata field alongside the data it belongs to.""" + from libemg.utils import get_windows + values = getattr(handler, key, None) + if not values or index >= len(values): + return None + column = np.asarray(values[index]).reshape(-1, 1) + enframed = get_windows(column, window_size, increment) + if not enframed.shape[0]: + return None + # The label of a window is the label at its end, which is the + # convention the rest of LibEMG uses. The dtype is left as it was read: + # a class index arrives as an integer already, and a regression target + # read from a file is a real number that rounding would destroy. + return enframed[:, 0, -1] + + +# ====================================================================== +# Compilation +# ====================================================================== +def compile_pipeline(document, log=None, probe_rows=400, buffer_rows=2000): + """Turn a document into a runnable pipeline. + + Parameters + ---------- + document: PipelineDocument + What to compile. It is validated first, so a pipeline that cannot run + fails here rather than half way through starting. + log: EventLog or None (optional), default=None + Passed to the reactive graph, for an online pipeline. + probe_rows: int (optional), default=400 + Rows retained in each probe's buffer. + buffer_rows: int (optional), default=2000 + Rows in each intermediate stream buffer. + + Returns + ---------- + OnlinePipeline or OfflinePipeline + + Raises + ---------- + CompileError + If the document does not validate, or names something unbuildable. + """ + problems = document.validate() + if problems: + raise CompileError("This pipeline cannot run yet:\n" + + "\n".join(f" - {p}" for p in problems)) + mode = document.mode() + if mode == "offline": + return _compile_offline(document) + return _compile_online(document, log=log, probe_rows=probe_rows, + buffer_rows=buffer_rows) + + +def _window_feeding(document, node_id): + """The window block immediately upstream of a node, if any. + + A window is fused into whatever it feeds, so a stage asks what window + configures it rather than reading one as data. + """ + for link in document.links_into(node_id): + spec = document.registry.get(document.nodes[link.from_node].spec_id) + if spec is not None and spec.category == WINDOW: + return document.nodes[link.from_node] + return None + + +def _upstream_stream(document, node_id): + """Walk back past any window block to the item actually carrying samples.""" + for link in document.links_into(node_id): + upstream = document.nodes[link.from_node] + spec = document.registry.get(upstream.spec_id) + if spec is not None and spec.category == WINDOW: + return _upstream_stream(document, upstream.id) + return tag_for(link.from_node, link.from_port) + return None + + +def _filter_dictionary(params): + """Build the dictionary Filter.install_filters expects.""" + name = params.get("name", "bandpass") + cutoff = _as_floats(params.get("cutoff"), [20.0, 450.0]) + dictionary = {"name": name} + if name == "standardize": + return dictionary + if name == "notch": + dictionary["cutoff"] = cutoff[0] + dictionary["bandwidth"] = float(params.get("bandwidth", 3.0)) + return dictionary + dictionary["cutoff"] = cutoff if len(cutoff) > 1 else cutoff[0] + dictionary["order"] = int(params.get("order", 4)) + return dictionary + + +def _compile_online(document, log, probe_rows, buffer_rows): + from multiprocessing import Lock + + items, streamer_specs, probe_items = [], [], {} + source_tags, channels_of = {}, {} + + def declare(tag, shape, dtype=np.double): + lock = Lock() + items.append([tag, shape, dtype, lock]) + items.append([tag + "_count", (1, 1), np.int32, lock]) + + # --- sources ------------------------------------------------------- + for node_id in document.sources(): + node = document.nodes[node_id] + spec = document.registry[node.spec_id] + function_name = node.spec_id.split(".", 1)[1] + tag = tag_for(node_id, "emg") + # The streamer writes straight into the item its output port names, so + # nothing has to copy device samples before the first stage sees them. + channels = int(node.params.get("num_channels") or 8) + stream_items = [[tag, (buffer_rows, channels), np.double], + [tag + "_count", (1, 1), np.int32]] + from libemg.shared_memory_manager import assign_shared_memory_locks + assign_shared_memory_locks(stream_items) + items.extend(stream_items) + kwargs = {} + for param in spec.params: + value = node.params.get(param.name) + if value in (None, ""): + continue + kwargs[param.name] = value + streamer_specs.append({"function": function_name, "items": stream_items, + "kwargs": kwargs}) + source_tags[node_id] = tag + channels_of[tag] = channels + + graph = ReactiveGraph(items, log=log, notifier_pool=default_notifier_pool()) + + # --- stages, in dependency order ---------------------------------- + for node_id in document.order(): + node = document.nodes[node_id] + spec = document.registry.get(node.spec_id) + if spec is None or spec.category in (SOURCE, WINDOW): + continue + upstream = _upstream_stream(document, node_id) + if upstream is None: + raise CompileError(f"'{spec.title}' ({node_id}) has no input to read.") + window = _window_feeding(document, node_id) + window_size = int(window.params["window_size"]) if window else 200 + increment = int(window.params["window_increment"]) if window else 1 + channels = channels_of.get(upstream, 8) + + if spec.id == "transform.filter": + if node.params.get("name") == "standardize": + # Standardizing needs a mean and a deviation measured from + # data, and a live stream has none to measure from before it + # starts. Refusing here, by name, beats letting it compile and + # then fail inside a spawned executor where the reason is much + # harder to see. + raise CompileError( + f"'{spec.title}' ({node_id}) is set to standardize, which needs " + "statistics measured from data and so only works on a stored-data " + "pipeline. For a live stream, choose another filter type, or " + "standardize the features instead by installing a scaler on the " + "model you point this pipeline at.") + target = tag_for(node_id, "output") + declare(target, (buffer_rows, channels)) + channels_of[target] = channels + config = {"sampling_rate": int(node.params.get("sampling_rate", 1000)), + "dictionary": _filter_dictionary(node.params)} + # Filtered edges settle over a margin, so more history is read than + # is republished. Four increments is enough for the orders LibEMG's + # filters use without holding up the stage. + margin = max(increment * 4, 64) + graph.add(_FilterHook(node_id, upstream, target, config, + (buffer_rows, channels), margin, increment), + executor=node_id) + + elif spec.id == "transform.channel_mask": + selected = _as_ints(node.params.get("channels"), None) + selected = selected if selected else list(range(channels)) + target = tag_for(node_id, "output") + declare(target, (buffer_rows, len(selected))) + channels_of[target] = len(selected) + graph.add(_ChannelMaskHook(node_id, upstream, target, selected, + (buffer_rows, len(selected)), increment), + executor=node_id) + + elif spec.category == FEATURES: + features = _resolve_features(node.params) + width = len(features) * channels + target = tag_for(node_id, "output") + declare(target, (probe_rows, width)) + graph.add(_FeatureHook(node_id, upstream, target, features, + window_size, increment, width, rows=probe_rows), + executor=node_id) + channels_of[target] = width + + elif spec.category == MODEL: + path = node.params.get("model_path") or "" + if not path or not os.path.exists(path): + raise CompileError( + f"'{spec.title}' ({node_id}) needs a fitted model to run live. " + "Set its Fitted Model parameter to a saved predictor.") + width = 2 if spec.id == "model.classifier" else 8 + target = tag_for(node_id, "output") + declare(target, (probe_rows, width)) + channels_of[target] = width + # What the model observes, and how often, comes from whatever feeds + # it. Features arrive one row at a time; raw windows arrive on the + # window's increment. + feeds_features = _feeds_category(document, node_id, FEATURES) + hook_type = _ClassifierHook if spec.id == "model.classifier" else _RegressorHook + if feeds_features: + hook = hook_type(node_id, upstream, target, path, width, + rows=probe_rows, criterion=OnSamples(1), mode=LATEST) + else: + hook = hook_type(node_id, upstream, target, path, width, + rows=probe_rows, criterion=OnSamples(increment), + mode=WINDOW_MODE, size=window_size) + graph.add(hook, executor=node_id) + + elif spec.category == SINK: + kind = spec.id.split(".", 1)[1] + if kind == "metrics": + continue + graph.add(_SinkHook(node_id, upstream, kind, dict(node.params)), + executor=node_id) + + # --- probes -------------------------------------------------------- + for probe in document.probes: + source = tag_for(probe.node, probe.port) + if not any(item[0] == source for item in items): + continue + width = channels_of.get(source, 8) + target = f"probe_{probe.node}_{probe.port}" + render = document.probe_render(probe) + rows = probe_rows if render == "timeseries" else 1 + declare(target, (rows, width)) + graph.add(_ProbeHook(f"probe_{probe.node}_{probe.port}", source, target, + (rows, width), probe.hz, + WINDOW_MODE if rows > 1 else LATEST, + rows if rows > 1 else None), + executor=f"probe_{probe.node}") + probe_items[(probe.node, probe.port)] = {"tag": target, "render": render, + "rows": rows, "width": width} + + return OnlinePipeline(document, graph, items, streamer_specs, probe_items, + source_tags, log=log) + + +def _feeds_category(document, node_id, category): + """Whether the block immediately feeding this one is of a category.""" + for link in document.links_into(node_id): + upstream = document.nodes[link.from_node] + spec = document.registry.get(upstream.spec_id) + if spec is None: + continue + if spec.category == WINDOW: + return _feeds_category(document, upstream.id, category) + return spec.category == category + return False + + +def _resolve_features(params): + """The feature list a features block asks for.""" + from libemg.feature_extractor import FeatureExtractor + group = params.get("feature_group", "(custom)") + if group and group != "(custom)": + groups = FeatureExtractor().get_feature_groups() + if group in groups: + return list(groups[group]) + chosen = params.get("features") or [] + return list(chosen) or ["MAV"] + + +def _compile_offline(document): + """Flatten a stored-data pipeline into one sequential pass.""" + plan = {"folder": "", "regex_filters": [], "delimiter": ",", + "label_key": "classes", "sampling_rate": 1000, + "filters": [], "channels": None, "window_size": 200, + "window_increment": 50, "features": [], "model_path": "", + "model_name": "", "metrics": [], "null_label": None} + + for node_id in document.order(): + node = document.nodes[node_id] + spec = document.registry.get(node.spec_id) + if spec is None: + continue + if spec.id == "source.offline": + plan["folder"] = node.params.get("folder", "") + plan["delimiter"] = node.params.get("delimiter", ",") + plan["label_key"] = node.params.get("label_key", "classes") + plan["sampling_rate"] = int(node.params.get("sampling_rate", 1000)) + plan["regex_filters"] = _parse_regex_filters( + node.params.get("regex_filters", "")) + if not plan["folder"]: + raise CompileError("The Stored Data block needs a folder to read from.") + if not plan["regex_filters"]: + raise CompileError( + "The Stored Data block needs at least one regex filter, so it " + "knows which files belong to what.") + elif spec.id == "transform.filter": + plan["filters"].append(_filter_dictionary(node.params)) + plan["sampling_rate"] = int(node.params.get("sampling_rate", + plan["sampling_rate"])) + elif spec.id == "transform.channel_mask": + plan["channels"] = _as_ints(node.params.get("channels"), None) + elif spec.category == WINDOW: + plan["window_size"] = int(node.params["window_size"]) + plan["window_increment"] = int(node.params["window_increment"]) + elif spec.category == FEATURES: + plan["features"] = _resolve_features(node.params) + elif spec.category == MODEL: + plan["model_path"] = node.params.get("model_path", "") + # Carried so the pipeline can fit this model, not only run one that + # was fitted elsewhere. + plan["model_name"] = node.params.get("model", "") + elif spec.id == "sink.metrics": + plan["metrics"] = list(node.params.get("metrics") or []) + null_label = node.params.get("null_label", -1) + plan["null_label"] = None if null_label in (-1, None) else int(null_label) + + return OfflinePipeline(document, plan) + + +def _parse_regex_filters(text): + """Parse the regex-filter parameter into RegexFilter keyword arguments. + + One filter per line, written as ``left|right|values|description``, which is + the smallest thing that carries everything a RegexFilter needs and still + reads back as what the user typed. + """ + filters = [] + for line in str(text).splitlines(): + line = line.strip() + if not line: + continue + parts = [p.strip() for p in line.split("|")] + if len(parts) < 4: + raise CompileError( + f"Cannot read the filter '{line}'. Each line should be " + "left|right|values|description, for example _C_|_EMG.csv|0,1,2|classes.") + filters.append({"left_bound": parts[0], "right_bound": parts[1], + "values": [v.strip() for v in parts[2].split(",") if v.strip()], + "description": parts[3]}) + return filters diff --git a/libemg/_gui/_pipeline/document.py b/libemg/_gui/_pipeline/document.py new file mode 100644 index 00000000..460f1a82 --- /dev/null +++ b/libemg/_gui/_pipeline/document.py @@ -0,0 +1,542 @@ +"""A pipeline as plain data. + +This is what gets saved, loaded, diffed and validated. It imports neither +DearPyGui nor the LibEMG runtime, so a pipeline can be built and checked in a +test with no display and nothing running. + +Surviving a version change +-------------------------- +The registry is generated from the library, so it legitimately differs between +LibEMG versions. A file written against a newer version will name blocks this +one has never heard of. Refusing to open it, or opening it and quietly dropping +what it did not recognise, both lose the user's work. Instead an unknown node +loads as *unresolved*: it keeps its id, its parameters and its position, the +editor marks it, and saving puts it back exactly as it came in. The pipeline +will not run until it is dealt with, but nothing is destroyed by looking at it. +""" + +import json +from dataclasses import dataclass, field, asdict +from typing import Any, Dict, List, Optional + +from libemg._gui._pipeline.registry import (MODEL, SINK, SOURCE, WINDOW, NodeSpec, + PortType, default_registry) + +#: Bumped when the saved shape changes. Migrations are keyed off it. +SCHEMA_VERSION = 1 + + +@dataclass +class Node: + """One placed block. + + Attributes + ---------- + id: str + Unique within the document. + spec_id: str + Which :class:`~libemg._gui._pipeline.registry.NodeSpec` this is. + params: dict + Configured values, keyed by parameter name. + position: tuple + Canvas position, so layout survives a save. + unresolved: bool + Set when the registry has no such spec. The node is preserved verbatim + and the document will not compile until it is removed or the right + LibEMG version is installed. + """ + + id: str + spec_id: str + params: Dict[str, Any] = field(default_factory=dict) + position: tuple = (0, 0) + unresolved: bool = False + + def to_dict(self): + return {"id": self.id, "spec_id": self.spec_id, "params": dict(self.params), + "position": list(self.position)} + + @classmethod + def from_dict(cls, raw, registry): + spec_id = raw.get("spec_id", "") + node = cls(id=raw.get("id", ""), spec_id=spec_id, + params=dict(raw.get("params", {})), + position=tuple(raw.get("position", (0, 0))), + unresolved=spec_id not in registry) + if not node.unresolved: + node.params = _coerce(registry[spec_id], node.params) + return node + + +@dataclass +class Link: + """A connection between one node's output and another's input.""" + + from_node: str + from_port: str + to_node: str + to_port: str + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, raw): + return cls(raw["from_node"], raw["from_port"], raw["to_node"], raw["to_port"]) + + def key(self): + return (self.from_node, self.from_port, self.to_node, self.to_port) + + +@dataclass +class Probe: + """A request to watch one output port while the pipeline runs. + + A probe is not a node. Making it one would clutter the canvas and force the + user to wire up something they only want to look at, so it attaches to a + port instead. + + Attributes + ---------- + node: str + The node whose output is watched. + port: str + Which output. + hz: float + Maximum updates per second. A probe that cannot keep up with its source + is still a probe; it just shows less. + """ + + node: str + port: str + hz: float = 30.0 + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, raw): + return cls(raw["node"], raw["port"], float(raw.get("hz", 30.0))) + + def key(self): + return (self.node, self.port) + + +def _coerce(spec, params): + """Bring a node's stored parameters into the shapes its spec declares.""" + out = spec.defaults() + for name, value in params.items(): + param = spec.param(name) + # A parameter the spec no longer declares is kept as-is rather than + # dropped, so downgrading LibEMG and upgrading again does not lose it. + out[name] = param.coerce(value) if param is not None else value + return out + + +class ValidationError(Exception): + """Raised when a document cannot be run, with every reason found.""" + + def __init__(self, problems): + self.problems = list(problems) + super().__init__("\n".join(f"- {p}" for p in self.problems)) + + +class PipelineDocument: + """A pipeline, as a thing you can edit, save, load and check. + + Parameters + ---------- + registry: dict or None (optional), default=None + The block descriptions to validate against. Defaults to the generated + registry. + + Examples + --------- + >>> doc = PipelineDocument() + >>> source = doc.add_node('source.myo_streamer') + >>> window = doc.add_node('window.enframe') + >>> doc.connect(source, 'emg', window, 'input') + >>> doc.save('pipeline.json') + """ + + def __init__(self, registry=None): + self.registry = registry if registry is not None else default_registry() + self.nodes: Dict[str, Node] = {} + self.links: List[Link] = [] + self.probes: List[Probe] = [] + self.canvas: Dict[str, Any] = {} + self._counter = 0 + + # ------------------------------------------------------------------ + # building + # ------------------------------------------------------------------ + def add_node(self, spec_id, params=None, position=(0, 0), node_id=None): + """Place a block. + + Returns + ---------- + str + The new node's id. + """ + if spec_id not in self.registry: + raise KeyError(f"No such block: '{spec_id}'.") + spec = self.registry[spec_id] + node_id = node_id or self._next_id(spec_id) + if node_id in self.nodes: + raise ValueError(f"A node called '{node_id}' already exists.") + node = Node(id=node_id, spec_id=spec_id, position=tuple(position)) + node.params = _coerce(spec, params or {}) + self.nodes[node_id] = node + return node_id + + def _next_id(self, spec_id): + stem = spec_id.split(".")[-1] + while True: + self._counter += 1 + candidate = f"{stem}_{self._counter}" + if candidate not in self.nodes: + return candidate + + def remove_node(self, node_id): + """Remove a block, and anything attached to it.""" + self.nodes.pop(node_id, None) + self.links = [l for l in self.links + if l.from_node != node_id and l.to_node != node_id] + self.probes = [p for p in self.probes if p.node != node_id] + + def spec(self, node_id): + """The :class:`NodeSpec` for a node, or None if it is unresolved.""" + node = self.nodes[node_id] + return self.registry.get(node.spec_id) + + def set_param(self, node_id, name, value): + """Set one parameter, coerced to its declared kind.""" + node = self.nodes[node_id] + spec = self.registry.get(node.spec_id) + param = spec.param(name) if spec else None + node.params[name] = param.coerce(value) if param else value + + def connect(self, from_node, from_port, to_node, to_port): + """Link an output to an input. + + Raises + ---------- + ValueError + If the link is not one the pipeline could run: mismatched types, + an input that already has a link and does not accept several, a + duplicate, or a cycle. + """ + problem = self.why_not_connect(from_node, from_port, to_node, to_port) + if problem: + raise ValueError(problem) + self.links.append(Link(from_node, from_port, to_node, to_port)) + + def why_not_connect(self, from_node, from_port, to_node, to_port): + """Why a link would be refused, or None if it would be accepted. + + Separate from :meth:`connect` because the editor needs to explain a + refusal as the user drags, rather than raise at them. + """ + if from_node not in self.nodes or to_node not in self.nodes: + return "One end of that link is not on the canvas." + if from_node == to_node: + return "A block cannot feed itself." + source, target = self.registry.get(self.nodes[from_node].spec_id), \ + self.registry.get(self.nodes[to_node].spec_id) + if source is None or target is None: + return "One end of that link is a block this version does not recognise." + out_port = source.port(from_port, "output") + in_port = target.port(to_port, "input") + if out_port is None: + return f"'{source.title}' has no output called '{from_port}'." + if in_port is None: + return f"'{target.title}' has no input called '{to_port}'." + if not PortType.accepts(out_port.type, in_port.type): + return (f"'{out_port.label}' carries {out_port.type} and " + f"'{in_port.label}' expects {in_port.type}.") + if any(l.key() == (from_node, from_port, to_node, to_port) for l in self.links): + return "Those are already connected." + if not in_port.multiple and self.links_into(to_node, to_port): + return (f"'{in_port.label}' already has a connection, and two sources " + "into one input would interleave with no defined order.") + if self._would_cycle(from_node, to_node): + return "That would make a loop, and each stage would wait for the other." + return None + + def disconnect(self, from_node, from_port, to_node, to_port): + """Remove a link if it exists.""" + key = (from_node, from_port, to_node, to_port) + self.links = [l for l in self.links if l.key() != key] + + def links_into(self, node_id, port=None): + """Links arriving at a node, optionally at one port.""" + return [l for l in self.links + if l.to_node == node_id and (port is None or l.to_port == port)] + + def links_out_of(self, node_id, port=None): + """Links leaving a node, optionally from one port.""" + return [l for l in self.links + if l.from_node == node_id and (port is None or l.from_port == port)] + + def _would_cycle(self, from_node, to_node): + """Whether adding from_node -> to_node closes a loop.""" + seen, stack = set(), [from_node] + while stack: + current = stack.pop() + if current == to_node: + return True + if current in seen: + continue + seen.add(current) + stack.extend(l.from_node for l in self.links_into(current)) + return False + + # ------------------------------------------------------------------ + # probes + # ------------------------------------------------------------------ + def add_probe(self, node_id, port, hz=30.0): + """Watch an output port. Replaces any probe already on that port. + + Raises + ---------- + ValueError + If there is no such output, or if it is one that never becomes an + item of its own and so could not be watched. + """ + spec = self.registry.get(self.nodes[node_id].spec_id) + if spec is None or spec.port(port, "output") is None: + raise ValueError(f"'{node_id}' has no output called '{port}' to probe.") + if spec.category == WINDOW: + # A window is folded into the stage it feeds rather than becoming a + # stage of its own, so it publishes nothing to watch. Accepting the + # probe and quietly dropping it at compile time would leave an + # empty plot with no explanation, and would also let it count as a + # reader and mask a genuinely dangling branch. + raise ValueError( + f"A window cannot be probed. It is folded into the block it feeds " + f"rather than producing anything of its own, so there is nothing to " + f"watch. Probe the block before it to see the samples going in, or " + f"the block after it to see what comes out.") + self.remove_probe(node_id, port) + self.probes.append(Probe(node_id, port, hz)) + + def remove_probe(self, node_id, port): + self.probes = [p for p in self.probes if p.key() != (node_id, port)] + + def has_probe(self, node_id, port): + return any(p.key() == (node_id, port) for p in self.probes) + + def probe_render(self, probe): + """How a probe should be drawn, from the type of the port it watches.""" + spec = self.registry.get(self.nodes[probe.node].spec_id) + port = spec.port(probe.port, "output") if spec else None + return PortType.RENDER.get(port.type if port else None, "timeseries") + + # ------------------------------------------------------------------ + # classification and validation + # ------------------------------------------------------------------ + def sources(self): + """Node ids whose spec is a source.""" + return [n for n, node in self.nodes.items() + if (self.registry.get(node.spec_id) or NodeSpec("", "", "")).category == SOURCE] + + def mode(self): + """Whether this pipeline runs online, offline, or cannot be decided. + + Inferred rather than configured. A pipeline fed by devices is online, a + pipeline fed by recordings is offline, and one fed by both is neither. + + Returns + ---------- + str + ``'online'``, ``'offline'``, ``'empty'`` or ``'mixed'``. + """ + sources = self.sources() + if not sources: + return "empty" + offline = {s for s in sources if self.registry[self.nodes[s].spec_id].offline_only} + if not offline: + return "online" + if len(offline) == len(sources): + return "offline" + return "mixed" + + def validate(self): + """Every reason this pipeline could not run. + + Returns + ---------- + list + Human-readable problems. Empty means it is ready. + """ + problems = [] + unresolved = [n.id for n in self.nodes.values() if n.unresolved] + if unresolved: + problems.append( + f"These blocks are not recognised by this version of LibEMG: " + f"{', '.join(sorted(unresolved))}. They were kept so nothing is lost, " + "but the pipeline cannot run until they are removed.") + + mode = self.mode() + if mode == "empty": + problems.append("There is no source, so nothing would ever run.") + elif mode == "mixed": + live = [s for s in self.sources() + if not self.registry[self.nodes[s].spec_id].offline_only] + stored = [s for s in self.sources() if s not in live] + problems.append( + f"This mixes live sources ({', '.join(sorted(live))}) with stored data " + f"({', '.join(sorted(stored))}). A run is either one or the other.") + + for node_id, node in self.nodes.items(): + spec = self.registry.get(node.spec_id) + if spec is None: + continue + for port in spec.inputs: + if port.optional: + continue + if not self.links_into(node_id, port.name): + problems.append(f"'{spec.title}' ({node_id}) has nothing connected " + f"to its {port.label} input.") + if spec.category == MODEL: + # A model reads features or raw windows, never both and never + # neither. Its ports are optional individually so the generic + # rule above does not demand both, so the real requirement is + # stated here. + connected = [p.label for p in spec.inputs + if self.links_into(node_id, p.name)] + if not connected: + problems.append( + f"'{spec.title}' ({node_id}) has no input. Connect features " + "to it, or connect a window directly for a model that takes " + "raw windows.") + elif len(connected) > 1: + problems.append( + f"'{spec.title}' ({node_id}) has both {' and '.join(connected)} " + "connected. A model reads one or the other, not both.") + if mode == "offline" and spec.online_only: + problems.append(f"'{spec.title}' ({node_id}) only works on a live stream.") + if mode == "online" and spec.offline_only: + problems.append(f"'{spec.title}' ({node_id}) only works on stored data.") + + # A source feeding nothing, or a model whose output goes nowhere, is + # almost always a half-finished edit rather than an intention. A sink + # is the exception: its output is the result the user reads at the end + # of a run, so having nothing downstream of it is the normal case. + for node_id, node in self.nodes.items(): + spec = self.registry.get(node.spec_id) + if spec is None or not spec.outputs or spec.category == SINK: + continue + if not self.links_out_of(node_id) and not any( + p.node == node_id for p in self.probes): + problems.append(f"'{spec.title}' ({node_id}) produces something that " + "nothing reads and no probe watches.") + + # Something has to make the result visible. A model or a sink does, and + # so does a probe: watching a stage live is a legitimate pipeline on + # its own while exploring, and refusing it would mean a pipeline could + # not be built up a block at a time. + visible = any( + (self.registry.get(n.spec_id) or NodeSpec("", "", "")).category in (SINK, MODEL) + for n in self.nodes.values()) or bool(self.probes) + if mode in ("online", "offline") and not visible: + problems.append("There is no model, output or probe, so the pipeline " + "would compute nothing anybody could see.") + return problems + + def check(self): + """Validate, raising :class:`ValidationError` if anything is wrong.""" + problems = self.validate() + if problems: + raise ValidationError(problems) + return True + + def order(self): + """Node ids in an order where every node follows its inputs. + + Returns + ---------- + list + A topological order. Cycles cannot occur because + :meth:`connect` refuses them. + """ + remaining = dict(self.nodes) + resolved, out = set(), [] + while remaining: + ready = [n for n in remaining + if all(l.from_node in resolved for l in self.links_into(n))] + if not ready: + # Only reachable if links were built by hand around connect(). + out.extend(sorted(remaining)) + break + for node_id in sorted(ready): + out.append(node_id) + resolved.add(node_id) + remaining.pop(node_id) + return out + + # ------------------------------------------------------------------ + # persistence + # ------------------------------------------------------------------ + def to_dict(self): + return { + "schema_version": SCHEMA_VERSION, + "nodes": [n.to_dict() for n in self.nodes.values()], + "links": [l.to_dict() for l in self.links], + "probes": [p.to_dict() for p in self.probes], + "canvas": dict(self.canvas), + } + + def save(self, path): + """Write the pipeline to a file.""" + with open(path, "w", encoding="utf-8") as handle: + json.dump(self.to_dict(), handle, indent=2) + return path + + @classmethod + def from_dict(cls, raw, registry=None): + """Rebuild from saved data, preserving anything not recognised.""" + raw = migrate(raw) + document = cls(registry=registry) + for entry in raw.get("nodes", []): + node = Node.from_dict(entry, document.registry) + document.nodes[node.id] = node + known = set(document.nodes) + for entry in raw.get("links", []): + link = Link.from_dict(entry) + # A link to a node that is not in the file is meaningless, and + # keeping it would make the document lie about its own shape. + if link.from_node in known and link.to_node in known: + document.links.append(link) + for entry in raw.get("probes", []): + probe = Probe.from_dict(entry) + if probe.node in known: + document.probes.append(probe) + document.canvas = dict(raw.get("canvas", {})) + return document + + @classmethod + def load(cls, path, registry=None): + """Read a pipeline from a file.""" + with open(path, "r", encoding="utf-8") as handle: + return cls.from_dict(json.load(handle), registry=registry) + + +def migrate(raw): + """Bring saved data up to the current schema. + + One step per version, applied in order, so a file several versions old is + carried forward rather than rejected. + """ + version = int(raw.get("schema_version", 1)) + steps = {} + while version in steps: + raw = steps[version](raw) + version += 1 + raw["schema_version"] = version + # Stamped on the way out whatever happened, so a caller can always read the + # version back. Previously a file with no version, or one already current, + # was returned untouched and reading the key afterwards raised. + raw = dict(raw) + raw["schema_version"] = min(version, SCHEMA_VERSION) + return raw diff --git a/libemg/_gui/_pipeline/editor_panel.py b/libemg/_gui/_pipeline/editor_panel.py new file mode 100644 index 00000000..41dcc8f1 --- /dev/null +++ b/libemg/_gui/_pipeline/editor_panel.py @@ -0,0 +1,788 @@ +"""The node editor: a view onto a pipeline document. + +Everything this window does is edit a +:class:`~libemg._gui._pipeline.document.PipelineDocument` and then ask the +compiler to run it. It holds no pipeline state of its own, which is what lets +the same pipeline be built, saved, validated and run with no display at all. + +Two things the toolkit does for us +---------------------------------- +A node attribute carries a ``category``, and DearPyGui refuses to link two +attributes whose categories differ. Setting the category to the port's type +means a wrong connection cannot be made in the first place, rather than being +made and then explained away. The rules a category cannot express, such as an +input that already has a link, are checked in the link callback and reported in +words. + +A node also carries its position, and the position can be read back, so canvas +layout survives a save without the document having to track it separately. + +Where the work happens +---------------------- +Nothing in the running pipeline lives in this process. The stages run in their +own processes and publish to shared memory; this window reads probe items and +progress on a periodic callback from the render thread and draws them. That is +the only safe arrangement, because DearPyGui will not accept calls from another +process, and it is also why a probe cannot slow down the pipeline it watches. +""" + +import os +import time +import traceback + +import dearpygui.dearpygui as dpg +import numpy as np + +from libemg._gui._pipeline.compile import CompileError, compile_pipeline +from libemg._gui._pipeline.document import PipelineDocument +from libemg._gui._pipeline.registry import (BOOL, ENUM, FLOAT, FOLDER, INT, + PortType, + MULTI_SELECT, PATH, STR, SOURCE, + default_registry) + +WINDOW_TAG = "__pipeline_window" +OPEN_TAG = "__pipeline_open" +SAVEAS_TAG = "__pipeline_saveas" +EDITOR_TAG = "__pipeline_editor" +STATUS_TAG = "__pipeline_status" +PROGRESS_TAG = "__pipeline_progress" +PROBLEMS_TAG = "__pipeline_problems" +SCOPE_TAG = "__pipeline_scope" +RESULTS_TAG = "__pipeline_results" + + +class PipelineEditorPanel: + """A window for building, saving and running pipelines. + + Parameters + ---------- + registry: dict or None (optional), default=None + Block descriptions. Defaults to the generated registry. + width, height: int (optional) + Initial window size. + + Examples + --------- + >>> panel = PipelineEditorPanel() + >>> panel.spawn_window() + """ + + + #: The window this panel owns, so a caller can ask if it is open. + window_tag = WINDOW_TAG + + def __init__(self, registry=None, width=1280, height=760): + self.registry = registry if registry is not None else default_registry() + self.document = PipelineDocument(self.registry) + self.width, self.height = width, height + self.pipeline = None + self.path = None + self._link_ids = {} # dpg link id -> document link key + self._attr_ids = {} # (node, port, direction) -> dpg attribute id + self._probe_plots = {} # (node, port) -> drawing state + self._offline_thread = None + self._offline_progress = 0.0 + self._last_error = "" + self._training = False + self._trained = None + + # ================================================================== + # window + # ================================================================== + def spawn_window(self): + """Build the editor window.""" + self.cleanup() + with dpg.window(label="Pipeline Editor", tag=WINDOW_TAG, + width=self.width, height=self.height, + on_close=lambda: self.cleanup()): + self._build_toolbar() + dpg.add_separator() + with dpg.group(horizontal=True): + self._build_palette() + self._build_canvas() + dpg.add_separator() + dpg.add_text("", tag=PROBLEMS_TAG, wrap=self.width - 40) + self._refresh_status() + return self + + def cleanup(self): + """Stop anything running and remove the window. + + The file dialogs are listed separately because DearPyGui parents them + to the viewport rather than to the window that declared them. Deleting + the window leaves them behind, and the next time this panel is opened + it fails partway through building its toolbar, having already created a + window it will never finish. + """ + self.stop() + for tag in (SCOPE_TAG, RESULTS_TAG, OPEN_TAG, SAVEAS_TAG, WINDOW_TAG): + if dpg.does_alias_exist(tag): + dpg.delete_item(tag) + + # ------------------------------------------------------------------ + def _build_toolbar(self): + with dpg.group(horizontal=True): + dpg.add_button(label="New", callback=self._new) + dpg.add_button(label="Open", callback=lambda: dpg.show_item(OPEN_TAG)) + dpg.add_button(label="Save", callback=self._save) + dpg.add_button(label="Save As", callback=lambda: dpg.show_item(SAVEAS_TAG)) + dpg.add_spacer(width=20) + dpg.add_button(label="Start", tag="__pipeline_start", callback=self._start) + dpg.add_button(label="Stop", tag="__pipeline_stop", callback=self.stop) + dpg.add_button(label="Train", tag="__pipeline_train", callback=self._train) + with dpg.tooltip("__pipeline_train"): + dpg.add_text("Fit this pipeline's model on the stored data it " + "reads, and save it where the model block points. " + "Only for a stored-data pipeline.", wrap=320) + dpg.add_spacer(width=20) + # The bar is only meaningful for a run whose total is known, which + # is what a recording has and a live stream does not. + dpg.add_progress_bar(tag=PROGRESS_TAG, default_value=0.0, width=220, + show=False) + dpg.add_text("", tag=STATUS_TAG) + with dpg.file_dialog(tag=OPEN_TAG, show=False, directory_selector=False, + width=620, height=420, callback=self._open_selected): + dpg.add_file_extension(".json") + with dpg.file_dialog(tag=SAVEAS_TAG, show=False, directory_selector=False, + width=620, height=420, callback=self._saveas_selected): + dpg.add_file_extension(".json") + + def _build_palette(self): + with dpg.child_window(width=210, autosize_y=True): + dpg.add_text("Blocks") + dpg.add_separator() + grouped = {} + for spec in self.registry.values(): + grouped.setdefault(spec.category, []).append(spec) + for category in ("source", "transform", "window", "features", "model", "sink"): + specs = sorted(grouped.get(category, []), key=lambda s: s.title) + if not specs: + continue + with dpg.tree_node(label=category.title(), default_open=category != "source"): + for spec in specs: + dpg.add_button(label=spec.title, width=-1, + user_data=spec.id, callback=self._add_from_palette) + if spec.help: + with dpg.tooltip(dpg.last_item()): + dpg.add_text(spec.help, wrap=320) + + def _build_canvas(self): + with dpg.child_window(autosize_x=True, autosize_y=True): + with dpg.node_editor(tag=EDITOR_TAG, callback=self._link_requested, + delink_callback=self._unlink_requested, + minimap=True, + minimap_location=dpg.mvNodeMiniMap_Location_BottomRight): + pass + + # ================================================================== + # editing + # ================================================================== + def _add_from_palette(self, sender, app_data, user_data): + node_id = self.document.add_node(user_data, position=self._free_position()) + self._draw_node(node_id) + self._refresh_status() + + def _free_position(self): + """Somewhere that does not sit exactly on top of an existing node.""" + count = len(self.document.nodes) + return (40 + 190 * (count % 6), 40 + 150 * (count // 6)) + + def _draw_node(self, node_id): + node = self.document.nodes[node_id] + spec = self.registry.get(node.spec_id) + title = spec.title if spec else f"? {node.spec_id}" + with dpg.node(label=f"{title} [{node_id}]", parent=EDITOR_TAG, + tag=f"__node_{node_id}", pos=node.position): + if spec is None: + # An unresolved block is drawn, and says so, rather than being + # dropped. Losing a user's work to a version difference is + # worse than showing them something they have to deal with. + with dpg.node_attribute(attribute_type=dpg.mvNode_Attr_Static): + dpg.add_text("Not recognised by this LibEMG.", color=(230, 140, 100)) + dpg.add_text(f"{node.params}", wrap=220) + return + + for port in spec.inputs: + attribute = dpg.add_node_attribute( + parent=f"__node_{node_id}", label=port.label, + attribute_type=dpg.mvNode_Attr_Input, + category=PortType.category_of(port.type)) + self._attr_ids[(node_id, port.name, "input")] = attribute + dpg.add_text(port.label, parent=attribute) + + for port in spec.outputs: + attribute = dpg.add_node_attribute( + parent=f"__node_{node_id}", label=port.label, + attribute_type=dpg.mvNode_Attr_Output, + category=PortType.category_of(port.type)) + self._attr_ids[(node_id, port.name, "output")] = attribute + with dpg.group(horizontal=True, parent=attribute): + # A probe is a toggle on the port, not a block to place. + # Making it a block would clutter the canvas and make the + # user wire something they only want to look at. + dpg.add_checkbox(label="", default_value=False, + tag=f"__probe_{node_id}_{port.name}", + user_data=(node_id, port.name), + callback=self._toggle_probe) + dpg.add_text(f"{port.label} >") + with dpg.tooltip(dpg.last_container()): + dpg.add_text(f"Carries {port.type}. Tick to watch it live.") + + with dpg.node_attribute(parent=f"__node_{node_id}", + attribute_type=dpg.mvNode_Attr_Static): + self._draw_params(node_id, spec) + with dpg.node_attribute(parent=f"__node_{node_id}", + attribute_type=dpg.mvNode_Attr_Static): + dpg.add_button(label="Remove", width=-1, user_data=node_id, + callback=self._remove_node) + + def _draw_params(self, node_id, spec): + plain = [p for p in spec.params if not p.advanced] + advanced = [p for p in spec.params if p.advanced] + for param in plain: + self._draw_param(node_id, param) + if advanced: + with dpg.tree_node(label="Advanced", default_open=False): + for param in advanced: + self._draw_param(node_id, param) + + def _draw_param(self, node_id, param): + """One widget, chosen by the parameter's kind. + + This mapping is the whole reason a parameter declares a kind rather + than a widget: the rule for what a value should be edited with lives + here once, instead of being restated on every block. + """ + value = self.document.nodes[node_id].params.get(param.name, param.default) + tag = f"__param_{node_id}_{param.name}" + data = (node_id, param.name) + common = dict(tag=tag, user_data=data, callback=self._param_changed, + label=param.label, width=150) + if param.kind == ENUM: + dpg.add_combo(list(param.choices), default_value=value or param.default, + **common) + elif param.kind == BOOL: + dpg.add_checkbox(default_value=bool(value), tag=tag, user_data=data, + callback=self._param_changed, label=param.label) + elif param.kind == INT: + dpg.add_input_int(default_value=int(value or 0), step=1, **common) + elif param.kind == FLOAT: + dpg.add_input_float(default_value=float(value or 0.0), step=0.0, + format="%.4f", **common) + elif param.kind == MULTI_SELECT: + dpg.add_text(param.label) + dpg.add_listbox(list(param.choices), num_items=6, + default_value=(value or [None])[0] if value else "", + tag=tag, user_data=data, width=150, + callback=self._multi_toggled) + dpg.add_text(_summarise(value), tag=f"{tag}__summary", wrap=200, + color=(150, 190, 210)) + elif param.kind in (PATH, FOLDER): + dpg.add_input_text(default_value=str(value or ""), **common) + dpg.add_button(label=f"Browse {param.label}", width=-1, + user_data=(node_id, param.name, param.kind), + callback=self._browse) + else: + multiline = param.name == "regex_filters" + dpg.add_input_text(default_value=str(value or ""), multiline=multiline, + height=70 if multiline else 0, **common) + if param.help: + with dpg.tooltip(tag): + dpg.add_text(param.help, wrap=320) + + def _param_changed(self, sender, app_data, user_data): + node_id, name = user_data + self.document.set_param(node_id, name, app_data) + self._refresh_status() + + def _multi_toggled(self, sender, app_data, user_data): + """A list box selection toggles membership rather than replacing it. + + A features block usually wants several features, and a single-selection + list would make choosing eight of them impossible. + """ + node_id, name = user_data + current = list(self.document.nodes[node_id].params.get(name) or []) + if app_data in current: + current.remove(app_data) + else: + current.append(app_data) + self.document.set_param(node_id, name, current) + summary = f"__param_{node_id}_{name}__summary" + if dpg.does_item_exist(summary): + dpg.set_value(summary, _summarise(current)) + self._refresh_status() + + def _browse(self, sender, app_data, user_data): + node_id, name, kind = user_data + tag = f"__browse_{node_id}_{name}" + if dpg.does_alias_exist(tag): + dpg.delete_item(tag) + with dpg.file_dialog(tag=tag, directory_selector=(kind == FOLDER), + width=620, height=420, modal=True, + user_data=(node_id, name), callback=self._browsed): + dpg.add_file_extension(".*") + + def _browsed(self, sender, app_data, user_data): + node_id, name = user_data + chosen = app_data.get("file_path_name") or app_data.get("current_path") or "" + self.document.set_param(node_id, name, chosen) + widget = f"__param_{node_id}_{name}" + if dpg.does_item_exist(widget): + dpg.set_value(widget, chosen) + self._refresh_status() + + def _remove_node(self, sender, app_data, user_data): + node_id = user_data + for link_id, key in list(self._link_ids.items()): + if key[0] == node_id or key[2] == node_id: + self._link_ids.pop(link_id, None) + self.document.remove_node(node_id) + if dpg.does_alias_exist(f"__node_{node_id}"): + dpg.delete_item(f"__node_{node_id}") + self._refresh_status() + + # ------------------------------------------------------------------ + # linking + # ------------------------------------------------------------------ + def _link_requested(self, sender, app_data): + """The user dragged a connection. Accept it only if it could run.""" + from_attr, to_attr = app_data + source = self._port_of(from_attr, "output") + target = self._port_of(to_attr, "input") + if source is None or target is None: + return + problem = self.document.why_not_connect(source[0], source[1], + target[0], target[1]) + if problem: + # Types are already blocked by the toolkit, so anything reaching + # here is a rule the toolkit cannot express. Saying why beats + # letting the link silently fail to appear. + self._say(problem) + return + self.document.connect(source[0], source[1], target[0], target[1]) + link_id = dpg.add_node_link(from_attr, to_attr, parent=EDITOR_TAG) + self._link_ids[link_id] = (source[0], source[1], target[0], target[1]) + self._refresh_status() + + def _unlink_requested(self, sender, app_data): + key = self._link_ids.pop(app_data, None) + if key is not None: + self.document.disconnect(*key) + dpg.delete_item(app_data) + self._refresh_status() + + def _port_of(self, attribute, direction): + for (node_id, port, side), identifier in self._attr_ids.items(): + if identifier == attribute and side == direction: + return node_id, port + return None + + def _toggle_probe(self, sender, app_data, user_data): + node_id, port = user_data + if app_data: + self.document.add_probe(node_id, port) + else: + self.document.remove_probe(node_id, port) + self._refresh_status() + + # ================================================================== + # files + # ================================================================== + def _new(self): + self.stop() + self.document = PipelineDocument(self.registry) + self.path = None + self._rebuild_canvas() + + def _save(self): + if self.path is None: + dpg.show_item(SAVEAS_TAG) + return + self._capture_positions() + self.document.save(self.path) + self._say(f"Saved to {self.path}") + + def _saveas_selected(self, sender, app_data): + path = app_data.get("file_path_name") or "" + if not path: + return + if not path.lower().endswith(".json"): + path += ".json" + self.path = path + self._save() + + def _open_selected(self, sender, app_data): + path = app_data.get("file_path_name") or "" + if not path or not os.path.exists(path): + return + try: + self.document = PipelineDocument.load(path, registry=self.registry) + except Exception as error: + self._say(f"Could not open that file: {error}") + return + self.path = path + self._rebuild_canvas() + + def _capture_positions(self): + """Read canvas positions back before saving, so layout survives.""" + for node_id in self.document.nodes: + tag = f"__node_{node_id}" + if dpg.does_item_exist(tag): + self.document.nodes[node_id].position = tuple(dpg.get_item_pos(tag)) + + def _rebuild_canvas(self): + """Redraw every node and link from the document.""" + if dpg.does_item_exist(EDITOR_TAG): + dpg.delete_item(EDITOR_TAG, children_only=True) + self._attr_ids.clear() + self._link_ids.clear() + for node_id in self.document.nodes: + self._draw_node(node_id) + for link in self.document.links: + source = self._attr_ids.get((link.from_node, link.from_port, "output")) + target = self._attr_ids.get((link.to_node, link.to_port, "input")) + if source is None or target is None: + continue + link_id = dpg.add_node_link(source, target, parent=EDITOR_TAG) + self._link_ids[link_id] = link.key() + for probe in self.document.probes: + tag = f"__probe_{probe.node}_{probe.port}" + if dpg.does_item_exist(tag): + dpg.set_value(tag, True) + self._refresh_status() + + # ================================================================== + # running + # ================================================================== + def _start(self): + if self.pipeline is not None: + self._say("Already running. Stop it first.") + return + self._capture_positions() + try: + self.pipeline = compile_pipeline(self.document) + except CompileError as error: + self._say(str(error)) + return + except Exception: + self._say("Could not build the pipeline:\n" + + traceback.format_exc().strip().splitlines()[-1]) + return + + if self.document.mode() == "offline": + self._start_offline() + else: + self._start_online() + + def _start_online(self): + try: + self.pipeline.start() + except Exception: + self._say("Could not start:\n" + + traceback.format_exc().strip().splitlines()[-1]) + self.pipeline = None + return + dpg.configure_item(PROGRESS_TAG, show=False) + self._open_scope() + self._say("Running.") + + def _start_offline(self): + import threading + dpg.configure_item(PROGRESS_TAG, show=True) + dpg.set_value(PROGRESS_TAG, 0.0) + self._offline_progress = 0.0 + + def work(): + try: + self.pipeline.run(on_progress=self._record_progress) + except Exception: + self._last_error = traceback.format_exc().strip().splitlines()[-1] + + # A thread, not a process: the run has to hand its results back, and + # the only thing it shares with the render thread is a float and a dict + # that the periodic callback reads. + self._offline_thread = threading.Thread(target=work, daemon=True, + name="libemg-offline-pipeline") + self._offline_thread.start() + self._say("Running over stored data.") + + def _record_progress(self, fraction): + self._offline_progress = float(fraction) + + def stop(self): + """Stop whatever is running.""" + if self.pipeline is None: + return + try: + if hasattr(self.pipeline, "request_stop"): + self.pipeline.request_stop() + if hasattr(self.pipeline, "stop"): + self.pipeline.stop() + except Exception: + pass + self.pipeline = None + self._offline_thread = None + if dpg.does_item_exist(PROGRESS_TAG): + dpg.configure_item(PROGRESS_TAG, show=False) + self._say("Stopped.") + + def _train(self): + """Fit the model this pipeline names, on the data it reads. + + The step that used to need a script. A pipeline built for stored data + already describes everything a fit needs: where the recordings are, how + they are filtered and windowed, which features to take and which labels + to learn. Training reuses that description rather than asking for it + again, so the model is fitted on exactly what it will be scored on. + """ + if self.pipeline is not None: + self._say("Stop the running pipeline before training.") + return + if self.document.mode() != "offline": + self._say("Training reads stored data. Add a Stored Data source, or " + "open the pipeline you collected your training data with.") + return + self._capture_positions() + try: + pipeline = compile_pipeline(self.document) + except CompileError as error: + self._say(str(error)) + return + + import threading + dpg.configure_item(PROGRESS_TAG, show=True) + dpg.set_value(PROGRESS_TAG, 0.0) + self._offline_progress = 0.0 + self._trained = None + self.pipeline = pipeline + + def work(): + try: + self._trained = pipeline.train(on_progress=self._record_progress) + except Exception as error: + self._last_error = f"{type(error).__name__}: {error}" + + self._offline_thread = threading.Thread(target=work, daemon=True, + name="libemg-pipeline-train") + self._offline_thread.start() + self._training = True + self._say("Training on the stored data.") + + # ================================================================== + # per-frame work, called from the render thread + # ================================================================== + def poll(self): + """Refresh anything driven by the running pipeline. + + Called once per rendered frame by the GUI's loop. This is the only + place the editor reads what the pipeline produced, and it runs on the + render thread, because DearPyGui items must not be touched from + anywhere else. + """ + if self.pipeline is None: + return + if hasattr(self.pipeline, "plan"): + self._poll_offline() + else: + self._poll_online() + + def _poll_offline(self): + if dpg.does_item_exist(PROGRESS_TAG): + dpg.set_value(PROGRESS_TAG, self._offline_progress) + thread = self._offline_thread + if thread is not None and not thread.is_alive(): + self._offline_thread = None + training = getattr(self, "_training", False) + self._training = False + if self._last_error: + self._say(("Training failed: " if training else "The run failed: ") + + self._last_error) + self._last_error = "" + elif training: + trained = getattr(self, "_trained", None) or {} + classes = trained.get("classes") + self._say( + "Trained on {windows} windows of {features} features" + .format(**{"windows": trained.get("windows", 0), + "features": trained.get("features", 0)}) + + (f" over classes {classes}" if classes else "") + + f". Saved to {trained.get('model_path', '?')}. " + "Point a live pipeline at that file to run it.") + else: + self._show_results(self.pipeline.results) + self._say("Finished." if not self.pipeline.stopped + else f"Stopped after {self.pipeline.progress:.0%}.") + self.pipeline = None + + def _poll_online(self): + try: + status = self.pipeline.status() + except Exception: + return + parts = [] + for tag in sorted(status): + if tag.startswith("probe_"): + continue + parts.append(f"{tag.replace('pipe_', '')}={status[tag].total_samples}") + if dpg.does_item_exist(STATUS_TAG): + dpg.set_value(STATUS_TAG, " ".join(parts[:6])) + self._draw_probes() + + # ------------------------------------------------------------------ + # probes + # ------------------------------------------------------------------ + def _open_scope(self): + if dpg.does_alias_exist(SCOPE_TAG): + dpg.delete_item(SCOPE_TAG) + if not self.pipeline.probe_items: + return + self._probe_plots.clear() + with dpg.window(label="Scope", tag=SCOPE_TAG, width=620, + height=180 + 200 * len(self.pipeline.probe_items), + pos=(self.width - 640, 60)): + from libemg.shared_memory_manager import SharedMemoryManager + self._probe_reader = SharedMemoryManager() + declared = {item[0]: item for item in self.pipeline.shared_memory_items} + for (node_id, port), info in self.pipeline.probe_items.items(): + item = declared.get(info["tag"]) + if item is None or not self._probe_reader.find_variable(*item): + continue + self._build_probe_plot(node_id, port, info) + + def _build_probe_plot(self, node_id, port, info): + """One plot, shaped by what the port carries. + + The render mode comes from the port's type rather than from a setting, + which is what keeps probing to a single click. + """ + render, width = info["render"], info["width"] + base = f"__scope_{node_id}_{port}" + dpg.add_text(f"{node_id}.{port} ({render})") + with dpg.plot(height=170, width=-1, no_menus=True, tag=f"{base}_plot"): + dpg.add_plot_legend() + x_axis = dpg.add_plot_axis(dpg.mvXAxis, label="", tag=f"{base}_x") + y_axis = dpg.add_plot_axis(dpg.mvYAxis, label="", tag=f"{base}_y") + series = [] + if render in ("timeseries", "window_overlay"): + for channel in range(width): + series.append(dpg.add_line_series( + [], [], label=f"ch{channel + 1}", parent=y_axis, + tag=f"{base}_s{channel}")) + else: + series.append(dpg.add_bar_series([], [], label=port, parent=y_axis, + tag=f"{base}_bar")) + self._probe_plots[(node_id, port)] = { + "info": info, "render": render, "x": x_axis, "y": y_axis, "base": base} + + def _draw_probes(self): + reader = getattr(self, "_probe_reader", None) + if reader is None: + return + for (node_id, port), state in self._probe_plots.items(): + info = state["info"] + try: + block, _ = reader.read_window(info["tag"], info["rows"]) + except Exception: + continue + if block.size == 0: + continue + base, render = state["base"], state["render"] + if render in ("timeseries", "window_overlay"): + x = list(range(block.shape[0])) + for channel in range(min(info["width"], block.shape[1])): + tag = f"{base}_s{channel}" + if dpg.does_item_exist(tag): + dpg.set_value(tag, [x, block[:, channel].tolist()]) + else: + row = block[-1] + tag = f"{base}_bar" + if dpg.does_item_exist(tag): + dpg.set_value(tag, [list(range(len(row))), row.tolist()]) + dpg.fit_axis_data(state["x"]) + dpg.fit_axis_data(state["y"]) + + # ------------------------------------------------------------------ + # results + # ------------------------------------------------------------------ + def _show_results(self, results): + if dpg.does_alias_exist(RESULTS_TAG): + dpg.delete_item(RESULTS_TAG) + if not results: + self._say("The run finished, but produced no metrics. " + "Connect an Offline Metrics block to score it.") + return + with dpg.window(label="Results", tag=RESULTS_TAG, width=520, height=440, + pos=(self.width - 560, 80)): + scalars = {k: v for k, v in results.items() if np.ndim(v) == 0} + matrices = {k: v for k, v in results.items() if np.ndim(v) >= 2} + vectors = {k: v for k, v in results.items() if np.ndim(v) == 1} + if scalars or vectors: + with dpg.table(header_row=True, borders_innerH=True, + borders_outerH=True, borders_innerV=True): + dpg.add_table_column(label="Metric") + dpg.add_table_column(label="Value") + for name, value in sorted(scalars.items()): + with dpg.table_row(): + dpg.add_text(name) + dpg.add_text(f"{float(value):.4f}") + for name, value in sorted(vectors.items()): + with dpg.table_row(): + dpg.add_text(name) + dpg.add_text(", ".join(f"{v:.3f}" for v in np.ravel(value))) + for name, matrix in sorted(matrices.items()): + dpg.add_separator() + dpg.add_text(name) + matrix = np.asarray(matrix, dtype=float) + with dpg.plot(height=260, width=-1, no_menus=True): + dpg.add_plot_axis(dpg.mvXAxis, label="Predicted") + with dpg.plot_axis(dpg.mvYAxis, label="True"): + dpg.add_heat_series(matrix.ravel().tolist(), + matrix.shape[0], matrix.shape[1], + scale_min=float(matrix.min()), + scale_max=float(matrix.max())) + dpg.add_separator() + dpg.add_button(label="Copy as CSV", callback=lambda: dpg.set_clipboard_text( + _as_csv(results))) + + # ================================================================== + # status + # ================================================================== + def _refresh_status(self): + if not dpg.does_item_exist(PROBLEMS_TAG): + return + mode = self.document.mode() + problems = self.document.validate() + if problems: + dpg.set_value(PROBLEMS_TAG, + f"[{mode}] not ready:\n" + "\n".join(f" - {p}" for p in problems)) + dpg.configure_item(PROBLEMS_TAG, color=(230, 170, 110)) + else: + dpg.set_value(PROBLEMS_TAG, f"[{mode}] ready to run.") + dpg.configure_item(PROBLEMS_TAG, color=(140, 210, 160)) + + def _say(self, message): + if dpg.does_item_exist(PROBLEMS_TAG): + dpg.set_value(PROBLEMS_TAG, message) + dpg.configure_item(PROBLEMS_TAG, color=(220, 220, 220)) + + +def _summarise(values): + """A short rendering of a multi-selection, for the label under the list.""" + values = list(values or []) + if not values: + return "none selected" + if len(values) <= 6: + return ", ".join(values) + return f"{', '.join(values[:6])} and {len(values) - 6} more" + + +def _as_csv(results): + lines = ["metric,value"] + for name, value in sorted(results.items()): + array = np.asarray(value) + if array.ndim == 0: + lines.append(f"{name},{float(array):.6f}") + else: + lines.append(f"{name},\"{';'.join(str(v) for v in array.ravel())}\"") + return "\n".join(lines) diff --git a/libemg/_gui/_pipeline/registry.py b/libemg/_gui/_pipeline/registry.py new file mode 100644 index 00000000..6ec721ec --- /dev/null +++ b/libemg/_gui/_pipeline/registry.py @@ -0,0 +1,618 @@ +"""What blocks a pipeline can be built from. + +The registry is a description, not an implementation. Each :class:`NodeSpec` +says what a block is called, what it consumes, what it produces, and what can +be configured on it. Nothing here imports DearPyGui or constructs anything that +runs; the editor reads this to decide what to draw, and the compiler reads it to +decide what to build. + +Generated, not hand-listed +-------------------------- +Wherever LibEMG already knows the answer, the registry asks it. Feature names +come from the feature extractor, metric names from the offline metrics, model +names from the predictors' own model tables, and each streamer's parameters +from its signature. A hand-maintained copy of those lists would be wrong the +first time somebody added a feature, and wrong silently. + +That is also why a parameter carries its *kind* rather than a widget name. The +kind is what decides whether the editor draws a dropdown, a number field, a +checkbox, a file picker or a multiple-selection list, so the rule for "a +dropdown where the choices are known, a text field where a continuous value is +viable" is written once here instead of once per block. +""" + +import inspect +from dataclasses import dataclass, field +from typing import Any, Callable, Optional, Sequence + + +# ---------------------------------------------------------------------- +# Port types +# +# These become the `category` on a DearPyGui node attribute, and DearPyGui +# refuses to link two attributes whose categories differ. Typing is therefore +# enforced as the user drags, by the toolkit, rather than by validation that +# runs afterwards and has to explain itself. +# ---------------------------------------------------------------------- +class PortType: + """The kinds of thing that can flow along a link.""" + + SAMPLES = "samples" # an N-by-C continuous stream + WINDOWS = "windows" # enframed windows + FEATURES = "features" # an N-by-F feature matrix + PREDICTION = "prediction" # class index, probabilities, velocity + CONTINUOUS = "continuous" # a regression output vector + LABELS = "labels" # ground truth, offline only + METRICS = "metrics" # a summary result table + + ALL = (SAMPLES, WINDOWS, FEATURES, PREDICTION, CONTINUOUS, LABELS, METRICS) + + #: The category DearPyGui enforces during a drag. Usually the type itself. + #: A classifier's decision and a regressor's vector share one, because a + #: sink treats them the same: it sends, writes or scores whatever the model + #: produced. Keeping them as separate *types* still matters, because a + #: probe draws class probabilities as bars and a velocity vector as a + #: trace, and that choice comes from the type. + CATEGORY = {PREDICTION: "decision", CONTINUOUS: "decision"} + + @classmethod + def category_of(cls, port_type): + """The drag category a port type belongs to.""" + return cls.CATEGORY.get(port_type, port_type) + + @classmethod + def accepts(cls, source_type, sink_type): + """Whether something of one type may flow into a port of another.""" + return cls.category_of(source_type) == cls.category_of(sink_type) + + #: How a probe should draw each type. Deriving this from the port rather + #: than asking the user is what keeps probing to a single click. + RENDER = { + SAMPLES: "timeseries", + CONTINUOUS: "timeseries", + WINDOWS: "window_overlay", + FEATURES: "bars", + PREDICTION: "probabilities", + LABELS: "timeseries", + METRICS: "table", + } + + +# Parameter kinds. The editor maps each to one widget. +ENUM = "enum" +INT = "int" +FLOAT = "float" +BOOL = "bool" +STR = "str" +PATH = "path" +FOLDER = "folder" +MULTI_SELECT = "multi_select" + + +@dataclass(frozen=True) +class ParamSpec: + """One configurable value on a block. + + Attributes + ---------- + name: str + Key this is stored under in a node's params. + kind: str + One of the module-level kinds. Decides the widget. + label: str + What the editor shows. Defaults to a prettified ``name``. + default: Any + Starting value. + choices: sequence or None + Allowed values, for :data:`ENUM` and :data:`MULTI_SELECT`. + minimum, maximum: float or None + Bounds, for :data:`INT` and :data:`FLOAT`. + help: str + One line explaining the parameter, shown as a tooltip. + advanced: bool + Hidden behind a disclosure in the editor. For parameters that have a + sensible default and rarely need touching. + """ + + name: str + kind: str + label: str = "" + default: Any = None + choices: Optional[Sequence[Any]] = None + minimum: Optional[float] = None + maximum: Optional[float] = None + help: str = "" + advanced: bool = False + + def __post_init__(self): + if self.kind in (ENUM, MULTI_SELECT) and not self.choices: + raise ValueError(f"Parameter '{self.name}' is a {self.kind} with no choices.") + if not self.label: + object.__setattr__(self, "label", self.name.replace("_", " ").strip().title()) + + def coerce(self, value): + """Bring ``value`` into this parameter's type and bounds. + + The editor's widgets return strings often enough, and a loaded file can + carry anything, so a spec is the one place that knows what a valid + value looks like. + """ + if value is None: + return self.default + try: + if self.kind == INT: + value = int(value) + elif self.kind == FLOAT: + value = float(value) + elif self.kind == BOOL: + value = bool(value) if not isinstance(value, str) \ + else value.strip().lower() in ("1", "true", "yes", "on") + elif self.kind in (STR, PATH, FOLDER): + value = str(value) + elif self.kind == MULTI_SELECT: + value = [v for v in value if self.choices is None or v in self.choices] + except (TypeError, ValueError): + return self.default + if self.kind in (INT, FLOAT): + if self.minimum is not None: + value = max(value, type(value)(self.minimum)) + if self.maximum is not None: + value = min(value, type(value)(self.maximum)) + if self.kind == ENUM and self.choices and value not in self.choices: + return self.default + return value + + +@dataclass(frozen=True) +class PortSpec: + """One input or output on a block. + + Attributes + ---------- + name: str + Identifies the port within its node. + type: str + A :class:`PortType`. Two ports link only if these match. + label: str + What the editor shows. + multiple: bool + Whether more than one link may attach. Outputs default to allowing + many, since fanning one stage out to several consumers is the point; + inputs default to one, because two writers into one input would + interleave with no defined order. + optional: bool + Whether the pipeline is still valid with this input unconnected. + """ + + name: str + type: str + label: str = "" + multiple: bool = False + optional: bool = False + + def __post_init__(self): + if self.type not in PortType.ALL: + raise ValueError(f"Port '{self.name}' has unknown type '{self.type}'.") + if not self.label: + object.__setattr__(self, "label", self.name.replace("_", " ").strip().title()) + + +# Node categories, which drive grouping in the editor's add menu and the +# online-or-offline classification in the compiler. +SOURCE = "source" +TRANSFORM = "transform" +WINDOW = "window" +FEATURES = "features" +MODEL = "model" +SINK = "sink" + + +@dataclass(frozen=True) +class NodeSpec: + """A block that can be placed on the canvas. + + Attributes + ---------- + id: str + Stable identifier, stored in saved files. Renaming one breaks old + files, which is what the unresolved-node handling in + :mod:`~libemg._gui._pipeline.document` exists to survive. + category: str + One of the module-level categories. + title: str + Shown on the node's title bar. + inputs, outputs: sequence of PortSpec + What it consumes and produces. + params: sequence of ParamSpec + What can be configured. + help: str + A sentence describing what the block does. + offline_only, online_only: bool + Whether the block only makes sense in one mode. The compiler uses this + to explain a pipeline that mixes the two. + """ + + id: str + category: str + title: str + inputs: Sequence[PortSpec] = field(default_factory=tuple) + outputs: Sequence[PortSpec] = field(default_factory=tuple) + params: Sequence[ParamSpec] = field(default_factory=tuple) + help: str = "" + offline_only: bool = False + online_only: bool = False + + def param(self, name): + """The named :class:`ParamSpec`, or None.""" + for spec in self.params: + if spec.name == name: + return spec + return None + + def port(self, name, direction="input"): + """The named :class:`PortSpec` on the given side, or None.""" + for spec in (self.inputs if direction == "input" else self.outputs): + if spec.name == name: + return spec + return None + + def defaults(self): + """A fresh params dict for a newly placed node.""" + return {spec.name: spec.default for spec in self.params} + + +# ---------------------------------------------------------------------- +# Generation helpers +# ---------------------------------------------------------------------- +_SKIP_STREAMER_ARGS = {"self", "shared_memory_items", "args", "kwargs"} + + +def _streamer_functions(): + """The public streamer entry points, with their signatures. + + Anything private or clearly not a device entry point is left out. A new + streamer added to the module appears here without the registry changing. + """ + from libemg import streamers + + found = {} + for name, function in inspect.getmembers(streamers, inspect.isfunction): + if name.startswith("_"): + continue + if function.__module__ != streamers.__name__: + continue + try: + parameters = inspect.signature(function).parameters + except (TypeError, ValueError): + continue + # Accepting shared_memory_items is the contract a pipeline source has + # to meet, because that is how its samples reach the rest of the graph. + # Testing for it rather than keeping an exclusion list is what keeps a + # streamer that talks over a socket, such as the UDP mock, from being + # offered as a block that could never be connected to anything. + if "shared_memory_items" not in parameters: + continue + found[name] = function + return found + + +def _params_from_signature(function, prefix=""): + """Turn a function's keyword arguments into parameter specs. + + The same trick the data-collection panel already uses to build its + arguments, applied to a whole signature so a device's options appear on its + node without anybody listing them here. + """ + specs = [] + try: + signature = inspect.signature(function) + except (TypeError, ValueError): + return specs + for name, parameter in signature.parameters.items(): + if name in _SKIP_STREAMER_ARGS: + continue + if parameter.kind in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD): + continue + default = None if parameter.default is inspect.Parameter.empty else parameter.default + if isinstance(default, bool): + kind = BOOL + elif isinstance(default, int): + kind = INT + elif isinstance(default, float): + kind = FLOAT + elif isinstance(default, (list, tuple)): + # A sequence default is almost always a pair of cutoffs or a + # channel list, which reads and edits far better as text than as a + # row of spinners. + kind, default = STR, ",".join(str(v) for v in default) + else: + kind, default = STR, "" if default is None else str(default) + specs.append(ParamSpec(name=prefix + name, kind=kind, default=default, + advanced=True, + help=f"Passed through to {function.__name__}.")) + return specs + + +def _feature_choices(): + from libemg.feature_extractor import FeatureExtractor + extractor = FeatureExtractor() + return list(extractor.get_feature_list()), list(extractor.get_feature_groups().keys()) + + +def _metric_choices(): + from libemg.offline_metrics import OfflineMetrics + return list(OfflineMetrics().get_available_metrics()) + + +def _model_choices(): + from libemg.emg_predictor import CLASSIFIER_MODELS, REGRESSOR_MODELS + return list(CLASSIFIER_MODELS), list(REGRESSOR_MODELS) + + +#: Filter names understood by Filter.install_filters. Taken from the branches +#: it dispatches on, which is the closest thing that module has to a list. +FILTER_NAMES = ("bandpass", "lowpass", "highpass", "bandstop", "notch", "standardize") + + +def build_registry(): + """Describe every block that can be placed, generated where possible. + + Returns + ---------- + dict + Mapping from spec id to :class:`NodeSpec`. + """ + features, groups = _feature_choices() + metrics = _metric_choices() + classifiers, regressors = _model_choices() + specs = {} + + def add(spec): + specs[spec.id] = spec + + # ---------------- sources ---------------- + for name, function in _streamer_functions().items(): + add(NodeSpec( + id=f"source.{name}", + category=SOURCE, + title=name.replace("_", " ").title(), + outputs=[PortSpec("emg", PortType.SAMPLES, "EMG", multiple=True)], + params=_params_from_signature(function), + help=(function.__doc__ or "").strip().split("\n")[0][:160], + online_only=True, + )) + + add(NodeSpec( + id="source.synthetic_streamer", + category=SOURCE, + title="Synthetic Source", + outputs=[PortSpec("emg", PortType.SAMPLES, "EMG", multiple=True)], + params=[ + ParamSpec("sampling_rate", INT, default=1000, minimum=1, + label="Sampling Rate", help="Hz."), + ParamSpec("num_channels", INT, default=8, minimum=1, maximum=256, + label="Channels"), + ParamSpec("pattern", ENUM, choices=["noise", "sine", "bursts"], + default="bursts", + help="Bursts alternate quiet and active, so a classifier " + "has something to separate."), + ParamSpec("amplitude", FLOAT, default=1.0, minimum=0.0, advanced=True), + ], + help="Produces samples without a device, for building and testing a " + "pipeline before any hardware is attached.", + online_only=True, + )) + + add(NodeSpec( + id="source.offline", + category=SOURCE, + title="Stored Data", + outputs=[PortSpec("emg", PortType.SAMPLES, "EMG", multiple=True), + PortSpec("labels", PortType.LABELS, "Labels", multiple=True)], + params=[ + ParamSpec("folder", FOLDER, default="", help="Folder to search for recordings."), + ParamSpec("regex_filters", STR, default="", + label="Regex Filters", + help="One filter per line, as left|right|values|description."), + ParamSpec("delimiter", STR, default=",", advanced=True), + ParamSpec("label_key", STR, default="classes", label="Label Key", + help="Which metadata field supplies the labels output."), + ParamSpec("sampling_rate", INT, default=1000, minimum=1, + label="Sampling Rate", help="Hz. Used by downstream filters."), + ParamSpec("replay", ENUM, choices=["as fast as possible", "real time"], + default="as fast as possible", advanced=True, + help="Pace the replay to simulate a live session, or run flat out."), + ], + help="Reads recordings from disk and replays them through the pipeline.", + offline_only=True, + )) + + # ---------------- transforms ---------------- + add(NodeSpec( + id="transform.filter", + category=TRANSFORM, + title="Filter", + inputs=[PortSpec("input", PortType.SAMPLES)], + outputs=[PortSpec("output", PortType.SAMPLES, multiple=True)], + params=[ + ParamSpec("name", ENUM, choices=list(FILTER_NAMES), default="bandpass", + label="Type"), + ParamSpec("cutoff", STR, default="20,450", + help="One value, or two separated by a comma for a band."), + ParamSpec("order", INT, default=4, minimum=1, maximum=20), + ParamSpec("bandwidth", FLOAT, default=3.0, advanced=True, + help="Notch only."), + ParamSpec("sampling_rate", INT, default=1000, minimum=1, + label="Sampling Rate", help="Hz."), + ], + help="Conditions the signal once, for every consumer downstream.", + )) + + add(NodeSpec( + id="transform.channel_mask", + category=TRANSFORM, + title="Channel Mask", + inputs=[PortSpec("input", PortType.SAMPLES)], + outputs=[PortSpec("output", PortType.SAMPLES, multiple=True)], + params=[ParamSpec("channels", STR, default="", + help="Channel indices to keep, comma separated. Empty keeps all.")], + help="Narrows the stream to a subset of channels.", + )) + + # ---------------- window ---------------- + add(NodeSpec( + id="window.enframe", + category=WINDOW, + title="Window", + inputs=[PortSpec("input", PortType.SAMPLES)], + outputs=[PortSpec("output", PortType.WINDOWS, multiple=True)], + params=[ + ParamSpec("window_size", INT, default=200, minimum=2, label="Window Size", + help="Samples per window."), + ParamSpec("window_increment", INT, default=50, minimum=1, + label="Window Increment", + help="New samples that constitute a window boundary."), + ], + # Deliberately no mode parameter. Whether this triggers downstream work + # on a live stream or enframes a recording in batches follows from what + # it is connected to, and a user who set it wrongly would get a + # pipeline that silently did nothing. + help="Cuts the stream into windows. Triggers downstream work online, " + "and enframes in batches offline.", + )) + + # ---------------- features ---------------- + add(NodeSpec( + id="features.extract", + category=FEATURES, + title="Features", + inputs=[PortSpec("input", PortType.WINDOWS)], + outputs=[PortSpec("output", PortType.FEATURES, multiple=True)], + params=[ + ParamSpec("feature_group", ENUM, choices=["(custom)"] + groups, + default="(custom)", label="Feature Group", + help="Pick a published group, or choose features individually."), + ParamSpec("features", MULTI_SELECT, choices=features, + default=["MAV", "ZC", "SSC", "WL"], + help="Used when the group is set to custom."), + ], + help="Extracts features once, so several models can share them.", + )) + + # ---------------- models ---------------- + add(NodeSpec( + id="model.classifier", + category=MODEL, + title="Classifier", + # Either input, not both. A statistical model takes features; a deep + # model takes the windows themselves. Both are marked optional so the + # generic "nothing connected" rule does not demand both, and a + # model-specific rule in the document requires exactly one. + inputs=[PortSpec("input", PortType.FEATURES, "Features", optional=True), + PortSpec("windows", PortType.WINDOWS, "Windows", optional=True)], + outputs=[PortSpec("output", PortType.PREDICTION, multiple=True)], + params=[ + ParamSpec("model", ENUM, choices=classifiers, default="LDA"), + ParamSpec("model_path", PATH, default="", label="Fitted Model", + help="A saved predictor to run. Required online."), + ParamSpec("rejection_threshold", FLOAT, default=0.0, minimum=0.0, maximum=1.0, + label="Rejection Threshold", + help="Confidence below which a prediction is rejected. 0 disables."), + ParamSpec("majority_vote", INT, default=0, minimum=0, + label="Majority Vote", help="Decisions to vote over. 0 disables."), + ParamSpec("velocity", BOOL, default=False, + help="Also output a proportional velocity."), + ], + help="Predicts a class from features.", + )) + + add(NodeSpec( + id="model.regressor", + category=MODEL, + title="Regressor", + inputs=[PortSpec("input", PortType.FEATURES, "Features", optional=True), + PortSpec("windows", PortType.WINDOWS, "Windows", optional=True)], + outputs=[PortSpec("output", PortType.CONTINUOUS, multiple=True)], + params=[ + ParamSpec("model", ENUM, choices=regressors, default="LR"), + ParamSpec("model_path", PATH, default="", label="Fitted Model", + help="A saved predictor to run. Required online."), + ParamSpec("deadband_threshold", FLOAT, default=0.0, minimum=0.0, + label="Deadband", help="Outputs below this are zeroed."), + ], + help="Predicts continuous outputs from features.", + )) + + # ---------------- sinks ---------------- + add(NodeSpec( + id="sink.socket", + category=SINK, + title="Socket Output", + inputs=[PortSpec("input", PortType.PREDICTION)], + params=[ + ParamSpec("ip", STR, default="127.0.0.1"), + ParamSpec("port", INT, default=12346, minimum=1, maximum=65535), + ParamSpec("protocol", ENUM, choices=["UDP", "TCP"], default="UDP"), + ], + help="Sends each output over a socket, for an environment to read.", + online_only=True, + )) + + add(NodeSpec( + id="sink.file", + category=SINK, + title="File Output", + inputs=[PortSpec("input", PortType.PREDICTION)], + params=[ParamSpec("file_path", PATH, default="output.log", label="File")], + help="Appends each output to a file.", + )) + + add(NodeSpec( + id="sink.console", + category=SINK, + title="Console Output", + inputs=[PortSpec("input", PortType.PREDICTION)], + params=[], + help="Prints each output. Useful while building a pipeline up.", + )) + + add(NodeSpec( + id="sink.metrics", + category=SINK, + title="Offline Metrics", + inputs=[PortSpec("input", PortType.PREDICTION), + PortSpec("labels", PortType.LABELS)], + outputs=[PortSpec("output", PortType.METRICS, multiple=True)], + params=[ + ParamSpec("metrics", MULTI_SELECT, choices=metrics, + default=["CA", "CONF_MAT"]), + ParamSpec("null_label", INT, default=-1, advanced=True, + label="Null Label", help="Class treated as no motion."), + ], + help="Scores predictions against ground truth once a run finishes.", + offline_only=True, + )) + + return specs + + +_DEFAULT = None + + +def default_registry(refresh=False): + """The registry, built once per process. + + Parameters + ---------- + refresh: bool (optional), default=False + Rebuild even if one was already made. Intended for tests. + + Returns + ---------- + dict + Mapping from spec id to :class:`NodeSpec`. + """ + global _DEFAULT + if _DEFAULT is None or refresh: + _DEFAULT = build_registry() + return _DEFAULT diff --git a/libemg/_gui/_pipeline/synthetic.py b/libemg/_gui/_pipeline/synthetic.py new file mode 100644 index 00000000..21af510e --- /dev/null +++ b/libemg/_gui/_pipeline/synthetic.py @@ -0,0 +1,133 @@ +"""A source that produces samples without a device attached. + +Building a pipeline is mostly a matter of getting the shapes and the rates +right, and none of that needs real electrodes. This writes into shared memory +exactly as a device streamer does, so a pipeline built on it is the same +pipeline, and swapping in the real device later changes one block. + +It is also what makes the editor testable: an end-to-end run can be exercised +on a machine with no hardware plugged in. +""" + +import time +from multiprocessing import Event, Process + +import numpy as np + + +class SyntheticStreamer(Process): + """Commits generated samples at a fixed rate, like a device would. + + Parameters + ---------- + shared_memory_items: list + The items to write into, in the usual ``[tag, shape, dtype, lock]`` + form. The first non-counter item is written to. + sampling_rate: int (optional), default=1000 + Samples per second. + num_channels: int (optional), default=8 + Channels produced. + pattern: str (optional), default='noise' + ``'noise'`` for gaussian noise, ``'sine'`` for a per-channel sine, and + ``'bursts'`` for alternating quiet and active periods, which is the + shape that makes a classifier pipeline visibly do something. + amplitude: float (optional), default=1.0 + Scale of the generated signal. + """ + + def __init__(self, shared_memory_items, sampling_rate=1000, num_channels=8, + pattern="noise", amplitude=1.0): + super().__init__(daemon=True) + self.shared_memory_items = shared_memory_items + self.sampling_rate = int(sampling_rate) + self.num_channels = int(num_channels) + self.pattern = pattern + self.amplitude = float(amplitude) + self.signal = Event() + self.notifier_pool = None + + def run(self): + from libemg.shared_memory_manager import SharedMemoryManager + smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) + for item in self.shared_memory_items: + smm.create_variable(*item) + tag = next(item[0] for item in self.shared_memory_items + if not item[0].endswith("_count")) + + rng = np.random.default_rng(0) + period = 1.0 / self.sampling_rate + # Sample times are accumulated from a fixed start rather than by + # sleeping a period each time, so the stream does not drift slower and + # slower as each sleep overshoots by a little. + started = time.perf_counter() + index = 0 + while not self.signal.is_set(): + index += 1 + target = started + index * period + delay = target - time.perf_counter() + if delay > 0: + time.sleep(delay) + smm.commit(tag, self._sample(rng, index)) + smm.cleanup(parent=False) + + def _sample(self, rng, index): + t = index / self.sampling_rate + if self.pattern == "sine": + frequencies = np.arange(1, self.num_channels + 1) * 5.0 + row = np.sin(2 * np.pi * frequencies * t) + elif self.pattern == "bursts": + # Four seconds quiet, four active, so a window of either is easy to + # recognise in a probe and easy to classify. + active = (int(t) // 4) % 2 == 1 + row = rng.standard_normal(self.num_channels) * (3.0 if active else 0.3) + else: + row = rng.standard_normal(self.num_channels) + return (row * self.amplitude).reshape(1, -1) + + +def synthetic_streamer(shared_memory_items=None, sampling_rate=1000, + num_channels=8, pattern="noise", amplitude=1.0): + """Start a source that needs no hardware. + + Matches the shape of the device streamers in :mod:`libemg.streamers`, so + anything that accepts one of those accepts this. + + Parameters + ---------- + shared_memory_items: list or None (optional), default=None + Items to write into. Built for you if omitted. + sampling_rate: int (optional), default=1000 + Samples per second. + num_channels: int (optional), default=8 + Channels produced. + pattern: str (optional), default='noise' + ``'noise'``, ``'sine'`` or ``'bursts'``. + amplitude: float (optional), default=1.0 + Scale of the generated signal. + + Returns + ---------- + SyntheticStreamer + The running process. + list + The shared memory items, to pass to an OnlineDataHandler. + + Examples + --------- + >>> streamer, shared_memory = synthetic_streamer(pattern='bursts') + >>> odh = OnlineDataHandler(shared_memory) + """ + from libemg.shared_memory_manager import assign_shared_memory_locks + from libemg.reactive import default_notifier_pool + + if shared_memory_items is None: + shared_memory_items = [["emg", (2000, num_channels), np.double], + ["emg_count", (1, 1), np.int32]] + assign_shared_memory_locks(shared_memory_items) + + streamer = SyntheticStreamer(shared_memory_items, sampling_rate=sampling_rate, + num_channels=num_channels, pattern=pattern, + amplitude=amplitude) + streamer.notifier_pool = default_notifier_pool() + streamer.start() + return streamer, shared_memory_items diff --git a/libemg/_gui/_streamer_panel.py b/libemg/_gui/_streamer_panel.py new file mode 100644 index 00000000..c870290b --- /dev/null +++ b/libemg/_gui/_streamer_panel.py @@ -0,0 +1,376 @@ +"""Start and stop a device from the GUI. + +This is the first thing a session needs and the last thing that still had to be +done in a script. With it, a device can be brought up, watched, and handed to +everything else in the window: screen guided training, the live signal view, +the pipeline editor and the environments all work off the handler this panel +publishes. + +As elsewhere, the device list and each device's options are generated rather +than written out. The streamers are found in :mod:`libemg.streamers` and +filtered to those that write into shared memory, because a streamer that talks +over a socket has nothing the rest of the GUI could attach to. Each device's +own arguments become its controls, so a new device appears here with its +options intact and nothing in this file changes. +""" + +import time +import traceback + +import dearpygui.dearpygui as dpg + +from libemg._gui._pipeline.registry import (BOOL, FLOAT, INT, STR, + _params_from_signature, + _streamer_functions) + +WINDOW_TAG = "__streamer_window" +STATUS_TAG = "__streamer_status" +MESSAGE_TAG = "__streamer_message" +TABLE_TAG = "__streamer_table" + +#: Offered alongside the real devices. Building a pipeline, trying an +#: environment or rehearsing a session should not require hardware on the desk. +SYNTHETIC = "Synthetic (no hardware)" + + +class StreamerPanel: + """Bring a device up, watch it, and hand it to the rest of the GUI. + + Parameters + ---------- + on_started: callable or None (optional), default=None + Called as ``on_started(online_data_handler, shared_memory_items)`` once + a device is streaming. The GUI uses this to make the handler available + to its other panels. + on_stopped: callable or None (optional), default=None + Called when the device stops. + width, height: int (optional) + Window size. + + Examples + --------- + >>> panel = StreamerPanel() + >>> panel.spawn_window() + """ + + + #: The window this panel owns, so a caller can ask if it is open. + window_tag = WINDOW_TAG + + def __init__(self, on_started=None, on_stopped=None, width=760, height=620): + self.on_started = on_started + self.on_stopped = on_stopped + self.width, self.height = width, height + self.functions = _streamer_functions() + self.devices = [SYNTHETIC] + sorted(self.functions) + self.selected = SYNTHETIC + self.values = {} + self.handle = None + self.items = None + self.odh = None + self._modalities = [] + self._previous = {} + self._previous_at = 0.0 + self._rates = {} + self._started_at = 0.0 + self._seen_data = False + self._warned = False + + # ================================================================== + # window + # ================================================================== + def spawn_window(self): + """Build the streamer window.""" + self.cleanup() + with dpg.window(label="Streamer", tag=WINDOW_TAG, + width=self.width, height=self.height, + on_close=lambda: self.cleanup()): + with dpg.group(horizontal=True): + dpg.add_text("Device") + dpg.add_combo(self.devices, default_value=self.selected, + tag="__streamer_choice", width=240, + callback=self._choose) + dpg.add_button(label="Start", tag="__streamer_start", + callback=self.start) + dpg.add_button(label="Stop", tag="__streamer_stop", + callback=self.stop) + dpg.add_text("", tag=MESSAGE_TAG, wrap=self.width - 40) + dpg.add_separator() + with dpg.child_window(tag="__streamer_body", autosize_x=True, + height=260): + self._build_settings() + dpg.add_separator() + dpg.add_text("Incoming data", tag=STATUS_TAG) + with dpg.table(tag=TABLE_TAG, header_row=True, borders_innerH=True, + borders_outerH=True, borders_innerV=True, + policy=dpg.mvTable_SizingStretchProp): + dpg.add_table_column(label="Modality") + dpg.add_table_column(label="Samples") + dpg.add_table_column(label="Rate (Hz)") + dpg.add_table_column(label="Writes") + self._say("Pick a device and press Start. Nothing else in the window " + "can see data until one is running.") + return self + + def _build_settings(self): + if self.selected == SYNTHETIC: + dpg.add_text("Generates samples at a fixed rate, so the rest of the " + "window can be used with no hardware attached.", + wrap=self.width - 60, color=(170, 190, 210)) + for name, kind, default, minimum, choices in ( + ("sampling_rate", INT, 1000, 1, None), + ("num_channels", INT, 8, 1, None), + ("pattern", "enum", "bursts", None, + ["noise", "sine", "bursts"]), + ("amplitude", FLOAT, 1.0, 0.0, None)): + self.values.setdefault(name, default) + self._control(name, kind, default, minimum, choices) + return + + function = self.functions[self.selected] + summary = (function.__doc__ or "").strip().split("\n")[0] + dpg.add_text(summary[:200], wrap=self.width - 60, color=(170, 190, 210)) + specs = _params_from_signature(function) + if not specs: + dpg.add_text("This device takes no options.") + for spec in specs: + self.values.setdefault(spec.name, spec.default) + kind = {INT: INT, FLOAT: FLOAT, BOOL: BOOL}.get(spec.kind, STR) + self._control(spec.name, kind, spec.default, None, None, + help=spec.help) + + def _control(self, name, kind, default, minimum, choices, help=""): + tag = f"__streamer_set_{name}" + value = self.values.get(name, default) + label = name.replace("_", " ").title() + common = dict(tag=tag, user_data=name, callback=self._changed, + label=label, width=160) + if kind == "enum": + dpg.add_combo(list(choices), default_value=value or default, **common) + elif kind == BOOL: + dpg.add_checkbox(default_value=bool(value), tag=tag, user_data=name, + callback=self._changed, label=label) + elif kind == INT: + dpg.add_input_int(default_value=int(value or 0), step=1, **common) + elif kind == FLOAT: + dpg.add_input_float(default_value=float(value or 0.0), step=0.0, + format="%.3f", **common) + else: + dpg.add_input_text(default_value="" if value is None else str(value), + **common) + if help: + with dpg.tooltip(tag): + dpg.add_text(help, wrap=320) + + def _choose(self, sender, app_data): + if self.handle is not None: + self._say("Stop the running device before switching to another.") + dpg.set_value("__streamer_choice", self.selected) + return + self.selected = app_data + self.values = {} + if dpg.does_item_exist("__streamer_body"): + dpg.delete_item("__streamer_body", children_only=True) + dpg.push_container_stack("__streamer_body") + self._build_settings() + dpg.pop_container_stack() + + def _changed(self, sender, app_data, user_data): + self.values[user_data] = app_data + + # ================================================================== + # running + # ================================================================== + def start(self): + """Bring the selected device up and publish its handler.""" + if self.handle is not None: + self._say("Already streaming. Stop it first.") + return + try: + handle, items = self._launch() + except Exception: + # A device that is not plugged in, a driver that is missing, a port + # already in use: all of these surface here, and the last line of + # the traceback is the part worth showing. + self._say("Could not start that device:\n" + + traceback.format_exc().strip().splitlines()[-1]) + return + + from libemg.data_handler import OnlineDataHandler + self.handle, self.items = handle, items + try: + self.odh = OnlineDataHandler(items) + except Exception: + self._say("The device started but its data could not be attached:\n" + + traceback.format_exc().strip().splitlines()[-1]) + self.stop() + return + + # Start from zero. Shared-memory segments outlive the process that made + # them, and every device writes to the same modality names, so a new + # device attaches to whatever the last one left behind. Without this, + # counters from a previous session read as live data and a device that + # is not connected looks like it is working. + try: + self.odh.reset() + except Exception: + pass + + self._modalities = list(self.odh.modalities) + self._previous, self._rates = {}, {} + self._previous_at = time.perf_counter() + self._started_at = self._previous_at + self._seen_data = False + self._warned = False + self._rebuild_table() + if self.on_started is not None: + self.on_started(self.odh, items) + # Deliberately not "streaming" yet. A device streamer spawns a process + # that connects on its own, so a device that is unplugged, asleep or + # paired to something else starts perfectly well and simply never + # produces a sample. Waiting to see one before claiming success is what + # stops the window from reporting a working device that is not there. + self._say(f"{self.selected} started. Waiting for the first samples.") + + def _launch(self): + """Call the chosen streamer, whatever kind it is.""" + if self.selected == SYNTHETIC: + from libemg._gui._pipeline.synthetic import synthetic_streamer + return synthetic_streamer( + sampling_rate=int(self.values.get("sampling_rate", 1000)), + num_channels=int(self.values.get("num_channels", 8)), + pattern=self.values.get("pattern", "bursts"), + amplitude=float(self.values.get("amplitude", 1.0))) + + function = self.functions[self.selected] + keywords = {} + for spec in _params_from_signature(function): + value = self.values.get(spec.name, spec.default) + if value is None or value == "": + continue + keywords[spec.name] = value + result = function(**keywords) + if isinstance(result, tuple) and len(result) == 2: + return result + raise RuntimeError( + f"{self.selected} did not return a streamer and its shared memory. " + "A device the GUI can use has to return both.") + + def stop(self): + """Stop the device and tell the rest of the window it has gone.""" + if self.handle is None: + return + try: + # Streamers stop by their own signal where they have one, and are + # terminated where they do not; both kinds exist in the library. + if hasattr(self.handle, "signal"): + self.handle.signal.set() + if hasattr(self.handle, "join"): + self.handle.join(timeout=3) + if hasattr(self.handle, "is_alive") and self.handle.is_alive(): + self.handle.terminate() + except Exception: + pass + self.handle = None + self.items = None + # The handler opened an OS handle per shared-memory segment. Dropping + # the reference does not close them, so a start-stop-start cycle would + # accumulate handles for as long as the window stayed open. Closing + # without unlinking is the right half: this process is not the one that + # created the segments, and unlinking them would pull the ground out + # from under anything else still attached. + if self.odh is not None: + try: + self.odh.smm.cleanup(parent=False) + except Exception: + pass + self.odh = None + self._modalities = [] + self._rebuild_table() + if self.on_stopped is not None: + self.on_stopped() + self._say("Stopped.") + + def cleanup(self): + """Stop the device and remove the window.""" + self.stop() + if dpg.does_alias_exist(WINDOW_TAG): + dpg.delete_item(WINDOW_TAG) + + # ================================================================== + # per-frame, on the render thread + # ================================================================== + def poll(self): + """Refresh what the device is delivering. + + Reads each modality's state block rather than its data. That is a + handful of integers, so showing a live rate costs nothing measurable + and cannot slow the device down. + """ + if self.odh is None or not self._modalities: + return + now = time.perf_counter() + elapsed = now - self._previous_at + if elapsed < 0.25: + return + try: + states = self.odh.get_state() + except Exception: + return + self._previous_at = now + arriving = any(state.total_samples > 0 for state in states.values()) + if arriving and not self._seen_data: + self._seen_data = True + self._say(f"{self.selected} is streaming. Collect Data, Live Signal, " + "the pipeline editor and the environments can all use it now.") + elif not arriving and not self._warned and now - self._started_at > 5.0: + self._warned = True + self._say(f"{self.selected} started, but no samples have arrived in " + "five seconds. Check that the device is on, paired and not " + "in use by another program. It is left running in case it " + "is still connecting.") + for modality, state in states.items(): + previous = self._previous.get(modality) + if previous is not None and elapsed > 0: + instant = (state.total_samples - previous) / elapsed + # Smoothed, because a rate recomputed from a quarter second + # jumps around too much to read. + held = self._rates.get(modality) + self._rates[modality] = instant if held is None \ + else 0.7 * held + 0.3 * instant + self._previous[modality] = state.total_samples + self._set_row(modality, state) + if dpg.does_item_exist(STATUS_TAG): + total = sum(self._rates.values()) + count = len(self._modalities) + dpg.set_value(STATUS_TAG, + f"Incoming data {total:.0f} samples per second across " + f"{count} modalit{'y' if count == 1 else 'ies'}") + + def _rebuild_table(self): + if not dpg.does_item_exist(TABLE_TAG): + return + for child in dpg.get_item_children(TABLE_TAG, 1) or []: + dpg.delete_item(child) + for modality in self._modalities: + with dpg.table_row(parent=TABLE_TAG, tag=f"__streamer_row_{modality}"): + dpg.add_text(modality) + dpg.add_text("0", tag=f"__streamer_n_{modality}") + dpg.add_text("0", tag=f"__streamer_r_{modality}") + dpg.add_text("0", tag=f"__streamer_w_{modality}") + if dpg.does_item_exist(STATUS_TAG): + dpg.set_value(STATUS_TAG, "Incoming data" if self._modalities + else "Incoming data nothing streaming") + + def _set_row(self, modality, state): + for prefix, value in (("n", f"{state.total_samples}"), + ("r", f"{self._rates.get(modality, 0.0):.0f}"), + ("w", f"{state.commits}")): + tag = f"__streamer_{prefix}_{modality}" + if dpg.does_item_exist(tag): + dpg.set_value(tag, value) + + # ------------------------------------------------------------------ + def _say(self, message): + if dpg.does_item_exist(MESSAGE_TAG): + dpg.set_value(MESSAGE_TAG, message) diff --git a/libemg/_gui/_utils.py b/libemg/_gui/_utils.py index 61683d41..596af882 100644 --- a/libemg/_gui/_utils.py +++ b/libemg/_gui/_utils.py @@ -1,7 +1,6 @@ from PIL import Image import numpy as np import dearpygui.dearpygui as dpg -import matplotlib.pyplot as plt import cv2 class Media: @@ -20,6 +19,7 @@ def from_file(self, location, fps=24): def import_picture(self, location): self.file_content = Image.open(location) + self._invalidate_texture_cache() def import_gif(self, location): self.file_content = Image.open(location) @@ -27,6 +27,7 @@ def import_gif(self, location): self.file_content.seek(self.frame) self.n_frames = self.file_content.n_frames self.frame_times = np.linspace(0, self.n_frames/self.fps, int(self.n_frames)) + self._invalidate_texture_cache() def import_video(self, location): # get video capture ready @@ -38,19 +39,37 @@ def import_video(self, location): _, cv2_image = self.video_capture.read() cv2_image = cv2.cvtColor(cv2_image,cv2.COLOR_BGR2RGBA) self.file_content = Image.fromarray(cv2_image) + self._invalidate_texture_cache() def from_numpy(self, numpy_array): self.file_content = Image.fromarray(numpy_array) self.type = "png" + self._invalidate_texture_cache() def reset(self): if self.type == "gif": self.frame = 0 self.file_content.seek(self.frame) + self._invalidate_texture_cache() if self.type == "mp4": - self.frame = 0 - self.video_capture.set(cv2.CAP_PROP_FRAME_COUNT, 0) + # CAP_PROP_POS_FRAMES is the decoder's read position; + # CAP_PROP_FRAME_COUNT (which used to be set here) is the read-only + # length of the clip, so the rewind never actually happened. The + # sequential fast path in advance_to tracks the decoder with + # self.frame, so this has to be a real seek. + self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, 0) self.fps = self.video_capture.get(cv2.CAP_PROP_FPS) + self.frame = 0 + # Pull frame 0 back out so the object is left in exactly the state + # import_video leaves it in: file_content holds frame 0 and the + # decoder is parked on frame 1. Without this read the caller would + # keep showing the last frame of the previous playthrough and every + # frame after it would be off by one. + ret, cv2_image = self.video_capture.read() + if ret: + cv2_image = cv2.cvtColor(cv2_image, cv2.COLOR_BGR2RGBA) + self.file_content = Image.fromarray(cv2_image) + self._invalidate_texture_cache() def advance(self): assert hasattr(self, "file_content") @@ -73,49 +92,103 @@ def advance(self): def advance_to(self, play_time): if not hasattr(self, "frame_times"): return - # find the closest time - del_times = np.abs(self.frame_times - play_time) - closest_frame = np.argmin(del_times) + # find the closest time. Frames are evenly spaced at 1/fps, so the index + # is arithmetic - the old np.abs(frame_times - play_time).argmin() + # scanned every frame time of the clip on every rendered frame. + last_frame = max(0, int(self.n_frames) - 1) + closest_frame = int(round(play_time * self.fps)) + closest_frame = min(max(closest_frame, 0), last_frame) if self.type == "gif": if closest_frame < self.file_content.n_frames: self.frame = closest_frame self.file_content.seek(self.frame) if self.type == "mp4": - self.frame = closest_frame - self.video_capture.set(1, self.frame) + if closest_frame == self.frame: + # Already showing this frame, so there is nothing to decode. + return + if closest_frame != self.frame + 1: + # Only seek when the frame cannot be reached by reading forward + # once: a backward jump, or a skip of more than one frame. A + # seek sends the decoder back to a keyframe and re-decodes + # forward from there, which is what made playback pay for a + # keyframe seek on every single frame. + self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, closest_frame) ret, cv2_image = self.video_capture.read() if not ret: print("End of video reached") + # Nothing was decoded, so re-derive self.frame from where the + # decoder actually stopped (the frame still being held is the + # one before its next read position). Leaving self.frame stale + # would let the sequential fast path above hand out a frame that + # was never decoded. + position = int(self.video_capture.get(cv2.CAP_PROP_POS_FRAMES)) + self.frame = max(0, position - 1) else: + self.frame = closest_frame cv2_image = cv2.cvtColor(cv2_image,cv2.COLOR_BGR2RGBA) self.file_content = Image.fromarray(cv2_image) + def _invalidate_texture_cache(self): + """Forget the cached texture, for when file_content has been replaced.""" + self._tex_cache_key = None + self._tex_cache_value = None + def get_dpg_formatted_texture(self, width, height, grayscale=False): + # Rasterising is resize -> convert -> float32 -> divide, ~10.8 ms and + # ~11 MB at 720x480, and screen guided training asks for a texture once + # per rendered frame. The cache key carries self.frame, so a still image + # (which has no frame) produces a constant key and only rasterises once, + # while a gif/mp4 gets a new key per frame and correctly re-rasterises - + # for those every frame really is a new image. + # Only the single most recent entry is kept. That is enough to remove + # the repeated-call cost for stills and for a frame that gets requested + # twice, and unlike a multi-entry cache it cannot grow frame by frame + # while a long video plays. + cache_key = (self.type, getattr(self, "frame", None), width, height, grayscale) + if getattr(self, "_tex_cache_key", None) == cache_key: + # Handed out by reference on purpose: dpg's raw texture keeps + # reading the very buffer it was given, so returning the same array + # object is what lets it keep working. Callers must not mutate it. + return self._tex_cache_value dpg_img = self.file_content.resize((width, height)) if grayscale: dpg_img = dpg_img.convert("L") dpg_img = dpg_img.convert("RGBA") - dpg_img = np.asfarray(dpg_img, dtype='f').ravel() + dpg_img = np.asarray(dpg_img, dtype=np.float32).ravel() dpg_img = np.true_divide(dpg_img, 255.0) + self._tex_cache_key = cache_key + self._tex_cache_value = dpg_img return dpg_img -def set_texture(tag, texture, width, height, format=dpg.mvFormat_Float_rgba): - with dpg.texture_registry(show=False): - if dpg.does_item_exist(tag): - dpg.set_value(tag, value=texture) - else: - dpg.add_raw_texture(width=width, - height=height, - default_value=texture, - tag=tag, - format=format) +# One texture registry shared by the whole process. dpg.texture_registry() is +# add_texture_registry + push_container_stack, so using it as a context manager +# mints a brand new registry item with a fresh uuid on every call - set_texture +# runs once per rendered frame, so that leaked thousands of orphan registry +# items per session. +TEXTURE_REGISTRY_TAG = "__libemg_texture_registry" -def init_matplotlib_canvas(width=720, height=480): - plt.figure(figsize=(width/80,height/80), dpi=80) - -def matplotlib_to_numpy(): - canvas = plt.gca().figure.canvas - canvas.draw() - data = np.frombuffer(canvas.tostring_rgb(), dtype=np.uint8) - image = data.reshape(canvas.get_width_height()[::-1] + (3,)) - return image \ No newline at end of file +def get_texture_registry(): + """Return the tag of the shared texture registry, creating it on first use.""" + if not dpg.does_item_exist(TEXTURE_REGISTRY_TAG): + if dpg.does_alias_exist(TEXTURE_REGISTRY_TAG): + # An alias left behind by a deleted registry would make the add + # below raise, so clear it first. + dpg.remove_alias(TEXTURE_REGISTRY_TAG) + dpg.add_texture_registry(show=False, tag=TEXTURE_REGISTRY_TAG) + return TEXTURE_REGISTRY_TAG + +def set_texture(tag, texture, width, height, format=dpg.mvFormat_Float_rgba): + # Updating an existing texture needs no container at all, and this is the + # path taken on every frame after the first, so take it first and entirely + # outside any registry context. + if dpg.does_item_exist(tag): + dpg.set_value(tag, value=texture) + return + # Only a newly created texture needs a registry to live in; parent it to the + # shared one instead of pushing a new container. + dpg.add_raw_texture(width=width, + height=height, + default_value=texture, + tag=tag, + format=format, + parent=get_texture_registry()) diff --git a/libemg/_gui/_visualization_panel.py b/libemg/_gui/_visualization_panel.py new file mode 100644 index 00000000..a947442f --- /dev/null +++ b/libemg/_gui/_visualization_panel.py @@ -0,0 +1,270 @@ +import threading +import time + +import dearpygui.dearpygui as dpg +import numpy as np + + +class VisualizationPanel: + """Live signal viewer drawn with dearpygui's native plots. + + One plot per modality, one line series per channel, updated in place from + the shared memory the OnlineDataHandler already holds open. Nothing is + rasterised on the way: the samples go straight from the shared-memory buffer + into the series, so a frame costs a buffer copy and a series update rather + than a figure render, a PNG encode and a texture upload. + + Parameters + ---------- + online_data_handler: OnlineDataHandler + The handler whose shared memory is plotted. + num_samples: int (optional), default=500 + Initial number of samples shown per modality. Each modality's plot has + its own control, so this is only the starting value. + refresh_rate: float (optional), default=60.0 + Target plot updates per second. Building a frame costs well under a + millisecond, so this — not the work — sets how stale the newest plotted + sample is. 60 matches the display refresh; going higher does not reach + the screen any sooner. See get_frame_stats for what was achieved. + plot_height: int (optional), default=220 + Height in pixels of each modality's plot. + """ + + def __init__(self, + online_data_handler, + num_samples=500, + refresh_rate=60.0, + plot_height=220): + self.online_data_handler = online_data_handler + self.num_samples = num_samples + self.refresh_rate = refresh_rate + self.plot_height = plot_height + + self.modalities = [] + self.channels = {} + self.buffer_samples = {} + self._thread = None + self._stop_event = threading.Event() + # Rolling frame timings, for get_frame_stats(). Bounded so a long + # session cannot grow it without limit. + self._frame_times = [] + self._frame_times_lock = threading.Lock() + self._max_frame_samples = 600 + + self.widget_tags = {"visualization": ["__vls_visualize_window"]} + + # ------------------------------------------------------------------ + # tags + # ------------------------------------------------------------------ + def _enable_tag(self, mod): + return f"__vls_enable_{mod}" + + def _samples_tag(self, mod): + return f"__vls_samples_{mod}" + + def _plot_tag(self, mod): + return f"__vls_plot_{mod}" + + def _xaxis_tag(self, mod): + return f"__vls_xaxis_{mod}" + + def _yaxis_tag(self, mod): + return f"__vls_yaxis_{mod}" + + def _series_tag(self, mod, channel): + return f"__vls_series_{mod}_{channel}" + + # ------------------------------------------------------------------ + # window + # ------------------------------------------------------------------ + def cleanup_window(self): + """Stop the updater and delete the window, in that order. + + The updater writes into the plot items, so it has to be stopped before + they are deleted or it will address items that no longer exist. + """ + self.stop_callback() + for tag in self.widget_tags["visualization"]: + if dpg.does_alias_exist(tag): + dpg.delete_item(tag) + + def _detect_modalities(self): + """Read one snapshot to learn which modalities exist and how wide they are. + + The channel count comes from the data rather than the shared-memory + declaration so that an installed channel mask is reflected. + """ + vals, _ = self.online_data_handler.get_data(N=0) + self.modalities = [mod for mod in self.online_data_handler.modalities if mod in vals] + self.channels = {mod: int(vals[mod].shape[1]) for mod in self.modalities} + self.buffer_samples = {mod: int(vals[mod].shape[0]) for mod in self.modalities} + + def spawn_window(self): + """Build the visualization window. Plots stay idle until Start.""" + self.cleanup_window() + self._detect_modalities() + + if not self.modalities: + raise ConnectionError( + "Attempted to visualize, but no modalities were found in shared memory. " + "Please ensure the OnlineDataHandler is receiving data." + ) + + with dpg.window(label="Visualize Live Signals", + tag="__vls_visualize_window", + width=900, + height=200 + self.plot_height * len(self.modalities), + on_close=lambda: self.stop_callback()): + + with dpg.group(horizontal=True): + dpg.add_button(label="Start", tag="__vls_start_button", + callback=self.start_callback) + dpg.add_button(label="Stop", tag="__vls_stop_button", + callback=self.stop_callback) + dpg.add_text("Stopped", tag="__vls_status") + + dpg.add_separator() + dpg.add_text("Modalities") + + # One row per modality: show/hide, and its own sample count. Both are + # read on every frame, so changes take effect without a callback. + with dpg.table(header_row=True, policy=dpg.mvTable_SizingStretchProp, + borders_outerH=True, borders_innerV=True, + borders_innerH=True, borders_outerV=True): + dpg.add_table_column(label="Show") + dpg.add_table_column(label="Modality") + dpg.add_table_column(label="Channels") + dpg.add_table_column(label="Samples plotted") + for mod in self.modalities: + with dpg.table_row(): + dpg.add_checkbox(tag=self._enable_tag(mod), default_value=True, + callback=self._toggle_modality_callback, + user_data=mod) + dpg.add_text(mod) + dpg.add_text(str(self.channels[mod])) + dpg.add_input_int( + tag=self._samples_tag(mod), + default_value=min(self.num_samples, self.buffer_samples[mod]), + min_value=2, + max_value=self.buffer_samples[mod], + min_clamped=True, + max_clamped=True, + step=100, + width=160, + ) + + dpg.add_separator() + + for mod in self.modalities: + with dpg.plot(label=mod, tag=self._plot_tag(mod), + height=self.plot_height, width=-1, no_menus=True): + dpg.add_plot_legend() + dpg.add_plot_axis(dpg.mvXAxis, label="Sample (oldest to newest)", + tag=self._xaxis_tag(mod)) + dpg.add_plot_axis(dpg.mvYAxis, label="Amplitude", + tag=self._yaxis_tag(mod)) + for channel in range(self.channels[mod]): + dpg.add_line_series([], [], + label=f"{mod}_CH{channel + 1}", + parent=self._yaxis_tag(mod), + tag=self._series_tag(mod, channel)) + + # ------------------------------------------------------------------ + # callbacks + # ------------------------------------------------------------------ + def _toggle_modality_callback(self, sender, app_data, user_data): + plot = self._plot_tag(user_data) + if not dpg.does_item_exist(plot): + return + if app_data: + dpg.show_item(plot) + else: + dpg.hide_item(plot) + + def start_callback(self): + """Begin updating the plots.""" + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event.clear() + with self._frame_times_lock: + self._frame_times = [] + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + if dpg.does_item_exist("__vls_status"): + dpg.set_value("__vls_status", "Running") + + def stop_callback(self): + """Stop updating the plots, leaving the last frame on screen.""" + self._stop_event.set() + thread = self._thread + if thread is not None and thread.is_alive(): + thread.join(timeout=2) + self._thread = None + if dpg.does_item_exist("__vls_status"): + dpg.set_value("__vls_status", "Stopped") + + # ------------------------------------------------------------------ + # updating + # ------------------------------------------------------------------ + def _run(self): + period = 1.0 / self.refresh_rate if self.refresh_rate > 0 else 0.0 + while not self._stop_event.is_set(): + started = time.perf_counter() + try: + self._update_plots() + except Exception: + # The window can be torn down mid-frame, which leaves the items + # this writes to gone. Stop rather than spin on the failure. + break + elapsed = time.perf_counter() - started + with self._frame_times_lock: + self._frame_times.append(elapsed) + if len(self._frame_times) > self._max_frame_samples: + del self._frame_times[:-self._max_frame_samples] + if self._stop_event.wait(max(0.0, period - elapsed)): + break + + def _update_plots(self): + vals, _ = self.online_data_handler.get_data(N=0) + for mod in self.modalities: + if not dpg.get_value(self._enable_tag(mod)): + # Hidden plots are skipped, so narrowing the view to one + # modality buys back the work the others were costing. + continue + requested = int(dpg.get_value(self._samples_tag(mod))) + data = vals[mod] + n = max(2, min(requested, data.shape[0])) + # Shared-memory buffers are newest-first; flip so the newest sample + # is on the right, the way a scope reads. + window = np.flip(data[:n, :], axis=0) + x = list(range(n)) + for channel in range(self.channels[mod]): + dpg.set_value(self._series_tag(mod, channel), + [x, window[:, channel].tolist()]) + dpg.set_axis_limits(self._xaxis_tag(mod), 0, n - 1) + dpg.fit_axis_data(self._yaxis_tag(mod)) + + # ------------------------------------------------------------------ + # instrumentation + # ------------------------------------------------------------------ + def get_frame_stats(self): + """Return timing for the frames drawn so far. + + Returns + ---------- + stats: dict + ``frames``, and when any were drawn, the mean/p95/max seconds spent + building a frame plus the ``max_fps`` those timings would sustain. + """ + with self._frame_times_lock: + times = list(self._frame_times) + if not times: + return {"frames": 0} + times = np.array(times) + return { + "frames": int(times.size), + "mean": float(times.mean()), + "p95": float(np.percentile(times, 95)), + "max": float(times.max()), + "max_fps": float(1.0 / times.mean()) if times.mean() else float("inf"), + } diff --git a/libemg/_streamers/__init__.py b/libemg/_streamers/__init__.py index 79da57c6..3e89541d 100644 --- a/libemg/_streamers/__init__.py +++ b/libemg/_streamers/__init__.py @@ -8,6 +8,5 @@ from libemg._streamers import _OTB_MuoviPlus from libemg._streamers import _OTB_SessantaquattroPlus from libemg._streamers import _OTB_Syncstation -from libemg._streamers import _oymotion_windows_streamer from libemg._streamers import _emager_streamer from libemg._streamers import _leap_streamer diff --git a/libemg/_streamers/_delsys_API_streamer.py b/libemg/_streamers/_delsys_API_streamer.py index 88051d17..63b1b525 100644 --- a/libemg/_streamers/_delsys_API_streamer.py +++ b/libemg/_streamers/_delsys_API_streamer.py @@ -1,12 +1,8 @@ -""" -This is the class that handles the data that is output from the Delsys Trigno Base. -Create an instance of this and pass it a reference to the Trigno base for initialization. -See CollectDataController.py for a usage example. -""" -import numpy as np from libemg.shared_memory_manager import SharedMemoryManager from multiprocessing import Process, Event, Lock +import numpy as np + class DataKernel(): def __init__(self, trigno_base): self.TrigBase = trigno_base @@ -171,15 +167,14 @@ def run(self): from Aero import AeroPy # Set up shared memory self.trigbase = AeroPy() - self.smm = SharedMemoryManager() + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) def write_emg(emg): - # update the samples in "emg" - self.smm.modify_variable("emg", lambda x: np.vstack((np.flip(emg,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("emg_count", lambda x: x + emg.shape[0]) + # The packet arrives oldest-first, which is the orientation commit() + # expects; it prepends and keeps "emg_count" in step under one lock. + self.smm.commit("emg", emg) self.add_emg_handler(write_emg) self.connect(self.key, self.license) diff --git a/libemg/_streamers/_delsys_streamer.py b/libemg/_streamers/_delsys_streamer.py index ca268161..01cf57fc 100644 --- a/libemg/_streamers/_delsys_streamer.py +++ b/libemg/_streamers/_delsys_streamer.py @@ -107,23 +107,19 @@ def add_imu_handler(self, h): self.imu_handlers.append(h) def run(self): - self.smm = SharedMemoryManager() + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) def write_emg(emg): - # update the samples in "emg" - self.smm.modify_variable("emg", lambda x: np.vstack((np.flip(emg,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("emg_count", lambda x: x + emg.shape[0]) + # Oldest-first packet, which is what commit() takes; it prepends and + # keeps "emg_count" in step under one lock. + self.smm.commit("emg", emg) self.add_emg_handler(write_emg) def write_imu(imu): - # update the samples in "imu" - self.smm.modify_variable("imu", lambda x: np.vstack((np.flip(imu,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("imu_count", lambda x: x + imu.shape[0]) - # sock.sendto(data_arr, (self.ip, self.port)) + # Oldest-first packet, as commit() expects. + self.smm.commit("imu", imu) self.add_imu_handler(write_imu) self.connect() @@ -135,7 +131,7 @@ def write_imu(imu): data = np.asarray(struct.unpack('<'+'f'*16, packet)) data = data[self.channel_list] if len(data.shape)==1: - data = data[:, None] + data = data[None, :] for e in self.emg_handlers: e(data) if self.imu: @@ -188,4 +184,4 @@ def _validate(response): if 'OK' not in s: print("warning: TrignoDaq command failed: {}".format(s)) - \ No newline at end of file + diff --git a/libemg/_streamers/_emager_streamer.py b/libemg/_streamers/_emager_streamer.py index bf6df7b7..5236c481 100644 --- a/libemg/_streamers/_emager_streamer.py +++ b/libemg/_streamers/_emager_streamer.py @@ -97,10 +97,12 @@ def close(self): class EmagerStreamer(Process): def __init__(self, shared_memory_items): super().__init__(daemon=True) - self.smm = SharedMemoryManager() self.shared_memory_items = shared_memory_items def run(self): + # Built here rather than in __init__ so it picks up the notifier pool the + # parent attached to this process before starting it. + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) @@ -108,9 +110,10 @@ def run(self): e.connect() def write_emg(emg): + # One 1-D sample per callback, so it is simultaneously oldest- and + # newest-first, and commit() counts the single row the old "+ 1" did. emg = np.array(emg) - self.smm.modify_variable('emg', lambda x: np.vstack((emg, x))[:x.shape[0], :]) - self.smm.modify_variable('emg_count', lambda x: x + 1) + self.smm.commit('emg', emg) e.add_emg_handler(write_emg) diff --git a/libemg/_streamers/_leap_streamer.py b/libemg/_streamers/_leap_streamer.py index a697ba0b..e39bebdf 100644 --- a/libemg/_streamers/_leap_streamer.py +++ b/libemg/_streamers/_leap_streamer.py @@ -15,13 +15,15 @@ def __init__(self, shared_memory_items): self.data_handlers = [] def run(self): - self.smm = SharedMemoryManager() + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) def write_key(value, key): - self.smm.modify_variable(key, lambda x: np.vstack((value, x))[:x.shape[0],:]) - self.smm.modify_variable(key+"_count", lambda x: x + value.shape[0]) + # The rows handed over are newest-first, so they are flipped into the + # oldest-first orientation commit() takes. count_tag is named + # explicitly because the tag varies from call to call. + self.smm.commit(key, np.flip(value, 0), count_tag=key + "_count") self.data_handlers.append(write_key) asyncio.run(self.start_stream()) diff --git a/libemg/_streamers/_myo_streamer.py b/libemg/_streamers/_myo_streamer.py index 4eacc116..0baabccf 100644 --- a/libemg/_streamers/_myo_streamer.py +++ b/libemg/_streamers/_myo_streamer.py @@ -521,11 +521,13 @@ def __init__(self, filtered, emg, imu, shared_memory_items=[]): self.filtered = filtered self.emg = emg self.imu = imu - self.smm = SharedMemoryManager() self.shared_memory_items = shared_memory_items self.signal = Event() def run(self): + # Built here rather than in __init__ so it picks up the notifier pool the + # parent attached to this process before starting it. + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) @@ -537,15 +539,19 @@ def run(self): if self.emg: def write_emg(emg): + # Each notification carries the two sequential readings already + # stacked newest-first, so they are flipped into the oldest-first + # orientation commit() takes. Two rows, which is exactly the "+ 2" + # the counter used to be advanced by. emg = np.array(emg) - self.smm.modify_variable("emg", lambda x: np.vstack((emg, x))[:x.shape[0],:]) - self.smm.modify_variable("emg_count", lambda x: x + 2) + self.smm.commit("emg", np.flip(emg, 0)) self.m.add_emg_handler(write_emg) if self.imu: def write_imu(quat, acc, gyro): + # A single 1-D row, so orientation does not arise, and commit() + # counts the one row the old "+ 1" did. imu_arr = np.array([*quat, *acc, *gyro]) - self.smm.modify_variable("imu", lambda x: np.vstack((imu_arr, x))[:x.shape[0],:]) - self.smm.modify_variable("imu_count", lambda x: x + 1) + self.smm.commit("imu", imu_arr) self.m.add_imu_handler(write_imu) self.m.set_leds([128, 0, 0], [128, 0, 0]) diff --git a/libemg/_streamers/_oymotion_streamer.py b/libemg/_streamers/_oymotion_streamer.py index 8bdabada..e47769ea 100644 --- a/libemg/_streamers/_oymotion_streamer.py +++ b/libemg/_streamers/_oymotion_streamer.py @@ -1,823 +1,700 @@ -# OyMotionStreamer begins here ------ -import socket -import pickle -import time +import asyncio import struct -import numpy as np - -socket_ = None -ip_ = None -port_ = None - -def set_cmd_cb(resp): - print('Command result: {}'.format(resp)) - -def ondata(data): - global socket_ - global ip_ - global port_ - if len(data) > 0: - if data[0] == NotifDataType['NTF_EMG_ADC_DATA'] and len(data) == 129: - emg = np.array(list(data[1:])).reshape(128 // 8,8) - for e in emg: - emg_arr = pickle.dumps(list(e)) - socket_.sendto(emg_arr, (ip_, port_)) - -class OyMotionStreamer(): - def __init__(self, ip, port, - sampRate=1000, - channelMask=0xFF, - dataLen=128, - resolution=8): - global ip_ - ip_ = ip - global port_ - port_ = port - global socket_ - socket_ = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - - self.sampRate = sampRate - self.channelMask = channelMask - self.dataLen = dataLen - self.resolution = resolution - - def start_stream(self): - - - GF = GForceProfile() +from asyncio import Queue +from contextlib import suppress +from dataclasses import dataclass +from enum import IntEnum +from typing import Optional, Dict, List - # Scan all gforces,return [[num,dev_name,dev_addr,dev_Rssi,dev_connectable],...] - scan_results = GF.scan(5) - - if scan_results == []: - print('No bracelet was found') - return - else: - # TODO: check for gpro in address - addr = scan_results[0][2] - GF.connect(addr) - time.sleep(1) - - - GF.setEmgRawDataConfig(self.sampRate, self.channelMask, self.dataLen, self.resolution, cb=set_cmd_cb, timeout=1000) - GF.setDataNotifSwitch(DataNotifFlags['DNF_EMG_RAW'], set_cmd_cb, 1000) - time.sleep(1) - GF.startDataNotification(ondata) - -## BEGIN HARDWARE SPECIFIC CONFIG -import platform -if platform.system() == 'Linux': - from bluepy import btle - from bluepy.btle import DefaultDelegate, Scanner, Peripheral -from datetime import datetime, timedelta -import struct -from enum import Enum -import threading -import time -import queue +""" +Thanks to @zubaidah93 for providing the source code. +""" +import numpy as np +from bleak import BleakScanner, BLEDevice, AdvertisementData, BleakClient, BleakGATTCharacteristic -class GF_RET_CODE(Enum): - - # Method returns successfully. - GF_SUCCESS = 0, - - # Method returns with a generic error. - GF_ERROR = 1, - - # Given parameters are not match required. - GF_ERROR_BAD_PARAM = 2, - - # Method call is not allowed by the inner state. - GF_ERROR_BAD_STATE = 3, - - # Method is not supported at this time. - GF_ERROR_NOT_SUPPORT = 4, - - # Hub is busying on device scan and cannot fulfill the call. - GF_ERROR_SCAN_BUSY = 5, +SERVICE_GUID = '0000ffd0-0000-1000-8000-00805f9b34fb' +CMD_NOTIFY_CHAR_UUID = 'f000ffe1-0451-4000-b000-000000000000' +DATA_NOTIFY_CHAR_UUID = 'f000ffe2-0451-4000-b000-000000000000' - # Insufficient resource to perform the call. - GF_ERROR_NO_RESOURCE = 6, - # A preset timer is expired. - GF_ERROR_TIMEOUT = 7, +@dataclass +class Characteristic: + uuid: str + service_uuid: str + descriptor_uuids: List[str] - # Target device is busy and cannot fulfill the call. - GF_ERROR_DEVICE_BUSY = 8, - # The retrieving data is not ready yet - GF_ERROR_NOT_READY = 9 +class Command(IntEnum): + GET_PROTOCOL_VERSION = 0x00, + GET_FEATURE_MAP = 0x01, + GET_DEVICE_NAME = 0x02, + GET_MODEL_NUMBER = 0x03, + GET_SERIAL_NUMBER = 0x04, + GET_HW_REVISION = 0x05, + GET_FW_REVISION = 0x06, + GET_MANUFACTURER_NAME = 0x07, + GET_BOOTLOADER_VERSION = 0x0A, + GET_BATTERY_LEVEL = 0x08, + GET_TEMPERATURE = 0x09, -CommandType = dict( - CMD_GET_PROTOCOL_VERSION=0x00, - CMD_GET_FEATURE_MAP=0x01, - CMD_GET_DEVICE_NAME=0x02, - CMD_GET_MODEL_NUMBER=0x03, - CMD_GET_SERIAL_NUMBER=0x04, - CMD_GET_HW_REVISION=0x05, - CMD_GET_FW_REVISION=0x06, - CMD_GET_MANUFACTURER_NAME=0x07, - CMD_GET_BOOTLOADER_VERSION=0x0A, + POWEROFF = 0x1D, + SWITCH_TO_OAD = 0x1E, + SYSTEM_RESET = 0x1F, + SWITCH_SERVICE = 0x20, - CMD_GET_BATTERY_LEVEL=0x08, - CMD_GET_TEMPERATURE=0x09, + SET_LOG_LEVEL = 0x21, + SET_LOG_MODULE = 0x22, + PRINT_KERNEL_MSG = 0x23, + MOTOR_CONTROL = 0x24, + LED_CONTROL_TEST = 0x25, + PACKAGE_ID_CONTROL = 0x26, + SEND_TRAINING_PACKAGE = 0x27, - CMD_POWEROFF=0x1D, - CMD_SWITCH_TO_OAD=0x1E, - CMD_SYSTEM_RESET=0x1F, - CMD_SWITCH_SERVICE=0x20, + GET_ACCELERATE_CAP = 0x30, + SET_ACCELERATE_CONFIG = 0x31, - CMD_SET_LOG_LEVEL=0x21, - CMD_SET_LOG_MODULE=0x22, - CMD_PRINT_KERNEL_MSG=0x23, - CMD_MOTOR_CONTROL=0x24, - CMD_LED_CONTROL_TEST=0x25, - CMD_PACKAGE_ID_CONTROL=0x26, - CMD_SEND_TRAINING_PACKAGE=0x27, + GET_GYROSCOPE_CAP = 0x32, + SET_GYROSCOPE_CONFIG = 0x33, - CMD_GET_ACCELERATE_CAP=0x30, - CMD_SET_ACCELERATE_CONFIG=0x31, + GET_MAGNETOMETER_CAP = 0x34, + SET_MAGNETOMETER_CONFIG = 0x35, - CMD_GET_GYROSCOPE_CAP=0x32, - CMD_SET_GYROSCOPE_CONFIG=0x33, + GET_EULER_ANGLE_CAP = 0x36, + SET_EULER_ANGLE_CONFIG = 0x37, - CMD_GET_MAGNETOMETER_CAP=0x34, - CMD_SET_MAGNETOMETER_CONFIG=0x35, + QUATERNION_CAP = 0x38, + QUATERNION_CONFIG = 0x39, - CMD_GET_EULER_ANGLE_CAP=0x36, - CMD_SET_EULER_ANGLE_CONFIG=0x37, + GET_ROTATION_MATRIX_CAP = 0x3A, + SET_ROTATION_MATRIX_CONFIG = 0x3B, - CMD_GET_QUATERNION_CAP=0x38, - CMD_SET_QUATERNION_CONFIG=0x39, + GET_GESTURE_CAP = 0x3C, + SET_GESTURE_CONFIG = 0x3D, - CMD_GET_ROTATION_MATRIX_CAP=0x3A, - CMD_SET_ROTATION_MATRIX_CONFIG=0x3B, + GET_EMG_RAWDATA_CAP = 0x3E, + SET_EMG_RAWDATA_CONFIG = 0x3F, - CMD_GET_GESTURE_CAP=0x3C, - CMD_SET_GESTURE_CONFIG=0x3D, + GET_MOUSE_DATA_CAP = 0x40, + SET_MOUSE_DATA_CONFIG = 0x41, - CMD_GET_EMG_RAWDATA_CAP=0x3E, - CMD_SET_EMG_RAWDATA_CONFIG=0x3F, + GET_JOYSTICK_DATA_CAP = 0x42, + SET_JOYSTICK_DATA_CONFIG = 0x43, - CMD_GET_MOUSE_DATA_CAP=0x40, - CMD_SET_MOUSE_DATA_CONFIG=0x41, + GET_DEVICE_STATUS_CAP = 0x44, + SET_DEVICE_STATUS_CONFIG = 0x45, - CMD_GET_JOYSTICK_DATA_CAP=0x42, - CMD_SET_JOYSTICK_DATA_CONFIG=0x43, + GET_EMG_RAWDATA_CONFIG = 0x46, - CMD_GET_DEVICE_STATUS_CAP=0x44, - CMD_SET_DEVICE_STATUS_CONFIG=0x45, + SET_DATA_NOTIF_SWITCH = 0x4F, + # Partial command packet, format: [CMD_PARTIAL_DATA, packet number in reverse order, packet content] + MD_PARTIAL_DATA = 0xFF - CMD_GET_EMG_RAWDATA_CONFIG=0x46, - CMD_SET_DATA_NOTIF_SWITCH=0x4F, - # Partial command packet, format: [CMD_PARTIAL_DATA, packet number in reverse order, packet content] - MD_PARTIAL_DATA=0xFF -) - -# Response from remote device -ResponseResult = dict( - RSP_CODE_SUCCESS=0x00, - RSP_CODE_NOT_SUPPORT=0x01, - RSP_CODE_BAD_PARAM=0x02, - RSP_CODE_FAILED=0x03, - RSP_CODE_TIMEOUT=0x04, - # Partial packet, format: [RSP_CODE_PARTIAL_PACKET, packet number in reverse order, packet content] - RSP_CODE_PARTIAL_PACKET=0xFF -) - -DataNotifFlags = dict( +class DataSubscription(IntEnum): # Data Notify All Off - DNF_OFF=0x00000000, + OFF = 0x00000000, # Accelerate On(C.7) - DNF_ACCELERATE=0x00000001, + ACCELERATE = 0x00000001, # Gyroscope On(C.8) - DNF_GYROSCOPE=0x00000002, + GYROSCOPE = 0x00000002, # Magnetometer On(C.9) - DNF_MAGNETOMETER=0x00000004, + MAGNETOMETER = 0x00000004, # Euler Angle On(C.10) - DNF_EULERANGLE=0x00000008, + EULERANGLE = 0x00000008, # Quaternion On(C.11) - DNF_QUATERNION=0x00000010, + QUATERNION = 0x00000010, # Rotation Matrix On(C.12) - DNF_ROTATIONMATRIX=0x00000020, + ROTATIONMATRIX = 0x00000020, # EMG Gesture On(C.13) - DNF_EMG_GESTURE=0x00000040, + EMG_GESTURE = 0x00000040, # EMG Raw Data On(C.14) - DNF_EMG_RAW=0x00000080, + EMG_RAW = 0x00000080, # HID Mouse On(C.15) - DNF_HID_MOUSE=0x00000100, + HID_MOUSE = 0x00000100, # HID Joystick On(C.16) - DNF_HID_JOYSTICK=0x00000200, + HID_JOYSTICK = 0x00000200, # Device Status On(C.17) - DNF_DEVICE_STATUS=0x00000400, + DEVICE_STATUS = 0x00000400, # Device Log On - DNF_LOG=0x00000800, + LOG = 0x00000800, # Data Notify All On - DNF_ALL=0xFFFFFFFF -) - - -class ProfileCharType(Enum): - PROF_SIMPLE_DATA = 0 # simple profile: data char - PROF_DATA_CMD = 1, # data profile: cmd char - PROF_DATA_NTF = 2, # data profile:nty char - PROF_OAD_IDENTIFY = 3, # OAD profile:identify char - PROF_OAD_BLOCK = 4, # OAD profile:block char - PROF_OAD_FAST = 5 # OAD profile:fast char - - -NotifDataType = dict( - NTF_ACC_DATA=0x01, - NTF_GYO_DATA=0x02, - NTF_MAG_DATA=0x03, - NTF_EULER_DATA=0x04, - NTF_QUAT_FLOAT_DATA=0x05, - NTF_ROTA_DATA=0x06, - NTF_EMG_GEST_DATA=0x07, - NTF_EMG_ADC_DATA=0x08, - NTF_HID_MOUSE=0x09, - NTF_HID_JOYSTICK=0x0A, - NTF_DEV_STATUS=0x0B, - NTF_LOG_DATA=0x0C, # Log data - - # Partial packet, format: [NTF_PARTIAL_DATA, packet number in reverse order, packet content] - NTF_PARTIAL_DATA=0xFF -) - -LogLevel = dict( - LOG_LEVEL_DEBUG=0x00, - LOG_LEVEL_INFO=0x01, - LOG_LEVEL_WARN=0x02, - LOG_LEVEL_ERROR=0x03, - LOG_LEVEL_FATAL=0x04, - LOG_LEVEL_NONE=0x05 -) - - -class BluetoothDeviceState(Enum): - disconnected = 0, - connected = 1 - - -SERVICE_GUID = '0000ffd0-0000-1000-8000-00805f9b34fb' -CMD_NOTIFY_CHAR_UUID = 'f000ffe1-0451-4000-b000-000000000000' -DATA_NOTIFY_CHAR_UUID = 'f000ffe2-0451-4000-b000-000000000000' - - -class CommandCallbackTableEntry(): - def __init__(self, _cmd, _timeoutTime, _cb): - self._cmd = _cmd - self._timeoutTime = _timeoutTime - self._cb = _cb - -if platform.system() == 'Linux': - class MyDelegate(btle.DefaultDelegate): - def __init__(self, gforce): - super().__init__() - self.gforce = gforce - self.bluepy_thread = threading.Thread(target=self.bluepy_handler) - self.bluepy_thread.setDaemon(True) - self.bluepy_thread.start() - - def bluepy_handler(self): - while True: - if not self.gforce.send_queue.empty(): - cmd = self.gforce.send_queue.get_nowait() - self.gforce.cmdCharacteristic.write(cmd) - self.gforce.device.waitForNotifications(1) - - def handleNotification(self, cHandle, data): - # check cHandle - # self.gforce.lock.acquire() - if cHandle == self.gforce.cmdCharacteristic.getHandle(): - self.gforce._onResponse(data) - - # check cHandle - if cHandle == self.gforce.notifyCharacteristic.getHandle(): - self.gforce.handleDataNotification(data, self.gforce.onData) - # self.gforce.lock.release() - - -class GForceProfile(): - def __init__(self): - self.device = Peripheral() - self.state = BluetoothDeviceState.disconnected - self.cmdCharacteristic = None - self.notifyCharacteristic = None - self.timer = None - self.cmdMap = {} - self.mtu = None - self.cmdForTimeout = -1 - self.incompleteCmdRespPacket = [] - self.lastIncompleteCmdRespPacketId = 0 - self.incompleteNotifPacket = [] - self.lastIncompleteNotifPacketId = 0 - self.onData = None - self.lock = threading.Lock() - self.send_queue = queue.Queue(maxsize=20) - - def getCharacteristic(self, device, uuid): - ches = device.getCharacteristics() - for ch in ches: - if uuid == str(ch.uuid): - return ch - else: - continue - - # Establishes a connection to the Bluetooth Device. - def connect(self, addr): - self.device.connect(addr) - print('connection succeeded') - - # set mtu - MTU = self.device.setMTU(200) - self.mtu = MTU['mtu'][0] - # self.device.setMTU(self.mtu) - # print('mtu:{}'.format(self.mtu)) - - self.state = BluetoothDeviceState.connected - - self.cmdCharacteristic = self.getCharacteristic( - self.device, CMD_NOTIFY_CHAR_UUID) - self.notifyCharacteristic = self.getCharacteristic( - self.device, DATA_NOTIFY_CHAR_UUID) - - # Listen cmd - self.setNotify(self.cmdCharacteristic, True) - - # Open the listening thread - self.device.setDelegate(MyDelegate(self)) - - # Connect the bracelet with the strongest signal - - def connectByRssi(self): - scanner = Scanner() - devices = scanner.scan(10.0) - rssi_devices = {} - - for dev in devices: - print("Device %s (%s), RSSI=%d dB" % - (dev.addr, dev.addrType, dev.rssi)) - for (_, desc, value) in dev.getScanData(): - print(" %s = %s" % (desc, value)) - if (value == SERVICE_GUID): - rssi_devices[dev.rssi] = dev.addr - - rssi = rssi_devices.keys() - dev_addr = rssi_devices[max(rssi)] - - # connect the bracelet - self.device.connect(dev_addr) - print('connection succeeded') - - # set mtu - MTU = self.device.setMTU(2000) - self.mtu = MTU['mtu'][0] - # self.device.setMTU(self.mtu) - # print('mtu:{}'.format(self.mtu)) - - self.state = BluetoothDeviceState.connected - - self.cmdCharacteristic = self.getCharacteristic( - self.device, CMD_NOTIFY_CHAR_UUID) - self.notifyCharacteristic = self.getCharacteristic( - self.device, DATA_NOTIFY_CHAR_UUID) - - # Listen cmd - self.setNotify(self.cmdCharacteristic, True) - - # Open the listening thread - self.device.setDelegate(MyDelegate(self)) - - # Enable a characteristic's notification - def setNotify(self, Chara, swich): - if swich: - setup_data = b"\x01\x00" - else: - setup_data = b"\x00\x00" - - setup_handle = Chara.getHandle() + 1 - self.device.writeCharacteristic( - setup_handle, setup_data, withResponse=False) - - def scan(self, timeout): - scanner = Scanner() - devices = scanner.scan(timeout) - - gforce_scan = [] - i = 1 - for dev in devices: - for (_, _, value) in dev.getScanData(): - if (value == SERVICE_GUID): - gforce_scan.append([i, dev.getValueText( - 9), dev.addr, dev.rssi, str(dev.connectable)]) - i += 1 - return gforce_scan - - # Disconnect from device - def disconnect(self): - - if self.timer != None: - self.timer.cancel() - self.timer = None - # Close the listenThread - - if self.state == BluetoothDeviceState.disconnected: - return True - else: - self.device.disconnect() - self.state == BluetoothDeviceState.disconnected - - # Set data notification flag - def setDataNotifSwitch(self, flags, cb, timeout): - - # Pack data - data = [] - data.append(CommandType['CMD_SET_DATA_NOTIF_SWITCH']) - data.append(0xFF & (flags)) - data.append(0xFF & (flags >> 8)) - data.append(0xFF & (flags >> 16)) - data.append(0xFF & (flags >> 24)) - data = bytes(data) - - def temp(resp, respData): - if cb != None: - cb(resp) - - # Send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - # def switchToOAD(self,cb,timeout): - # # Pack data - # data = [] - # data.append(CommandType['CMD_SWITCH_TO_OAD']) - # data = bytes(data) - # def temp(resp,respData): - # if cb != None: - # cb(resp,None) - - # # Send data - # return self.sendCommand(ProfileCharType.PROF_DATA_CMD,data,True,temp,timeout) - - def powerOff(self, timeout): - # Pack data - data = [] - data.append(CommandType['CMD_POWEROFF']) - data = bytes(data) - - def temp(resp, respData): - pass - - # Send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - def systemReset(self, timeout): - # Pack data - data = [] - data.append(CommandType['CMD_SYSTEM_RESET']) - data = bytes(data) - - def temp(resp, respData): - pass - - # Send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - def setMotor(self, switchStatus, cb, timeout): - data = [] - data.append(CommandType['CMD_MOTOR_CONTROL']) - - tem = 0x01 if switchStatus else 0x00 - data.append(tem) - data = bytes(data) - - def temp(resp, respData): - if cb != None: - cb(resp) - - # send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - def setLED(self, switchStatus, cb, timeout): - data = [] - data.append(CommandType['CMD_LED_CONTROL_TEST']) - - tem = 0x01 if switchStatus else 0x00 - data.append(tem) - data = bytes(data) - - def temp(resp, respData): - if cb != None: - cb(resp) - - # send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - # Get controller firmware version - def setLogLevel(self, logLevel, cb, timeout): - # Pack data - data = [] - data.append(CommandType['CMD_SET_LOG_LEVEL']) - data.append(0xFF & logLevel) - data = bytes(data) - - def temp(resp, respData): - if cb != None: - cb(resp) - - # Send data - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - # Set Emg Raw Data Config - def setEmgRawDataConfig(self, sampRate, channelMask, dataLen, resolution, cb, timeout): - # Pack data - data = b'' - data += struct.pack(' 4: - firmwareVersion = respData.decode('ascii') - else: - firmwareVersion = '' - for i in respData: - firmwareVersion += str(i) + '.' - firmwareVersion = firmwareVersion[0:len(firmwareVersion)] - cb(resp, firmwareVersion) - return self.sendCommand(ProfileCharType.PROF_DATA_CMD, data, True, temp, timeout) - - def sendCommand(self, profileCharType, data, hasResponse, cb, timeout): - if hasResponse and cb != None: - cmd = data[0] - - self.lock.acquire() - - if cmd in self.cmdMap.keys(): - self.lock.release() - return GF_RET_CODE.GF_ERROR_DEVICE_BUSY - self.cmdMap[cmd] = CommandCallbackTableEntry( - cmd, datetime.now()+timedelta(milliseconds=timeout), cb) - self._refreshTimer() - self.lock.release() - - if profileCharType == ProfileCharType.PROF_DATA_CMD: - if self.cmdCharacteristic == None: - return GF_RET_CODE.GF_ERROR_BAD_STATE - else: - if len(data) > self.mtu: - contentLen = self.mtu - 2 - packetCount = (len(data)+contentLen-1)//contentLen - startIndex = 0 - buf = [] - - for i in range(packetCount-1, 0, -1): - buf.append(CommandType['CMD_PARTIAL_DATA']) - buf.append(i) - buf += data[startIndex:startIndex+contentLen] - startIndex += contentLen - self.send_queue.put_nowait(buf) - buf.clear() - # Packet end - buf.append(CommandType['CMD_PARTIAL_DATA']) - buf.append(0) - buf += data[startIndex:] - self.send_queue.put_nowait(buf) - else: - self.send_queue.put_nowait(data) - - return GF_RET_CODE.GF_SUCCESS + ALL = 0xFFFFFFFF + + +class DataType(IntEnum): + ACC = 0x01, + GYO = 0x02, + MAG = 0x03, + EULER = 0x04, + QUAT = 0x05, + ROTA = 0x06, + EMG_GEST = 0x07, + EMG_ADC = 0x08, + HID_MOUSE = 0x09, + HID_JOYSTICK = 0x0A, + DEV_STATUS = 0x0B, + LOG = 0x0C, + + PARTIAL = 0xFF + + +class SampleResolution(IntEnum): + BITS_8 = 8, + BITS_12 = 12 + + +class SamplingRate(IntEnum): + HZ_500 = 500, + HZ_650 = 650, + HZ_1000 = 1000 + + +@dataclass +class EmgRawDataConfig: + fs: SamplingRate = SamplingRate.HZ_1000 + channel_mask: int = 0xFF + batch_len: int = 32 + resolution: SampleResolution = SampleResolution.BITS_8 + + def to_bytes(self): + body = b'' + body += struct.pack(' OyMotionStreamer (No GForce device found). N={count}.") + count += 1 + + + if device == None: + raise Exception("LibEMG -> OyMotionStreamer (No GForce device found). Tries Exceeded.") + + def handle_disconnect(_: BleakClient): + for task in asyncio.all_tasks(): + task.cancel() + + client = BleakClient(device, disconnected_callback=handle_disconnect) + await client.connect() + + await client.start_notify( + CMD_NOTIFY_CHAR_UUID, self._on_cmd_response, + ) + + self.client = client + print("LibEMG -> OyMotionStreamer (connected).") + + def _on_data_response(self, q: Queue, bs: bytearray): + bs = bytes(bs) + full_packet = [] + + is_partial_data = bs[0] == ResponseCode.PARTIAL_PACKET + if is_partial_data: + packet_id = bs[1] + if self.packet_id != 0 and self.packet_id != packet_id + 1: + raise Exception("Unexpected packet id: expected {} got {}".format( + self.packet_id + 1, + packet_id, + )) + elif self.packet_id == 0 or self.packet_id > packet_id: + self.packet_id = packet_id + self.data_packet += bs[2:] + + if self.packet_id == 0: + full_packet = self.data_packet + self.data_packet = [] else: - return GF_RET_CODE.GF_ERROR_BAD_PARAM - - # Refresh time,need external self.lock - def _refreshTimer(self): - def cmp_time(cb): - return cb._timeoutTime - - if self.timer != None: - self.timer.cancel() - - self.timer = None - cmdlist = self.cmdMap.values() - - if len(cmdlist) > 0: - cmdlist = sorted(cmdlist, key=cmp_time) - - # Process timeout entries - timeoutTime = None - listlen = len(cmdlist) - - for i in range(listlen): - timeoutTime = cmdlist[0]._timeoutTime - print('_' * 40) - print('system time : ', datetime.now()) - print('timeout time: ', timeoutTime) - print('\ncmd: {0}, timeout: {1}'.format( - hex(cmdlist[0]._cmd), timeoutTime < datetime.now())) - print('_' * 40) - - if timeoutTime > datetime.now(): - self.cmdForTimeout = cmdlist[0]._cmd - ms = int((timeoutTime.timestamp() - - datetime.now().timestamp())*1000) - - if ms <= 0: - ms = 1 - self.timer = threading.Timer(ms/1000, self._onTimeOut) - self.timer.start() - - break - - cmd = cmdlist.pop(0) - - if cmd._cb != None: - cmd._cb(ResponseResult['RSP_CODE_TIMEOUT'], None) + full_packet = bs - def startDataNotification(self, onData): - - self.onData = onData - - try: - self.setNotify(self.notifyCharacteristic, True) - success = True - except: - success = False + if len(full_packet) == 0: + return - if success: - return GF_RET_CODE.GF_SUCCESS + data = None + data_type = DataType(full_packet[0]) + packet = full_packet[1:] + if data_type == DataType.EMG_ADC: + data = self._convert_emg_to_uv(packet) + elif data_type == DataType.ACC: + data = self._convert_acceleration_to_g(packet) + elif data_type == DataType.GYO: + data = self._convert_gyro_to_dps(packet) + elif data_type == DataType.MAG: + data = self._convert_magnetometer_to_ut(packet) + elif data_type == DataType.EULER: + data = self._convert_euler(packet) + elif data_type == DataType.QUAT: + data = self._convert_quaternion(packet) + elif data_type == DataType.ROTA: + data = self._convert_rotation_matrix(packet) else: - return GF_RET_CODE.GF_ERROR_BAD_STATE - - def stopDataNotification(self): - try: - self.setNotify(self.notifyCharacteristic, False) - success = True - except: - success = False - - if success: - return GF_RET_CODE.GF_SUCCESS + raise Exception(f"Unknown data type {data_type}, full packet: {full_packet}") + + q.put_nowait(data) + + def _convert_emg_to_uv(self, data: bytes): + min_voltage = -1.25 + max_voltage = 1.25 + + if self.resolution == SampleResolution.BITS_8: + dtype = np.uint8 + div = 127.0 + sub = 128 + elif self.resolution == SampleResolution.BITS_12: + dtype = np.uint16 + div = 2047.0 + sub = 2048 else: - return GF_RET_CODE.GF_ERROR_BAD_STATE + raise Exception(f"Unsupported resolution {self.resolution}") - def handleDataNotification(self, data, onData): - fullPacket = [] + gain = 1200.0 + conversion_factor = (max_voltage - min_voltage) / gain / div - if len(data) >= 2: - if data[0] == NotifDataType['NTF_PARTIAL_DATA']: - if self.lastIncompleteNotifPacketId != 0 and self.lastIncompleteNotifPacketId != data[1]+1: - print('Error:lastIncompleteNotifPacketId:{0},current packet id:{1}'.format( - self.lastIncompleteNotifPacketId, data[1])) - # How to do with packet loss? - # Must validate packet len in onData callback! + emg_data = (np.frombuffer(data, dtype=dtype).astype(np.float32) - sub) * conversion_factor + num_channels = 8 - if self.lastIncompleteNotifPacketId == 0 or self.lastIncompleteNotifPacketId > data[1]: - # Only accept packet with smaller packet num - self.lastIncompleteNotifPacketId = data[1] - self.incompleteNotifPacket += data[2:] + return emg_data.reshape(-1, num_channels) - if self.lastIncompleteNotifPacketId == 0: - fullPacket = self.incompleteNotifPacket - self.incompleteNotifPacket = [] + @staticmethod + def _convert_acceleration_to_g(data: bytes): + normalizing_factor = 65536.0 - else: - fullPacket = data + acceleration_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor + num_channels = 3 - if len(fullPacket) > 0: - onData(fullPacket) + return acceleration_data.reshape(-1, num_channels) - # Command notification callback - def _onResponse(self, data): - print('_onResponse: data=', data) + @staticmethod + def _convert_gyro_to_dps(data: bytes): + normalizing_factor = 65536.0 - fullPacket = [] + gyro_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor + num_channels = 3 - if len(data) >= 2: - if data[0] == ResponseResult['RSP_CODE_PARTIAL_PACKET']: - if self.lastIncompleteCmdRespPacketId != 0 and self.lastIncompleteCmdRespPacketId != data[1] + 1: - print('Error: _lastIncompletePacketId:{0}, current packet id:{1}' - .format(self.lastIncompleteCmdRespPacketId, data[1])) + return gyro_data.reshape(-1, num_channels) - if (self.lastIncompleteCmdRespPacketId == 0 or self.lastIncompleteCmdRespPacketId > data[1]): - self.lastIncompleteCmdRespPacketId = data[1] - self.incompleteCmdRespPacket += data[2:] - print('_incompleteCmdRespPacket 等于 ', - self.incompleteCmdRespPacket) + @staticmethod + def _convert_magnetometer_to_ut(data: bytes): + normalizing_factor = 65536.0 - if self.lastIncompleteCmdRespPacketId == 0: - fullPacket = self.incompleteCmdRespPacket - self.incompleteCmdRespPacket = [] - else: - fullPacket = data + magnetometer_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor + num_channels = 3 - if fullPacket != None and len(fullPacket) >= 2: - resp = fullPacket[0] - cmd = fullPacket[1] + return magnetometer_data.reshape(-1, num_channels) - # Delete command callback table entry & refresh timer's timeout + @staticmethod + def _convert_euler(data: bytes): - self.lock.acquire() + euler_data = np.frombuffer(data, dtype=np.float32).astype(np.float32) + num_channels = 3 - if cmd > 0 and self.cmdMap.__contains__(cmd): - cb = self.cmdMap[cmd]._cb + return euler_data.reshape(-1, num_channels) - del self.cmdMap[cmd] + @staticmethod + def _convert_quaternion(data: bytes): - self._refreshTimer() + quaternion_data = np.frombuffer(data, dtype=np.float32).astype(np.float32) + num_channels = 4 - if cb != None: - cb(resp, fullPacket[2:]) + return quaternion_data.reshape(-1, num_channels) - self.lock.release() + @staticmethod + def _convert_rotation_matrix(data: bytes): - # Timeout callback function - def _onTimeOut(self): - print('_onTimeOut: _cmdForTimeout={0}, time={1}'.format( - self.cmdForTimeout, datetime.now())) + rotation_matrix_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) + num_channels = 9 - # Delete command callback table entry & refresh timer's timeout + return rotation_matrix_data.reshape(-1, num_channels) - cb = None - self.lock.acquire() + @staticmethod + def _convert_emg_gesture(data: bytes): - if self.cmdForTimeout > 0 and self.cmdMap.__contains__(self.cmdForTimeout): - cb = self.cmdMap[self.cmdForTimeout]._cb - del self.cmdMap[self.cmdForTimeout] + emg_gesture_data = np.frombuffer(data, dtype=np.int16).astype(np.float16) + num_channels = 6 - self._refreshTimer() + return emg_gesture_data.reshape(-1, num_channels) - self.lock.release() + def _on_cmd_response(self, _: BleakGATTCharacteristic, bs: bytearray): + try: + response = self._parse_response(bytes(bs)) + if response.cmd in self.responses: + self.responses[response.cmd].put_nowait( + response.data, + ) + except Exception as e: + raise Exception("Failed to parse response: %s" % e) + + @staticmethod + def _parse_response(res: bytes): + code = int.from_bytes(res[:1], byteorder='big') + code = ResponseCode(code) + + cmd = int.from_bytes(res[1:2], byteorder='big') + cmd = Command(cmd) + + data = res[2:] + + return Response( + code=code, + cmd=cmd, + data=data, + ) + + async def get_protocol_version(self): + buf = await self._send_request(Request( + cmd=Command.GET_PROTOCOL_VERSION, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_feature_map(self): + buf = await self._send_request(Request( + cmd=Command.GET_FEATURE_MAP, + has_res=True, + )) + return int.from_bytes(buf, byteorder='big') # TODO: check if this is correct + + async def get_device_name(self): + buf = await self._send_request(Request( + cmd=Command.GET_DEVICE_NAME, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_firmware_revision(self): + buf = await self._send_request(Request( + cmd=Command.GET_FW_REVISION, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_hardware_revision(self): + buf = await self._send_request(Request( + cmd=Command.GET_HW_REVISION, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_model_number(self): + buf = await self._send_request(Request( + cmd=Command.GET_MODEL_NUMBER, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_serial_number(self): + buf = await self._send_request(Request( + cmd=Command.GET_SERIAL_NUMBER, + has_res=True, + )) + return buf.decode('utf-8') + + async def get_manufacturer_name(self): + buf = await self._send_request(Request( + cmd=Command.GET_MANUFACTURER_NAME, + has_res=True, + )) + + return buf.decode('utf-8') + + async def get_bootloader_version(self): + buf = await self._send_request(Request( + cmd=Command.GET_BOOTLOADER_VERSION, + has_res=True, + )) + + return buf.decode('utf-8') + + async def get_battery_level(self): + buf = await self._send_request(Request( + cmd=Command.GET_BATTERY_LEVEL, + has_res=True, + )) + return int.from_bytes(buf, byteorder='big') + + async def get_temperature(self): + buf = await self._send_request(Request( + cmd=Command.GET_TEMPERATURE, + has_res=True, + )) + return int.from_bytes(buf, byteorder='big') + + async def power_off(self): + await self._send_request(Request( + cmd=Command.POWEROFF, + has_res=False, + )) + + async def switch_to_oad(self): + await self._send_request(Request( + cmd=Command.SWITCH_TO_OAD, + has_res=False, + )) + + async def system_reset(self): + await self._send_request(Request( + cmd=Command.SYSTEM_RESET, + has_res=False, + )) + + async def switch_service(self): + await self._send_request(Request( + cmd=Command.SWITCH_SERVICE, + has_res=False, + )) + + async def set_motor(self): # TODO: check if this works and what it does + await self._send_request(Request( + cmd=Command.MOTOR_CONTROL, + has_res=True, + )) + + async def set_led(self): # TODO: check if this works and what it does + await self._send_request(Request( + cmd=Command.LED_CONTROL_TEST, + has_res=True, + )) + + async def set_log_level(self): + await self._send_request(Request( + cmd=Command.SET_LOG_LEVEL, + has_res=False, + )) + + async def set_log_module(self): + await self._send_request(Request( + cmd=Command.SET_LOG_MODULE, + has_res=False, + )) + + async def print_kernel_msg(self): + await self._send_request(Request( + cmd=Command.PRINT_KERNEL_MSG, + has_res=True, + )) + + async def set_package_id(self): + await self._send_request(Request( + cmd=Command.PACKAGE_ID_CONTROL, + has_res=False, + )) + + async def send_training_package(self): + await self._send_request(Request( + cmd=Command.SEND_TRAINING_PACKAGE, + has_res=False, + )) + + async def set_emg_raw_data_config(self, cfg=EmgRawDataConfig()): + body = cfg.to_bytes() + await self._send_request(Request( + cmd=Command.SET_EMG_RAWDATA_CONFIG, + body=body, + has_res=True, + )) + self.resolution = cfg.resolution + + async def get_emg_raw_data_config(self): + buf = await self._send_request(Request( + cmd=Command.GET_EMG_RAWDATA_CONFIG, + has_res=True, + )) + return EmgRawDataConfig.from_bytes(buf) + + async def set_subscription(self, subscription: DataSubscription): + body = [0xFF & subscription, 0xFF & (subscription >> 8), 0xFF & (subscription >> 16), + 0xFF & (subscription >> 24)] + body = bytes(body) + await self._send_request(Request( + cmd=Command.SET_DATA_NOTIF_SWITCH, + body=body, + has_res=True, + )) + + async def start_streaming(self): + q = Queue() + await self.client.start_notify( + DATA_NOTIFY_CHAR_UUID, + lambda _, data: self._on_data_response(q, data), + ) + print("LibEMG -> OyMotionStreamer (streaming started).") + return q + + - if cb != None: - cb(ResponseResult['RSP_CODE_TIMEOUT'], None) + async def stop_streaming(self): + exceptions = [] + try: + await self.set_subscription(DataSubscription.OFF) + except Exception as e: + exceptions.append(e) + try: + await self.client.stop_notify(DATA_NOTIFY_CHAR_UUID) + except Exception as e: + exceptions.append(e) + try: + await self.client.stop_notify(CMD_NOTIFY_CHAR_UUID) + except Exception as e: + exceptions.append(e) + + if len(exceptions) > 0: + raise Exception("Failed to stop streaming: %s" % exceptions) + print("LibEMG -> OyMotionStreamer (streaming stopped).") + + async def disconnect(self): + with suppress(asyncio.CancelledError): + await self.client.disconnect() + print("LibEMG -> OyMotionStreamer (disconnected).") + + def _get_response_channel(self, cmd: Command): + q = Queue() + self.responses[cmd] = q + return q + + async def _send_request(self, req: Request): + q = None + if req.has_res: + q = self._get_response_channel(req.cmd) + + bs = bytes([req.cmd]) + if req.body is not None: + bs += req.body + await self.client.write_gatt_char(CMD_NOTIFY_CHAR_UUID, bs) + + if not req.has_res: + return None + + return await asyncio.wait_for(q.get(), 5) + + def run(self): + # Built here rather than in __init__ so it picks up the notifier pool the + # parent attached to this process before starting it. + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) + asyncio.run(self.start_stream()) + + async def start_stream(self): + for item in self.shared_memory_items: + self.smm.create_variable(*item) + await self.connect() + await self.set_emg_raw_data_config(self.emg_conf) + await self.set_subscription( + DataSubscription.EMG_RAW + ) + + q = await self.start_streaming() + try: + while True: + if self.signal.is_set(): + break + + for e in await q.get(): + # One row per sample, so orientation does not arise; commit() + # counts that row exactly as "+ emg.shape[0]" did. + emg = np.expand_dims(np.array(e),0) + self.smm.commit("emg", emg) + + except Exception as e: + print(f"Errored within LibEMG-> OyMotionStreamer: {e}") + + finally: + await self._cleanup() + quit() + + async def _cleanup(self): + await self.stop_streaming() + await self.disconnect() + self.smm.cleanup() + print("LibEMG -> OyMotionStreamer (smm cleaned up).") + print("LibEMG -> OyMotionStreamer (process ended).") + + def stop(self): + self.signal.set() + self.join() \ No newline at end of file diff --git a/libemg/_streamers/_oymotion_windows_streamer.py b/libemg/_streamers/_oymotion_windows_streamer.py deleted file mode 100644 index b149e8db..00000000 --- a/libemg/_streamers/_oymotion_windows_streamer.py +++ /dev/null @@ -1,670 +0,0 @@ -import asyncio -import struct -from asyncio import Queue -from contextlib import suppress -from dataclasses import dataclass -from enum import IntEnum -from typing import Optional, Dict, List - -""" -Thanks to @zubaidah93 for providing the source code. -""" - -import numpy as np -from bleak import BleakScanner, BLEDevice, AdvertisementData, BleakClient, BleakGATTCharacteristic - -SERVICE_GUID = '0000ffd0-0000-1000-8000-00805f9b34fb' -CMD_NOTIFY_CHAR_UUID = 'f000ffe1-0451-4000-b000-000000000000' -DATA_NOTIFY_CHAR_UUID = 'f000ffe2-0451-4000-b000-000000000000' - - -@dataclass -class Characteristic: - uuid: str - service_uuid: str - descriptor_uuids: List[str] - - -class Command(IntEnum): - GET_PROTOCOL_VERSION = 0x00, - GET_FEATURE_MAP = 0x01, - GET_DEVICE_NAME = 0x02, - GET_MODEL_NUMBER = 0x03, - GET_SERIAL_NUMBER = 0x04, - GET_HW_REVISION = 0x05, - GET_FW_REVISION = 0x06, - GET_MANUFACTURER_NAME = 0x07, - GET_BOOTLOADER_VERSION = 0x0A, - - GET_BATTERY_LEVEL = 0x08, - GET_TEMPERATURE = 0x09, - - POWEROFF = 0x1D, - SWITCH_TO_OAD = 0x1E, - SYSTEM_RESET = 0x1F, - SWITCH_SERVICE = 0x20, - - SET_LOG_LEVEL = 0x21, - SET_LOG_MODULE = 0x22, - PRINT_KERNEL_MSG = 0x23, - MOTOR_CONTROL = 0x24, - LED_CONTROL_TEST = 0x25, - PACKAGE_ID_CONTROL = 0x26, - SEND_TRAINING_PACKAGE = 0x27, - - GET_ACCELERATE_CAP = 0x30, - SET_ACCELERATE_CONFIG = 0x31, - - GET_GYROSCOPE_CAP = 0x32, - SET_GYROSCOPE_CONFIG = 0x33, - - GET_MAGNETOMETER_CAP = 0x34, - SET_MAGNETOMETER_CONFIG = 0x35, - - GET_EULER_ANGLE_CAP = 0x36, - SET_EULER_ANGLE_CONFIG = 0x37, - - QUATERNION_CAP = 0x38, - QUATERNION_CONFIG = 0x39, - - GET_ROTATION_MATRIX_CAP = 0x3A, - SET_ROTATION_MATRIX_CONFIG = 0x3B, - - GET_GESTURE_CAP = 0x3C, - SET_GESTURE_CONFIG = 0x3D, - - GET_EMG_RAWDATA_CAP = 0x3E, - SET_EMG_RAWDATA_CONFIG = 0x3F, - - GET_MOUSE_DATA_CAP = 0x40, - SET_MOUSE_DATA_CONFIG = 0x41, - - GET_JOYSTICK_DATA_CAP = 0x42, - SET_JOYSTICK_DATA_CONFIG = 0x43, - - GET_DEVICE_STATUS_CAP = 0x44, - SET_DEVICE_STATUS_CONFIG = 0x45, - - GET_EMG_RAWDATA_CONFIG = 0x46, - - SET_DATA_NOTIF_SWITCH = 0x4F, - # Partial command packet, format: [CMD_PARTIAL_DATA, packet number in reverse order, packet content] - MD_PARTIAL_DATA = 0xFF - - -class DataSubscription(IntEnum): - # Data Notify All Off - OFF = 0x00000000, - - # Accelerate On(C.7) - ACCELERATE = 0x00000001, - - # Gyroscope On(C.8) - GYROSCOPE = 0x00000002, - - # Magnetometer On(C.9) - MAGNETOMETER = 0x00000004, - - # Euler Angle On(C.10) - EULERANGLE = 0x00000008, - - # Quaternion On(C.11) - QUATERNION = 0x00000010, - - # Rotation Matrix On(C.12) - ROTATIONMATRIX = 0x00000020, - - # EMG Gesture On(C.13) - EMG_GESTURE = 0x00000040, - - # EMG Raw Data On(C.14) - EMG_RAW = 0x00000080, - - # HID Mouse On(C.15) - HID_MOUSE = 0x00000100, - - # HID Joystick On(C.16) - HID_JOYSTICK = 0x00000200, - - # Device Status On(C.17) - DEVICE_STATUS = 0x00000400, - - # Device Log On - LOG = 0x00000800, - - # Data Notify All On - ALL = 0xFFFFFFFF - - -class DataType(IntEnum): - ACC = 0x01, - GYO = 0x02, - MAG = 0x03, - EULER = 0x04, - QUAT = 0x05, - ROTA = 0x06, - EMG_GEST = 0x07, - EMG_ADC = 0x08, - HID_MOUSE = 0x09, - HID_JOYSTICK = 0x0A, - DEV_STATUS = 0x0B, - LOG = 0x0C, - - PARTIAL = 0xFF - - -class SampleResolution(IntEnum): - BITS_8 = 8, - BITS_12 = 12 - - -class SamplingRate(IntEnum): - HZ_500 = 500, - HZ_650 = 650, - HZ_1000 = 1000 - - -@dataclass -class EmgRawDataConfig: - fs: SamplingRate = SamplingRate.HZ_1000 - channel_mask: int = 0xFF - batch_len: int = 32 - resolution: SampleResolution = SampleResolution.BITS_8 - - def to_bytes(self): - body = b'' - body += struct.pack(' packet_id: - self.packet_id = packet_id - self.data_packet += bs[2:] - - if self.packet_id == 0: - full_packet = self.data_packet - self.data_packet = [] - else: - full_packet = bs - - if len(full_packet) == 0: - return - - data = None - data_type = DataType(full_packet[0]) - packet = full_packet[1:] - if data_type == DataType.EMG_ADC: - data = self._convert_emg_to_uv(packet) - elif data_type == DataType.ACC: - data = self._convert_acceleration_to_g(packet) - elif data_type == DataType.GYO: - data = self._convert_gyro_to_dps(packet) - elif data_type == DataType.MAG: - data = self._convert_magnetometer_to_ut(packet) - elif data_type == DataType.EULER: - data = self._convert_euler(packet) - elif data_type == DataType.QUAT: - data = self._convert_quaternion(packet) - elif data_type == DataType.ROTA: - data = self._convert_rotation_matrix(packet) - else: - raise Exception(f"Unknown data type {data_type}, full packet: {full_packet}") - - q.put_nowait(data) - - def _convert_emg_to_uv(self, data: bytes): - min_voltage = -1.25 - max_voltage = 1.25 - - if self.resolution == SampleResolution.BITS_8: - dtype = np.uint8 - div = 127.0 - sub = 128 - elif self.resolution == SampleResolution.BITS_12: - dtype = np.uint16 - div = 2047.0 - sub = 2048 - else: - raise Exception(f"Unsupported resolution {self.resolution}") - - gain = 1200.0 - conversion_factor = (max_voltage - min_voltage) / gain / div - - emg_data = (np.frombuffer(data, dtype=dtype).astype(np.float32) - sub) * conversion_factor - num_channels = 8 - - return emg_data.reshape(-1, num_channels) - - @staticmethod - def _convert_acceleration_to_g(data: bytes): - normalizing_factor = 65536.0 - - acceleration_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor - num_channels = 3 - - return acceleration_data.reshape(-1, num_channels) - - @staticmethod - def _convert_gyro_to_dps(data: bytes): - normalizing_factor = 65536.0 - - gyro_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor - num_channels = 3 - - return gyro_data.reshape(-1, num_channels) - - @staticmethod - def _convert_magnetometer_to_ut(data: bytes): - normalizing_factor = 65536.0 - - magnetometer_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) / normalizing_factor - num_channels = 3 - - return magnetometer_data.reshape(-1, num_channels) - - @staticmethod - def _convert_euler(data: bytes): - - euler_data = np.frombuffer(data, dtype=np.float32).astype(np.float32) - num_channels = 3 - - return euler_data.reshape(-1, num_channels) - - @staticmethod - def _convert_quaternion(data: bytes): - - quaternion_data = np.frombuffer(data, dtype=np.float32).astype(np.float32) - num_channels = 4 - - return quaternion_data.reshape(-1, num_channels) - - @staticmethod - def _convert_rotation_matrix(data: bytes): - - rotation_matrix_data = np.frombuffer(data, dtype=np.int32).astype(np.float32) - num_channels = 9 - - return rotation_matrix_data.reshape(-1, num_channels) - - @staticmethod - def _convert_emg_gesture(data: bytes): - - emg_gesture_data = np.frombuffer(data, dtype=np.int16).astype(np.float16) - num_channels = 6 - - return emg_gesture_data.reshape(-1, num_channels) - - def _on_cmd_response(self, _: BleakGATTCharacteristic, bs: bytearray): - try: - response = self._parse_response(bytes(bs)) - if response.cmd in self.responses: - self.responses[response.cmd].put_nowait( - response.data, - ) - except Exception as e: - raise Exception("Failed to parse response: %s" % e) - - @staticmethod - def _parse_response(res: bytes): - code = int.from_bytes(res[:1], byteorder='big') - code = ResponseCode(code) - - cmd = int.from_bytes(res[1:2], byteorder='big') - cmd = Command(cmd) - - data = res[2:] - - return Response( - code=code, - cmd=cmd, - data=data, - ) - - async def get_protocol_version(self): - buf = await self._send_request(Request( - cmd=Command.GET_PROTOCOL_VERSION, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_feature_map(self): - buf = await self._send_request(Request( - cmd=Command.GET_FEATURE_MAP, - has_res=True, - )) - return int.from_bytes(buf, byteorder='big') # TODO: check if this is correct - - async def get_device_name(self): - buf = await self._send_request(Request( - cmd=Command.GET_DEVICE_NAME, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_firmware_revision(self): - buf = await self._send_request(Request( - cmd=Command.GET_FW_REVISION, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_hardware_revision(self): - buf = await self._send_request(Request( - cmd=Command.GET_HW_REVISION, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_model_number(self): - buf = await self._send_request(Request( - cmd=Command.GET_MODEL_NUMBER, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_serial_number(self): - buf = await self._send_request(Request( - cmd=Command.GET_SERIAL_NUMBER, - has_res=True, - )) - return buf.decode('utf-8') - - async def get_manufacturer_name(self): - buf = await self._send_request(Request( - cmd=Command.GET_MANUFACTURER_NAME, - has_res=True, - )) - - return buf.decode('utf-8') - - async def get_bootloader_version(self): - buf = await self._send_request(Request( - cmd=Command.GET_BOOTLOADER_VERSION, - has_res=True, - )) - - return buf.decode('utf-8') - - async def get_battery_level(self): - buf = await self._send_request(Request( - cmd=Command.GET_BATTERY_LEVEL, - has_res=True, - )) - return int.from_bytes(buf, byteorder='big') - - async def get_temperature(self): - buf = await self._send_request(Request( - cmd=Command.GET_TEMPERATURE, - has_res=True, - )) - return int.from_bytes(buf, byteorder='big') - - async def power_off(self): - await self._send_request(Request( - cmd=Command.POWEROFF, - has_res=False, - )) - - async def switch_to_oad(self): - await self._send_request(Request( - cmd=Command.SWITCH_TO_OAD, - has_res=False, - )) - - async def system_reset(self): - await self._send_request(Request( - cmd=Command.SYSTEM_RESET, - has_res=False, - )) - - async def switch_service(self): - await self._send_request(Request( - cmd=Command.SWITCH_SERVICE, - has_res=False, - )) - - async def set_motor(self): # TODO: check if this works and what it does - await self._send_request(Request( - cmd=Command.MOTOR_CONTROL, - has_res=True, - )) - - async def set_led(self): # TODO: check if this works and what it does - await self._send_request(Request( - cmd=Command.LED_CONTROL_TEST, - has_res=True, - )) - - async def set_log_level(self): - await self._send_request(Request( - cmd=Command.SET_LOG_LEVEL, - has_res=False, - )) - - async def set_log_module(self): - await self._send_request(Request( - cmd=Command.SET_LOG_MODULE, - has_res=False, - )) - - async def print_kernel_msg(self): - await self._send_request(Request( - cmd=Command.PRINT_KERNEL_MSG, - has_res=True, - )) - - async def set_package_id(self): - await self._send_request(Request( - cmd=Command.PACKAGE_ID_CONTROL, - has_res=False, - )) - - async def send_training_package(self): - await self._send_request(Request( - cmd=Command.SEND_TRAINING_PACKAGE, - has_res=False, - )) - - async def set_emg_raw_data_config(self, cfg=EmgRawDataConfig()): - body = cfg.to_bytes() - await self._send_request(Request( - cmd=Command.SET_EMG_RAWDATA_CONFIG, - body=body, - has_res=True, - )) - self.resolution = cfg.resolution - - async def get_emg_raw_data_config(self): - buf = await self._send_request(Request( - cmd=Command.GET_EMG_RAWDATA_CONFIG, - has_res=True, - )) - return EmgRawDataConfig.from_bytes(buf) - - async def set_subscription(self, subscription: DataSubscription): - body = [0xFF & subscription, 0xFF & (subscription >> 8), 0xFF & (subscription >> 16), - 0xFF & (subscription >> 24)] - body = bytes(body) - await self._send_request(Request( - cmd=Command.SET_DATA_NOTIF_SWITCH, - body=body, - has_res=True, - )) - - async def start_streaming(self): - q = Queue() - await self.client.start_notify( - DATA_NOTIFY_CHAR_UUID, - lambda _, data: self._on_data_response(q, data), - ) - return q - - async def stop_streaming(self): - exceptions = [] - try: - await self.set_subscription(DataSubscription.OFF) - except Exception as e: - exceptions.append(e) - try: - await self.client.stop_notify(DATA_NOTIFY_CHAR_UUID) - except Exception as e: - exceptions.append(e) - try: - await self.client.stop_notify(CMD_NOTIFY_CHAR_UUID) - except Exception as e: - exceptions.append(e) - - if len(exceptions) > 0: - raise Exception("Failed to stop streaming: %s" % exceptions) - - async def disconnect(self): - with suppress(asyncio.CancelledError): - await self.client.disconnect() - - def _get_response_channel(self, cmd: Command): - q = Queue() - self.responses[cmd] = q - return q - - async def _send_request(self, req: Request): - q = None - if req.has_res: - q = self._get_response_channel(req.cmd) - - bs = bytes([req.cmd]) - if req.body is not None: - bs += req.body - await self.client.write_gatt_char(CMD_NOTIFY_CHAR_UUID, bs) - - if not req.has_res: - return None - - return await asyncio.wait_for(q.get(), 5) - - def run(self): - asyncio.run(self.start_stream()) - - async def start_stream(self): - for item in self.shared_memory_items: - self.smm.create_variable(*item) - await self.connect() - await self.set_emg_raw_data_config(self.emg_conf) - await self.set_subscription( - DataSubscription.EMG_RAW - ) - print("Connected to Oymotion Cuff!") - - q = await self.start_streaming() - while True: - if self.signal.is_set(): - self.cleanup() - break - try: - for e in await q.get(): - emg = np.expand_dims(np.array(e),0) - self.smm.modify_variable("emg", lambda x: np.vstack((emg, x))[:x.shape[0],:]) - self.smm.modify_variable("emg_count", lambda x: x + emg.shape[0]) - - except: - print("Worker Stopped.") - quit() - - def cleanup(self): - self.disconnect() - print("Oymotion has disconnected.") \ No newline at end of file diff --git a/libemg/_streamers/_sifi_bridge_streamer.py b/libemg/_streamers/_sifi_bridge_streamer.py index eea7735b..0a1cce88 100644 --- a/libemg/_streamers/_sifi_bridge_streamer.py +++ b/libemg/_streamers/_sifi_bridge_streamer.py @@ -1,15 +1,123 @@ -import os -import requests -from libemg.shared_memory_manager import SharedMemoryManager -from multiprocessing import Process, Event, Lock -import subprocess -import json +from multiprocessing import Process, Event +import time import numpy as np -import shutil -import json -from semantic_version import Version from collections.abc import Callable -from platform import system + +import sifi_bridge_py as sbp + +from libemg.shared_memory_manager import SharedMemoryManager + + +# Sampling rates (Hz) accepted by the SiFi hardware for each modality. +ECG_SAMPLING_RATES = (250, 500, 1000, 2000) +EMG_SAMPLING_RATES = (500, 1000, 2000) +EMG_SAMPLING_RATES_BIOARMBAND = (500, 1000, 1600, 2000) # 1600 Hz is BioArmband only +EDA_SAMPLING_RATES = (4, 8, 16, 32, 50) +IMU_SAMPLING_RATES = (25, 50, 100, 200) +PPG_SAMPLING_RATES = (50, 100, 200, 400, 800) +PPG_AVERAGING_FACTORS = (1, 2, 4, 8, 16, 32) +PPG_MAX_EFFECTIVE_RATE = 400 # sps / avg must not exceed this +TEMPERATURE_SAMPLING_RATES = (0.1, 1, 2, 10) + +# Seconds without a single data packet before the link is presumed down. Every +# modality packetizes far faster than this even at its lowest supported rate, so +# a gap this long is a dropped connection rather than a quiet stream. +DATA_STALL_TIMEOUT = 1.0 +# Seconds to let a quiet link settle before trying to recover it. Windows goes +# on reporting the device connected for several seconds after it stops sending, +# while the GATT objects behind that connection are already closed; a connect +# inside that window hands service discovery those stale handles, which fails +# with E_ILLEGAL_METHOD_CALL and leaves a session that reports connected and +# delivers nothing. Recovery cannot succeed sooner than this, so waiting costs +# nothing and avoids poisoning the attempt that can. +STALL_GRACE_PERIOD = 8.0 +# How long one recovery attempt waits for the device to start advertising again. +# Having dropped the link, this device stays dark for a minute or more; there is +# nothing to connect to until it is back. +ADVERTISING_WAIT_TIMEOUT = 180.0 +# Seconds between scans while waiting for the device to reappear. +ADVERTISING_POLL_INTERVAL = 5.0 +# Seconds between reports of samples the device could not deliver. Losses tend +# to be continuous rather than one-off, so this is summarised, not printed per +# packet. +LOSS_REPORT_INTERVAL = 10.0 +# Fraction of the configured sampling rate a modality has to reach before its +# throughput is treated as healthy. Loose enough to ignore packetisation jitter +# and the ramp-up of the first seconds, tight enough to catch a modality +# arriving at a fraction of what was asked for. +STREAM_RATE_TOLERANCE = 0.9 +# Highest 8-channel EMG rate a BioArmband BLE link was measured to carry without +# loss. Above this the device samples faster than the link can drain: sifibridge +# fills the shortfall with empty rows and counts them as samples_lost, so the +# configured rate is still reported while a growing fraction of the signal is +# simply absent. Measured on this device family at 500/1000/1600/2000 Hz -- +# lossless at 1000 and below, ~35% short at 1600, ~40% at 2000. It is a property +# of the link, not a hardware limit, so it is a warning rather than a cap. +BIOARMBAND_SUSTAINABLE_EMG_FS = 1000 +# IMU rate that was observed to deliver no data at all on BioArmband firmware +# v5, and to leave the IMU silent afterwards: once the device has been +# configured to it, no reconfiguration recovers the sensor -- not a plain +# rewrite of the rate, not disabling and re-enabling it, not stepping back down +# through the supported rates, not reconnecting. Only a power cycle does. It is +# a documented-supported value, so it stays accepted, but not silently. +IMU_RATE_KNOWN_TO_SILENCE_THE_SENSOR = 200 +# Cap on stderr lines read per drain, so a bridge flooding its stderr cannot +# starve the recovery it was supposed to explain. +MAX_STDERR_LINES_PER_DRAIN = 200 + + +def _validate_setting(name: str, value, allowed): + """Raise a ValueError if value is not one of the hardware-supported settings.""" + if value not in allowed: + raise ValueError( + f"Invalid {name} of {value}. Must be one of {list(allowed)}." + ) + return value + + +def validate_sifi_sampling_rates( + ecg_fs, + emg_fs, + eda_fs, + imu_fs, + ppg_sps, + ppg_avg, + temperature_fs, + bioarmband: bool = False, +): + """ + Check every SiFi sampling rate against the values the hardware supports. + + Parameters + ---------- + bioarmband : bool + Whether the target device is a BioArmband, which additionally supports a + 1600 Hz EMG sampling rate. + + Raises + ------ + ValueError + If any setting is unsupported. + """ + _validate_setting("ECG sampling rate", ecg_fs, ECG_SAMPLING_RATES) + _validate_setting( + "EMG sampling rate", + emg_fs, + EMG_SAMPLING_RATES_BIOARMBAND if bioarmband else EMG_SAMPLING_RATES, + ) + _validate_setting("EDA sampling rate", eda_fs, EDA_SAMPLING_RATES) + _validate_setting("IMU sampling rate", imu_fs, IMU_SAMPLING_RATES) + _validate_setting("PPG sampling rate", ppg_sps, PPG_SAMPLING_RATES) + _validate_setting("PPG averaging factor", ppg_avg, PPG_AVERAGING_FACTORS) + if ppg_sps / ppg_avg > PPG_MAX_EFFECTIVE_RATE: + raise ValueError( + f"Invalid PPG configuration: sps of {ppg_sps} with an averaging factor of " + f"{ppg_avg} gives an effective sampling rate of {ppg_sps / ppg_avg} Hz, " + f"which exceeds the maximum of {PPG_MAX_EFFECTIVE_RATE} Hz." + ) + _validate_setting( + "temperature sampling rate", temperature_fs, TEMPERATURE_SAMPLING_RATES + ) class SiFiBridgeStreamer(Process): @@ -21,9 +129,8 @@ class SiFiBridgeStreamer(Process): Parameters ---------- - - version : str - The version of the devie ('1_1 for bioarmband, 1_2 or 1_3 for biopoint). + name : sifi_bridge_py.DeviceType | None + The name of the devie (eg BioArmband, BioPoint_v1_2, BioPoint_v1_3, etc.). None to auto-connect to any device. shared_memory_items : list Shared memory configuration parameters for the streamer in format: ["tag", (size), datatype, Lock()]. @@ -37,411 +144,773 @@ class SiFiBridgeStreamer(Process): Turn IMU modality on or off. ppg : bool Turn PPG modality on or off - notch_on : bool - Turn on-system EMG notch filtering on or off. - notch_freq : int - Specify the frequency of the on-system notch filter. - emgfir_on : bool - Turn on-system EMG bandpass filter on or off. - emg_fir : list - The cutoff frequencies of the on-system bandpass filter. - eda_cfg : bool - Turn EDA into high sampling frequency mode (Bioimpedance at high frequency). - fc_lp : int - Bioimpedance bandpass low cutoff frequency. - fc_hp : int - Bioimpedance bandpass upper cutoff frequency. - freq : int - EDA/Bioimpedance sampling frequency. + filtering, default = True + Enable on-device filtering, including bandpass filters and notch filters. + emg_notch_freq, default = 60 + EMG notch filter frequency, useful for eliminating Mains power interference. Can be {None, 50, 60} Hz. + emg_bandpass: tuple + The (lower, higher) cutoff frequencies of the on-system EMG bandpass filter. + eda_bandpass: tuple + The (lower, higher) cutoff frequencies of the on-system EDA/BIOZ bandpass filter. + eda_freq : int + EDA/Bioimpedance injected signal frequency. 0 for DC. streaming : bool - Reduce latency by joining packets of different modalities together. - bridge_version : str - Version of sifi bridge to use for PIPE. - mac : str + Reduce latency by joining packets of different modalities together + (sifibridge's low-latency mode). + night_mode : bool + Turn the device LEDs off during acquisition. + high_gain : bool + Use more of the ECG/EMG ADC's dynamic range, at the cost of saturating + more easily. + mac : str | None MAC address of the device to be connected with. - + ecg_fs : int + ECG sampling rate (Hz). Can be {250, 500, 1000, 2000}. + emg_fs : int + EMG sampling rate (Hz). Can be {500, 1000, 2000}, plus 1600 on the BioArmband. + eda_fs : int + EDA sampling rate (Hz). Can be {4, 8, 16, 32, 50}. + imu_fs : int + IMU sampling rate (Hz). Can be {25, 50, 100, 200}. + ppg_sps : int + PPG sampling rate (Hz). Can be {50, 100, 200, 400, 800}. + ppg_avg : int + PPG averaging factor. Can be {1, 2, 4, 8, 16, 32}. The effective sampling rate + (ppg_sps / ppg_avg) must be <= 400 Hz. + temperature_fs : float + Temperature sampling rate (Hz). Can be {0.1, 1, 2, 10}. + bioarmband : bool | None + Whether the target device is a BioArmband, which additionally supports a + 1600 Hz EMG sampling rate. None infers this from the device name. + """ - def __init__(self, - version: str = '1_2', - shared_memory_items: list = [], - ecg: bool = False, - emg: bool = True, - eda: bool = False, - imu: bool = False, - ppg: bool = False, - notch_on: bool = True, - notch_freq: int = 60, - emgfir_on: bool = True, - emg_fir: list = [20, 450], - eda_cfg: bool = True, - fc_lp: int = 0, # low pass eda - fc_hp: int = 5, # high pass eda - freq: int = 250,# eda sampling frequency - streaming: bool = False, - bridge_version: str | None = None, - mac: str | None = None): - + + def __init__( + self, + name: str | None = None, + shared_memory_items: list = [], + ecg: bool = False, + emg: bool = True, + eda: bool = False, + imu: bool = False, + ppg: bool = False, + filtering: bool = True, + emg_notch_freq: int = 60, + emg_bandpass: tuple = (20, 450), + eda_bandpass: tuple = (0, 5), + eda_freq: int = 0, + streaming: bool = True, + night_mode: bool = False, + high_gain: bool = False, + mac: str | None = None, + ecg_fs: int = 500, + emg_fs: int = 2000, + eda_fs: int = 50, + imu_fs: int = 50, + ppg_sps: int = 50, + ppg_avg: int = 1, + temperature_fs: float = 1, + bioarmband: bool | None = None, + ): + Process.__init__(self, daemon=True) - self.connected=False + self.connected = False self.signal = Event() self.shared_memory_items = shared_memory_items + # What arrived and what the device said it could not transmit, per + # packet type. Tracked so a link delivering less than was configured is + # visible instead of silently shortening the recording (see + # _track_stream). + self._lost_samples = {} + self._received_samples = {} + self._stream_started = None + self._last_loss_report = 0.0 + self.emg_handlers = [] self.imu_handlers = [] self.eda_handlers = [] self.ecg_handlers = [] self.ppg_handlers = [] - - self.prepare_config_message(ecg, emg, eda, imu, ppg, - notch_on, notch_freq, emgfir_on, emg_fir, - eda_cfg, fc_lp, fc_hp, freq, streaming) - self.prepare_connect_message(version, mac) - self.prepare_executable(bridge_version) - - - - def prepare_config_message(self, - ecg: bool = False, - emg: bool = True, - eda: bool = False, - imu: bool = False, - ppg: bool = False, - notch_on: bool = True, - notch_freq: int = 60, - emgfir_on: bool = True, - emg_fir: list = [20, 450], - eda_cfg: bool = True, - fc_lp: int = 0, # low pass eda - fc_hp: int = 5, # high pass eda - freq: int = 250,# eda sampling frequency - streaming: bool = False,): - self.config_message = "-s ch " + str(int(ecg)) +","+str(int(emg))+","+str(int(eda))+","+str(int(imu))+","+str(int(ppg)) - if notch_on or emgfir_on: - self.config_message += " enable_filters 1 " - if notch_on: - self.config_message += " emg_notch " + str(notch_freq) - else: - self.config_message += " emg_notch 0" - if emgfir_on: - self.config_message += " emg_fir " + str(emg_fir[0]) + "," + str(emg_fir[1]) + "" - else: - self.config_message += " enable_filters 0" - - if eda_cfg: - self.config_message += " eda_cfg " + str(int(fc_lp)) + "," + str(int(fc_hp)) + "," + str(int(freq)) - - if streaming: - self.config_message += " data_mode 1" - - self.config_message += " tx_power 2" - self.config_message += "\n" - self.config_message = bytes(self.config_message,"UTF-8") - - def prepare_connect_message(self, - version: str, - mac : str): - if mac is not None: - self.connect_message = '-c ' + str(mac) + '\n' - else: - self.connect_message = '-c BioPoint_v' + str(version) + '\n' - self.connect_message = bytes(self.connect_message,"UTF-8") - - def prepare_executable(self, - bridge_version: str): - pltfm = system() - self.executable = f"sifi_bridge%s-{pltfm.lower()}" + ( - ".exe" if pltfm == "Windows" else "" + self.temperature_handlers = [] + + + self.device_name = name + self.ecg = ecg + self.emg = emg + self.eda = eda + self.imu = imu + self.ppg = ppg + self.filtering = filtering + self.emg_notch_freq = emg_notch_freq + self.emg_bandpass = emg_bandpass + self.eda_bandpass = eda_bandpass + self.eda_freq = eda_freq + self.streaming = streaming + self.night_mode = night_mode + self.high_gain = high_gain + self.mac = mac + # 1600 Hz EMG is only available on the BioArmband. When the caller doesn't say + # which device this is, fall back to identifying an armband by its name. + self.bioarmband = ( + bool(name is not None and "armband" in str(name).lower()) + if bioarmband is None + else bioarmband + ) + validate_sifi_sampling_rates( + ecg_fs, + emg_fs, + eda_fs, + imu_fs, + ppg_sps, + ppg_avg, + temperature_fs, + self.bioarmband, ) - if bridge_version is None: - # Find the latest upstream version + if imu and imu_fs == IMU_RATE_KNOWN_TO_SILENCE_THE_SENSOR: + print( + f"LibEMG -> SiFiBridgeStreamer (an IMU rate of {imu_fs} Hz delivered no data " + "at all on the firmware this was tested against, and left the IMU silent " + "until the device was power cycled -- reconfiguring it does not bring the " + "sensor back. Use 100 Hz or lower unless you have confirmed your firmware " + "handles it.)" + ) + if emg and self.bioarmband and emg_fs > BIOARMBAND_SUSTAINABLE_EMG_FS: + print( + f"LibEMG -> SiFiBridgeStreamer (an EMG rate of {emg_fs} Hz is above the " + f"{BIOARMBAND_SUSTAINABLE_EMG_FS} Hz this link was measured to carry across 8 " + "channels; the device will sample faster than it can send and the shortfall " + "is dropped, not slowed. Watch for the samples-lost report below, and lower " + "emg_fs if it appears.)" + ) + self.ecg_fs = ecg_fs + self.emg_fs = emg_fs + self.eda_fs = eda_fs + self.imu_fs = imu_fs + self.ppg_sps = ppg_sps + self.ppg_avg = ppg_avg + self.temperature_fs = temperature_fs + # connecting to device can either have a string for the name, device class, or mac address. If None is provided, it autoconnects. + self.handle = self.mac if self.mac is not None else self.device_name + + def configure( + self, + ecg: bool = False, + emg: bool = True, + eda: bool = False, + imu: bool = False, + ppg: bool = False, + filtering: bool = True, + notch_freq: int = 60, + emg_bandpass: tuple = (20, 450), + eda_bandpass: tuple = (0, 5), + eda_freq: int = 0, + streaming: bool = True, + night_mode: bool = False, + high_gain: bool = False, + ): + """Write the whole device configuration, with the device silenced. + + Status updates are turned off for the duration. The device keeps + streaming status packets across disconnects, and sifibridge syncs its + view of the device from them; one landing mid-push snapshots whatever + the device was still running and undoes settings just written. Quiet, + configure, resume leaves nothing to race. + """ + self.sb.set_status_updates(False) + try: + self._configure_sensors(ecg, emg, eda, imu, ppg, filtering, notch_freq, + emg_bandpass, eda_bandpass, eda_freq, streaming, + night_mode, high_gain) + finally: + # Resume even if a command failed, or the device is left mute and + # nothing downstream can tell why. + self.sb.set_status_updates(True) + + def _configure_sensors( + self, + ecg, + emg, + eda, + imu, + ppg, + filtering, + notch_freq, + emg_bandpass, + eda_bandpass, + eda_freq, + streaming, + night_mode, + high_gain, + ): + self.sb.configure_sensors(ecg, emg, eda, imu, ppg) + + if ecg: + self.sb.configure_ecg(fs=self.ecg_fs, + dc_notch=filtering, + mains_notch=notch_freq, + bandpass=filtering, + flo=0, + fhi=30) + + if emg: + self.sb.configure_emg(fs=self.emg_fs, + dc_notch=filtering, + mains_notch=notch_freq, + bandpass=filtering, + flo=emg_bandpass[0], + fhi=emg_bandpass[1]) + + + + if eda: + self.sb.configure_eda(fs=self.eda_fs, + dc_notch=filtering, + mains_notch=notch_freq, + bandpass=filtering, + flo=eda_bandpass[0], + fhi=eda_bandpass[1],) + + if imu: + self.sb.configure_imu(fs=self.imu_fs) + + if ppg: + self.sb.configure_ppg(sps=self.ppg_sps, avg=self.ppg_avg) + + self.sb.configure_temperature(fs=self.temperature_fs) + + # Every device-level setting is written on every connect, including the + # ones we are happy with the default of. The device retains all of + # these across disconnects, so anything left unwritten is inherited + # from whoever configured the device last -- another tool, or an + # earlier run with different arguments. + self.sb.set_night_mode(night_mode) + self.sb.set_high_gain(high_gain) + # 'streaming' is sifibridge's low-latency mode: the device packs several + # sensors into each BLE packet instead of sending one packet per sensor. + self.sb.set_low_latency_mode(streaming) + self.sb.set_ble_power(sbp.BleTxPower.HIGH) + self.sb.set_memory_mode(sbp.MemoryMode.STREAMING) + + def connect(self, max_attempts: int | None = None): + """Join the device, apply the configuration, and begin sampling. + + Parameters + ---------- + max_attempts : int | None + Give up after this many failed attempts. None retries until the + shutdown signal is raised, which is what the initial connection + wants; recovery bounds it so it can escalate instead of looping. + + Returns + ------- + bool + True once connected and sampling. False if the attempt was + abandoned, either because the shutdown signal was raised or because + max_attempts was reached, so callers can give up instead of + retrying forever against a device that is off or out of range. + """ + attempts = 0 + while True: + attempts += 1 try: - releases = requests.get( - "https://api.github.com/repos/sifilabs/sifi-bridge-pub/releases", - timeout=5, - ).json() - bridge_version = str( - max([Version(release["tag_name"]) for release in releases]) - ) - except Exception: - # Probably some network error, so try to find an existing version - # Expected to find sifi_bridge-V.V.V-platform in the current directory - for file in os.listdir(): - if not file.startswith("sifi_bridge"): - continue - bridge_version = file.split("-")[1].replace(".exe", "") - if bridge_version is None: - raise ValueError( - "Could not fetch from upstream nor find a version of sifi_bridge to use in the current directory." - ) - - self.executable = self.executable % ("-" + bridge_version) + if self.sb.connect(self.handle): + break + reason = "the device did not accept the connection" + except sbp.SifiBridgeError as e: + # sifibridge reports "could not find device" and "Already + # connected" as errors rather than as a False return. Letting + # those propagate kills the streamer process outright, which is + # the opposite of what this retry loop exists for. + reason = str(e) + # Without this check a device that is off or out of range wedges the + # process in a tight retry loop that ignores cleanup requests. + if self.signal.is_set(): + print("LibEMG -> SiFiBridgeStreamer (connect abandoned, stopping).") + return False + if max_attempts is not None and attempts >= max_attempts: + print(f"LibEMG -> SiFiBridgeStreamer (connect failed: {reason}).") + return False + print(f"Could not connect to {self.handle} ({reason}). Retrying.") + + self.connected = True + print("Connected to Sifi device.") + + self.configure( + self.ecg, + self.emg, + self.eda, + self.imu, + self.ppg, + self.filtering, + self.emg_notch_freq, + self.emg_bandpass, + self.eda_bandpass, + self.eda_freq, + self.streaming, + self.night_mode, + self.high_gain, + ) - if self.executable not in os.listdir(): - ext = ".zip" if pltfm == "Windows" else ".tar.gz" - arch = None - if pltfm == "Linux": - arch = "x86_64-unknown-linux-gnu" + self.sb.stop() + self.sb.start() + self.sb.clear_data_buffer() + self._reset_stream_tracking() + return True + + def _rebuild_bridge(self): + """Replace the sifibridge subprocess. Does not connect. + + A BLE-level reconnect is not enough. ``SifiBridge`` opens its data socket + and starts the thread that reads from it once, in its constructor; if + that socket closes (bridge subprocess died, host dropped the connection) + the reader thread exits and no packet ever reaches the queue again. And + on Windows the OS caches the GATT service objects of the dead + connection, so rediscovery on the same bridge gets back handles that are + already closed. A fresh process resolves the device from scratch, which + is the only path measured to restore the stream. + """ + print("LibEMG -> SiFiBridgeStreamer (rebuilding bridge).") + try: + self.sb.close() + except Exception as e: + # Already-dead bridges raise here; the replacement matters, not this. + print(f"LibEMG -> SiFiBridgeStreamer (error closing old bridge: {e}).") + self.sb = sbp.SifiBridge() + self.sb._DEFAULT_REQUEST_TIMEOUT = 10.0 + + def _wait_for_device(self): + """Block until a SiFi device is advertising, or shutdown is requested. + + Connect attempts made while the device is dark all fail, each costing a + scan and a round trip, and they bury the one message that matters in + noise. Scan results are already filtered to SiFi hardware by the bridge, + so anything in the list is worth a connect attempt. + + Returns + ------- + bool + True if a device is advertising, False on shutdown or timeout. + """ + deadline = time.time() + ADVERTISING_WAIT_TIMEOUT + waiting_announced = False + while time.time() < deadline: + if self.signal.is_set(): + return False + try: + devices = self.sb.list_devices("ble") + except Exception as e: + print(f"LibEMG -> SiFiBridgeStreamer (scan failed: {e}).") + devices = [] + if devices: print( - "Please run in the terminal to indicate this is an executable file! You only need to do this once." + "LibEMG -> SiFiBridgeStreamer (device is advertising again after " + f"{ADVERTISING_WAIT_TIMEOUT - (deadline - time.time()):.0f}s)." ) - elif pltfm == "Darwin": - arch = "aarch64-apple-darwin" - elif pltfm == "Windows": - arch = "x86_64-pc-windows-gnu" - - # Get Github releases - releases = requests.get( - "https://api.github.com/repos/sifilabs/sifi-bridge-pub/releases", - timeout=5, - ).json() - - # Extract the release matching the requested version - release_idx = [release["tag_name"] for release in releases].index( - bridge_version - ) - assets = releases[release_idx]["assets"] - - # Find the asset that matches the architecture - archive_url = None - for asset in assets: - asset_name = asset["name"] - if arch not in asset_name: - continue - archive_url = asset["browser_download_url"] - if not archive_url: - ValueError(f"No upstream version found for {self.executable}") - print(f"Fetching sifi_bridge from {archive_url}") - - # Fetch and write to disk as a zip file - r = requests.get(archive_url) - zip_path = "sifi_bridge" + ext - with open(zip_path, "wb") as file: - file.write(r.content) - - # Unpack & delete the archive - shutil.unpack_archive(zip_path, "./") - os.remove(zip_path) - extracted_path = f"sifi_bridge-{bridge_version}-{arch}/" - for file in os.listdir(extracted_path): - if not file.startswith("sifi_bridge"): - continue - shutil.move(extracted_path + file, f"./{self.executable}") - shutil.rmtree(extracted_path) - - - - def start_pipe(self): - # note, for linux you may need to use sudo chmod +x sifi_bridge_linux - self.proc = subprocess.Popen( - ["./" + self.executable], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, + return True + if not waiting_announced: + waiting_announced = True + print( + "LibEMG -> SiFiBridgeStreamer (waiting for the device to advertise " + "again; it stays dark for a while after dropping the link)." + ) + time.sleep(ADVERTISING_POLL_INTERVAL) + print( + "LibEMG -> SiFiBridgeStreamer (no device advertised within " + f"{ADVERTISING_WAIT_TIMEOUT:.0f}s)." ) - - assert self.proc is not None - - - def connect(self): - while not self.connected: - self.proc.stdin.write(self.connect_message) - self.proc.stdin.flush() - - ret = self.proc.stdout.readline().decode() - - dat = json.loads(ret) - - if dat["connected"] == 1: - self.connected = True - print("Connected to Sifi device.") - else: - print("Could not connect. Retrying.") - # Setup channels - self.proc.stdin.write(self.config_message) - self.proc.stdin.flush() - - self.proc.stdin.write(b'-cmd 1\n') - self.proc.stdin.flush() - self.proc.stdin.write(b'-cmd 0\n') - self.proc.stdin.flush() - - def add_emg_handler(self, - closure: Callable): + return False + + def _drain_bridge_diagnostics(self): + """Print anything sifibridge wrote to stderr, and return the lines. + + sifibridge explains link failures on its own stderr, which its Python + wrapper buffers in a queue that is only ever drained inside ``connect()``. + Nothing reads it while streaming, so the one message that says *why* a + recording stopped is discarded. Draining it here turns a bare stall into + an actionable reason, and keeps the unbounded queue from growing for the + length of a long session. + """ + lines = [] + stderr_queue = getattr(self.sb, "_stderr_queue", None) + if stderr_queue is None: + return lines + for _ in range(MAX_STDERR_LINES_PER_DRAIN): + try: + lines.append(str(stderr_queue.get_nowait()).strip()) + except Exception: + # Empty, or a bridge object that does not expose this queue. + break + for line in lines: + if line: + print(f"LibEMG -> SiFiBridgeStreamer (bridge said: {line})") + return lines + + def _recover_stream(self): + """Rebuild the bridge, wait for the device to return, and reconnect. + + The cheap path -- disconnect and reconnect on the existing bridge -- is + deliberately not attempted. When the link goes quiet without a + disconnect event, sifibridge still counts the device as connected and + answers every connect with "Already connected"; once it does notice, the + OS hands rediscovery the closed GATT objects of the dead connection. In + neither state can a reconnect on that bridge produce data. So: a fresh + process, then wait for the device to advertise, then connect. + + Returns + ------- + bool + Whether the stream is expected to flow again. + """ + self.connected = False + try: + self._rebuild_bridge() + if not self._wait_for_device(): + return False + # Several attempts: the scan only proves some SiFi device is back, + # not that this one has finished settling. + return self.connect(max_attempts=3) + except Exception as e: + print(f"LibEMG -> SiFiBridgeStreamer (recovery attempt failed: {e}).") + return False + + def add_emg_handler(self, closure: Callable): self.emg_handlers.append(closure) - def add_imu_handler(self, - closure: Callable): + def add_imu_handler(self, closure: Callable): self.imu_handlers.append(closure) - def add_ppg_handler(self, - closure: Callable): + def add_ppg_handler(self, closure: Callable): self.ppg_handlers.append(closure) - - def add_ecg_handler(self, - closure: Callable): + + def add_ecg_handler(self, closure: Callable): self.ecg_handlers.append(closure) - - def add_eda_handler(self, - closure: Callable): + + def add_eda_handler(self, closure: Callable): self.eda_handlers.append(closure) - def process_packet(self, - data: str): - packet = np.zeros((14,8)) - if data == "" or data.startswith("sending cmd"): + def add_temperature_handler(self, closure: Callable): + self.temperature_handlers.append(closure) + + def _reset_stream_tracking(self): + """Start the throughput accounting over for a newly opened acquisition. + + Rates are measured against the time the stream has been up. Carrying the + counters across a reconnect would divide one connection's samples by a + span that includes the outage before it, reporting a healthy link as + badly short of its configured rate. + """ + self._lost_samples = {} + self._received_samples = {} + # Left unset: the clock starts when data does, not when connect() + # returns. The gap between the two would otherwise be charged to the + # link as a rate shortfall on every reconnect. + self._stream_started = None + self._last_loss_report = 0.0 + + def _configured_rate(self, packet_type: str): + """The sampling rate we asked for, for a given packet type, or None.""" + return { + "emg_armband": self.emg_fs, + "emg": self.emg_fs, + "ecg": self.ecg_fs, + "eda": self.eda_fs, + "imu": self.imu_fs, + "ppg": self.ppg_sps / self.ppg_avg if self.ppg_avg else self.ppg_sps, + }.get(packet_type) + + def _track_stream(self, packet_type: str, received: int, lost: int): + """Accumulate what actually arrived, and periodically say how it compares. + + Two different things go wrong quietly here. sifibridge reports + ``samples_lost`` when the link could not carry what the device produced, + and leaves None-filled rows in their place; those rows are dropped + below, which closes the gap, so the samples either side of it end up + adjacent and the recording is shortened rather than marked. And a + modality can simply arrive at a fraction of its configured rate with no + loss reported at all, which nothing else here would notice. Comparing + what arrived against what was asked for catches both. + """ + if received: + self._received_samples[packet_type] = ( + self._received_samples.get(packet_type, 0) + received + ) + if lost > 0: + self._lost_samples[packet_type] = self._lost_samples.get(packet_type, 0) + lost + now = time.time() + if self._stream_started is None: + self._stream_started = now + self._last_loss_report = now return - data = json.loads(data) - + if now - self._last_loss_report < LOSS_REPORT_INTERVAL: + return + elapsed = now - self._stream_started + if elapsed < LOSS_REPORT_INTERVAL: + # Too early to judge a rate; the first packets arrive in a burst. + return + problems = [] + for kind, total in sorted(self._received_samples.items()): + configured = self._configured_rate(kind) + if not configured: + continue + achieved = total / elapsed + missing = self._lost_samples.get(kind, 0) + # Judged on the achieved rate alone. Dropped samples already show up + # as a lower rate, so triggering on the loss counter as well would + # report a link running at 99% every ten seconds for a handful of + # samples, and bury the shortfalls that matter. + if achieved >= STREAM_RATE_TOLERANCE * configured: + continue + note = f"{missing} dropped in transit" if missing else "nothing reported lost" + problems.append( + f"{kind} {achieved:.0f}/{configured:g} Hz " + f"({100 * achieved / configured:.0f}%, {note})" + ) + if not problems: + return + self._last_loss_report = now + print( + "LibEMG -> SiFiBridgeStreamer (receiving less than configured: " + + "; ".join(problems) + + "). Samples that never arrive are absent from the recording rather " + "than marked, so lower the sampling rate or disable modalities if " + "every sample matters." + ) + + def process_packet(self, data: dict): if "data" in list(data.keys()): - if "emg0" in list(data["data"].keys()): # this is multi-channel (armband) emg - emg = np.stack((data["data"]["emg0"], - data["data"]["emg1"], - data["data"]["emg2"], - data["data"]["emg3"], - data["data"]["emg4"], - data["data"]["emg5"], - data["data"]["emg6"], - data["data"]["emg7"] - )).T + packet_type = str(data.get("packet_type", "unknown")) + received = max( + (len(v) for v in data["data"].values() if isinstance(v, list)), + default=0, + ) + self._track_stream( + packet_type, received, int(data.get("samples_lost") or 0) + ) + if "emg0" in list( + data["data"].keys() + ): # this is multi-channel (armband) emg + emg = np.stack( + ( + data["data"]["emg0"], + data["data"]["emg1"], + data["data"]["emg2"], + data["data"]["emg3"], + data["data"]["emg4"], + data["data"]["emg5"], + data["data"]["emg6"], + data["data"]["emg7"], + ) + ).T + if emg.dtype != 'float64': + # A non-float dtype means the packet carried None entries: + # the placeholders sifibridge leaves for samples lost in + # transit. Drop those rows (reported by _track_stream) + # while preserving 2D structure. + emg = emg.astype('float64') + emg = emg[[any(~np.isnan(row)) for row in emg]] + for h in self.emg_handlers: h(emg) # print(data['sample_rate']) - if "emg" in list(data["data"].keys()): # This is the biopoint emg - emg = np.expand_dims(np.array(data['data']["emg"]),0).T + if "emg" in list(data["data"].keys()): # This is the biopoint emg + # print(data["data"]["emg"]) + emg = np.expand_dims(np.array(data["data"]["emg"]), 0).T for h in self.emg_handlers: h(emg) - if "acc_x" in list(data["data"].keys()): - imu = np.stack((data["data"]["acc_x"], - data["data"]["acc_y"], - data["data"]["acc_z"], - data["data"]["w"], - data["data"]["x"], - data["data"]["y"], - data["data"]["z"] - )).T + if "ax" in list(data["data"].keys()): + imu = np.stack( + ( + data["data"]["ax"], + data["data"]["ay"], + data["data"]["az"], + data["data"]["qw"], + data["data"]["qx"], + data["data"]["qy"], + data["data"]["qz"], + ) + ).T for h in self.imu_handlers: h(imu) if "eda" in list(data["data"].keys()): - eda = np.expand_dims(np.array(data['data']['eda']),0).T + eda = np.expand_dims(np.array(data["data"]["eda"]), 0).T for h in self.eda_handlers: h(eda) if "ecg" in list(data["data"].keys()): - ecg = np.stack((data["data"]["ecg"], - )).T + ecg = np.stack((data["data"]["ecg"],)).T for h in self.ecg_handlers: h(ecg) - if "b" in list(data["data"].keys()): - if self.old_ppg_packet is None: - self.old_ppg_packet = data - else: - ppg = np.stack((data["data"]["b"] + self.old_ppg_packet["data"]["b"], - data["data"]["g"] + self.old_ppg_packet["data"]["g"], - data["data"]["r"] + self.old_ppg_packet["data"]["r"], - data["data"]["ir"] + self.old_ppg_packet["data"]["ir"] - )).T - self.old_ppg_packet = None - for h in self.ppg_handlers: - h(ppg) - + if "ir" in list(data["data"].keys()): + ppg = np.array([data["data"]["ir"], data["data"]["r"], data["data"]["g"], data["data"]["b"]]).T + for h in self.ppg_handlers: + h(ppg) + if "temperature" in list(data["data"].keys()): + # Skin temperature rides along in the device's status packet + # (alongside battery and memory use) rather than in a stream of + # its own, so it arrives here whatever modalities are enabled. + temperature = np.expand_dims( + np.array(data["data"]["temperature"], dtype=np.double), 0 + ).T + for h in self.temperature_handlers: + h(temperature) + def run(self): # process is started beyond this point! - self.smm = SharedMemoryManager() + self.sb = sbp.SifiBridge() + self.sb._DEFAULT_REQUEST_TIMEOUT = 10.0 + + self.connect() + + + + self.smm = SharedMemoryManager(notifier_pool=getattr(self, "notifier_pool", None)) for item in self.shared_memory_items: self.smm.create_variable(*item) - self.start_pipe() + def write_emg(emg): - # update the samples in "emg" - self.smm.modify_variable("emg", lambda x: np.vstack((np.flip(emg,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("emg_count", lambda x: x + emg.shape[0]) + # Oldest-first packet, as commit() expects; it prepends and keeps + # "emg_count" in step under one lock. + self.smm.commit("emg", emg) + self.add_emg_handler(write_emg) def write_imu(imu): - # update the samples in "imu" - self.smm.modify_variable("imu", lambda x: np.vstack((np.flip(imu,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("imu_count", lambda x: x + imu.shape[0]) - # sock.sendto(data_arr, (self.ip, self.port)) + # Oldest-first packet, as commit() expects; it prepends and keeps + # "imu_count" in step under one lock. + self.smm.commit("imu", imu) + self.add_imu_handler(write_imu) def write_eda(eda): - # update the samples in "eda" - self.smm.modify_variable("eda", lambda x: np.vstack((np.flip(eda,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("eda_count", lambda x: x + eda.shape[0]) + # Oldest-first packet, as commit() expects; it prepends and keeps + # "eda_count" in step under one lock. + self.smm.commit("eda", eda) + self.add_eda_handler(write_eda) def write_ppg(ppg): - # update the samples in "ppg" - self.smm.modify_variable("ppg", lambda x: np.vstack((np.flip(ppg,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("ppg_count", lambda x: x + ppg.shape[0]) + # Oldest-first packet, as commit() expects; it prepends and keeps + # "ppg_count" in step under one lock. + self.smm.commit("ppg", ppg) + self.add_ppg_handler(write_ppg) def write_ecg(ecg): - # update the samples in "ecg" - self.smm.modify_variable("ecg", lambda x: np.vstack((np.flip(ecg,0), x))[:x.shape[0],:]) - # update the number of samples retrieved - self.smm.modify_variable("ecg_count", lambda x: x + ecg.shape[0]) + # Oldest-first packet, as commit() expects; it prepends and keeps + # "ecg_count" in step under one lock. + self.smm.commit("ecg", ecg) + self.add_ecg_handler(write_ecg) - self.connect() - - self.old_ppg_packet = None # required for now since ppg sends non-uniform packet length + def write_temperature(temperature): + # Oldest-first packet, as commit() expects. + self.smm.commit("temperature", temperature) + + # Unlike every other modality, status packets arrive whether or not the + # caller asked for temperature, so the handler is only wired up when a + # buffer was allocated for it -- otherwise the first status packet would + # write to a variable that does not exist. + if "temperature" in self.smm.variables: + self.add_temperature_handler(write_temperature) + + self.old_ppg_packet = ( + None # required for now since ppg sends non-uniform packet length + ) + # When the stream first went quiet, or None while data is flowing. + stalled_since = None while True: + # Checked first so a stalled or unrecoverable link still shuts down. + if self.signal.is_set(): + self.cleanup() + break try: - data_from_processess = self.proc.stdout.readline().decode() - self.process_packet(data_from_processess) + # A bounded wait is what makes a dropped link observable at all. + # get_data() defaults to blocking forever, and the thread feeding + # its queue exits silently when the data socket closes, so an + # unbounded read parks here for the rest of the session: no + # exception to catch, no samples, and no way back out. + new_packet = self.sb.get_data(timeout=DATA_STALL_TIMEOUT) except Exception as e: print("Error Occurred: " + str(e)) + new_packet = None + + if new_packet: + if stalled_since is not None: + print( + f"LibEMG -> SiFiBridgeStreamer (stream resumed after " + f"~{time.time() - stalled_since:.1f}s gap; samples in " + f"that window are lost)." + ) + stalled_since = None + try: + self.process_packet(new_packet) + except Exception as e: + print("Error Occurred: " + str(e)) continue - if self.signal.is_set(): - self.cleanup() - break + + now = time.time() + if stalled_since is None: + stalled_since = now + continue + quiet_for = now - stalled_since + if quiet_for < STALL_GRACE_PERIOD: + # Still inside the window where the link looks alive but every + # object behind it is dead. Keep reading: a brief hiccup + # resolves itself here, and a real drop cannot be repaired yet. + continue + + # Say so loudly, so a truncated recording is not mistaken for a + # complete one, then go and get the stream back. + print( + f"LibEMG -> SiFiBridgeStreamer (no data for {quiet_for:.1f}s, " + "attempting recovery)." + ) + self._drain_bridge_diagnostics() + if self._recover_stream(): + stalled_since = None + else: + # Restart the clock so the next attempt is a grace period away + # rather than immediate. + stalled_since = time.time() print("LibEMG -> SiFiBridgeStreamer (process ended).") def stop_sampling(self): - self.proc.stdin.write(b'-cmd 1\n') - self.proc.stdin.flush() + self.sb.stop() return def turnoff(self): - self.proc.stdin.write(b'-cmd 13\n') - self.proc.stdin.flush() + """Power the device off.""" + self.sb.power_off() return - + def disconnect(self): - self.proc.stdin.write(b'-d\n') - self.proc.stdin.flush() - while self.connected: - ret = self.proc.stdout.readline().decode() - dat = json.loads(ret) - if 'connected' in dat.keys(): - if dat["connected"] == 0: - self.connected = False + """Drop the BLE link and the sifibridge session that holds it.""" + # sifi_bridge_py returns the connection state directly here. + self.connected = self.sb.disconnect() return self.connected - def deep_sleep(self): - self.proc.stdin.write(b'-cmd 14\n') - self.proc.stdin.flush() - def cleanup(self): - - self.stop_sampling() # stop sampling - print("LibEMG -> SiFiBridgeStreamer (sampling stopped).") - self.deep_sleep() # stops status packets - print("LibEMG -> SiFiBridgeStreamer (device sleeped).") - self.disconnect() # disconnect - print("LibEMG -> SiFiBridgeStreamer (device disconnected).") - self.proc.kill() - print("LibEMG -> SiFiBridgeStreamer (bridge killed).") - self.smm.cleanup() - print("LibEMG -> SiFiBridgeStreamer (SMM cleaned up).") - - def __del__(self): - # self.proc.stdin.write(b"-d -q\n") - # print("LibEMG -> SiFiBridgeStreamer (device disconnected).") - # print("LibEMG -> SiFiBridgeStreamer (bridge killed).") - # self.smm.cleanup() - # print("LibEMG -> SiFiBridgeStreamer (SMM cleaned up).") - pass + # Each step is attempted independently: shutdown often runs with the link + # already down, and letting an early failure propagate would skip the + # shared-memory release and leak the segments past process exit. + # Every callable is wrapped so attribute lookup happens inside the try + # too: a bridge that never finished starting has no _bridge to resolve, + # and that lookup failing here would skip the steps after it. + # Disconnecting is what stops the device streaming, and it has to happen + # before the bridge goes away: killing sifibridge while the link is up + # leaves the device connected to a process that no longer exists, and + # the next session then meets an "Already connected" it cannot clear. + # close() sends 'quit' and waits, rather than killing outright. + steps = ( + (lambda: self.stop_sampling(), "sampling stopped"), + (lambda: self.disconnect(), "device disconnected"), + (lambda: self.sb.close(), "bridge closed"), + (lambda: self.smm.cleanup(), "SMM cleaned up"), + ) + for step, message in steps: + try: + step() + print(f"LibEMG -> SiFiBridgeStreamer ({message}).") + except Exception as e: + print(f"LibEMG -> SiFiBridgeStreamer ({message} failed: {e}).") diff --git a/libemg/adaptation/__init__.py b/libemg/adaptation/__init__.py new file mode 100644 index 00000000..4d02e755 --- /dev/null +++ b/libemg/adaptation/__init__.py @@ -0,0 +1 @@ +from libemg.adaptation import _base, managers, memory \ No newline at end of file diff --git a/libemg/adaptation/_base.py b/libemg/adaptation/_base.py new file mode 100644 index 00000000..3645834e --- /dev/null +++ b/libemg/adaptation/_base.py @@ -0,0 +1,223 @@ +import numpy as np +from multiprocessing import Lock +from libemg.output_writer import SharedMemoryOutputWriter, SocketOutputWriter + +def get_edil_adaptation_objects(num_features, num_outputs): + """ + Get the shared memory items necessary for adaptation. This is configured for environment-dependent incremental learning. + + This config prepares the model_inputs and model_outputs to be available via shared memory, and broadcast model_outputs over a UDP port. + + Parameters + ---------- + num_features : int + Number of features for input in the model. + num_outputs : int + Number of outputs of the model (and returned by the environment). + + Returns + ------- + smi + Shared memory items necessary for adaptation. Pass this to the OnlineStreamer (classifier or regressor) + """ + + # Every sharedmemoryoutputwriter should have a mod_fn that describes how to modify its data. + + # there are two places to grab the model input (pre scaler and post scaler) + # for the setup of my experiments, I've worked with the pre-scaler (non-normalized) model inputs. + def mod_fn_input(self, data, info): + new_slice = np.hstack((info['timestamp'],info['model_input_raw'][-1,:])) + input_size = self.smm.variables['model_input']["shape"][0] + data[:] = np.vstack((new_slice, data))[:input_size, :] + return data + + def mod_fn_output(self, data, info): + new_slice = np.hstack((info['timestamp'],info['model_output'])) + input_size = self.smm.variables['model_output']["shape"][0] + data[:] = np.vstack((new_slice, data))[:input_size, :] + return data + + def mod_fn_env(self, data, info): + new_slice = np.hstack((info['timestamp'], info['trial'], info['environment_feedback'])) + input_size = self.smm.variables['environment_feedback']["shape"][0] + data[:] = np.vstack((new_slice, data))[:input_size, :] + return data + + def mod_fn_flags(self, data, number): + data[:] = number + return data + + def mod_fn_flags_count(self, data, number): + data[:] = data[:] + 1 + return data + + + + adapt_flag_smow = SharedMemoryOutputWriter('adapt_flag', (1,1), np.int32, Lock(), mod_fn=mod_fn_flags, mod_fn_count=mod_fn_flags_count) + memory_flag_smow = SharedMemoryOutputWriter('memory_flag', (1,1), np.int32, Lock(), mod_fn=mod_fn_flags, mod_fn_count=mod_fn_flags_count) + active_flag_smow = SharedMemoryOutputWriter('active_flag', (1,1), np.int8, Lock(), mod_fn=mod_fn_flags, mod_fn_count=mod_fn_flags_count) + environment_flag_smow = SharedMemoryOutputWriter('environment_flag', (1,1), np.int32, Lock(), mod_fn=mod_fn_flags, mod_fn_count=mod_fn_flags_count) + + model_output_smow = SharedMemoryOutputWriter('model_output', (100, 1+num_outputs), np.float64, Lock(), mod_fn=mod_fn_output, mod_fn_count=mod_fn_flags_count) + model_input_smow = SharedMemoryOutputWriter('model_input', (100, 1+num_features), np.float64, Lock(), mod_fn=mod_fn_input, mod_fn_count=mod_fn_flags_count) + environment_feedback_smow = SharedMemoryOutputWriter('environment_feedback', (100, 1+1+num_outputs), np.float64, Lock(), mod_fn=mod_fn_env, mod_fn_count=mod_fn_flags_count) + + model_output_sow = SocketOutputWriter("model_output") + + # initial values for flags + adapt_flag_smow.write(0) + memory_flag_smow.write(0) + active_flag_smow.write(1) + environment_flag_smow.write(1) + + model_output_smow.reset() + model_input_smow.reset() + environment_feedback_smow.reset() + + """ + The model receives messages over shared memory via the adapt tag (to notify when a new model is ready to be loaded). + The model receives messages over the active tag (to notify when a model should halt running). + The model writes inputs out via the model_output and model_input tags via shared memory. + model_output contains a timestamp and DoF outputs + model_input contains a timestamp and model inputs (i.e., features in most cases). + The model also writes out the model_output to a socket (UDP) for the environment to read -- this can be changed in the future to use sharedmemory, but the + RegressionController and ClassifierController are not set up to read from shared memory yet. + """ + model_smi = [ + adapt_flag_smow.smm.get_shared_memory_items()[0], # notify the onlinestreamer to load a new model by this int + active_flag_smow.smm.get_shared_memory_items()[0], # notify the online streamer to pause running with this flag + ] + model_ow = [ + model_output_smow, # <- timestamp, DOF1, DOF2 -> + model_input_smow, # <- timestamp, ---INPUTS--- -> + model_output_sow, + ] + + """ + The environment receives messages over the UDP port from the model to let the user take action during the game loop. This is handled pretty manually within the + environment, but could be made more elegant in the future. The environment_smi is then empty, but provided to keep the interface consistent and extensible. + + For adaptation, the environment writes out messages to shared memory via the environment_feedback tag. This contains a timestamp, trial number, and environment feedback (i.e., reward or pseudolabels). + The environment also has a + """ + environment_smi = [] + environment_ow = [ + environment_feedback_smow, # <- timestamp, TRIAL, ENVIRONMENT FEEDBACK -> + environment_flag_smow, # indicate when the environment is alive + ] + + """ + The adaptation manager receives messages from the memory manager to indicate a new slice of memory is ready (default upon a trial being complete). + The adaptation manager also receives messages from the environment to indicate when the environment is alive (i.e., when it is useful to continue adaptation). + + The adaptation manager writes to the model when a new model is ready to be loaded (i.e., when the model should be updated). + """ + adaptation_manager_smi = [ + memory_flag_smow.smm.get_shared_memory_items()[0], + environment_flag_smow.smm.get_shared_memory_items()[0] + ] + adaptation_manager_ow = [ + adapt_flag_smow + ] + + """ + The memory manager receives messages from the model containing the inputs (e.g., EMG features), that will be used for adaptation later. + The memory manager receives messages from the environment in response to its actions to provide feedback to ends up being a pseudo-label. + The memory manager receives messages from the environment to indicate when the environment is alive (i.e., when it is useful to continue adaptation). + + The memory manager outputs a message to the adaptation_manager which specifies the number of memory slices that have been saved thus far (written to .pkl files). + """ + memory_manager_smi = [ + model_input_smow.smm.get_shared_memory_items()[0], + environment_feedback_smow.smm.get_shared_memory_items()[0], # the feedback itself + environment_feedback_smow.smm.get_shared_memory_items()[1], # the number of times the feedback has been written to (useful for only querying the new stuff to be appended to memory) + environment_flag_smow.smm.get_shared_memory_items()[0], + ] + memory_manager_ow = [ + memory_flag_smow + ] + + return model_smi, model_ow, environment_smi, environment_ow, adaptation_manager_smi, adaptation_manager_ow, memory_manager_smi, memory_manager_ow + +def get_pdil_adaptation_items(): + ... + +def get_uil_adaptation_items(): + ... + +def get_dodr_adaptation_items(): + ... + +def produce_tciil_feedback(current_location: list[int, int], + current_direction: list[int, int], + target_location: list[int, int], + target_size: int, + trial_distance: int): + """ + Example function handle to produce a tolerant context informed incremental learning pseudo-label to be used as feedback from a 2-DoF environment. + This is used in the default implementation of libemg.environment.curricular_environment.CurricularFittsEnvironment. This uses the procedure described + in "Context-Informed Incremental Learning Improves Throughput and Reduces Drift in Regression-Based Myoelectric Control", Morrell et al 2025. + + Parameters + ---------- + current location : list + A 2DoF location where the cursor is located. + target location : list + A 2DoF location where the target is located. + target size : int + The size of the target. + trial_distance : int + The distance between the cursor and target at the start of the trial. + """ + + optimal_direction = get_optimal_direction(current_location, target_location) + + PC = distance_to_proportional_control(current_location, target_location, current_direction, target_size, trial_distance) + quadrant_check = check_quadrants(current_location, current_direction, target_location) + + # silence bad directions + pseudo_label = [val if outcome else 0 for val, outcome in zip(optimal_direction, quadrant_check)] + # scale to correct value + pseudo_label_scale = np.linalg.norm(pseudo_label) + pseudo_label = [val * PC / pseudo_label_scale for val in pseudo_label] + # pseudo_label = [0 if np.isnan(i) else i for i in pseudo_label] # remove NaNs (completely wrong quadrant is set to 0,0) + + return pseudo_label + +def get_optimal_direction(current_location: list[int, int], + target_location: list[int, int]) -> list[int, int]: + return [i - j for i, j in zip(target_location, current_location)] + +def distance_to_proportional_control(current_location, target_location, current_direction, target_size, trial_distance) -> float: + distance = np.linalg.norm(np.array(current_location) - np.array(target_location)) + in_target = distance < target_size + if in_target: + # step_in_dir = [ x + 0.01*y for x,y in zip(current_location, current_direction)] + # if np.linalg.norm(np.array(step_in_dir) - np.array(target_location)) < distance: + # PC = 0.05 # if we're still approaching the center of the target, output speed is 0.1 + # else: + PC = 0 # if we're in the circle, but not approaching the center, output speed is 0 + else: + # trial distance makes sense as the normalizer for most shooting tasks, but sometimes with random distance targets, the new target can be very close + # in which case, the user wouldn't hit the max proportinal control value for that trial. + # it probably makes more sense to just normalize by a consant value + #PC = min(1.41, np.sqrt((distance - target_size)/trial_distance)) + calculated_percentile = (distance - target_size) / 300 + calculated_pc = 0.1+0.9/(1+np.exp(-10*(calculated_percentile-0.5))) + PC = min(1., calculated_pc) + # the gameplay region in CurricularFittsLaw is about 1000 pixels, so any distance greater than half the playable + # area should evoke max speed. + return PC + +def check_quadrants(current_location, current_direction, target_location) -> list[bool, bool]: + margins = [abs(i - j) for i, j in zip(target_location, current_location)] + del_margins = [abs(i - (0.01 * k + j)) for i, j, k in zip(target_location, current_location, current_direction)] + outcome = [] + for i, j in zip(margins, del_margins): + if i > j : + # better after the step + outcome.append(True) + else: + # worse after the step + outcome.append(False) + return outcome diff --git a/libemg/adaptation/hooks.py b/libemg/adaptation/hooks.py new file mode 100644 index 00000000..df497ec4 --- /dev/null +++ b/libemg/adaptation/hooks.py @@ -0,0 +1,474 @@ +"""Hooks for incremental (user-in-the-loop) learning. + +What the adaptation loop is +--------------------------- +Three parties pass work between them while a person is using the system: + +1. The **environment** decides, from what the person did, what the model + *should* have said, and writes that judgement to ``environment_feedback``. +2. The **memory** side pairs each judgement with the model input that produced + it, accumulating a slice per trial and announcing a finished slice on + ``memory_flag``. +3. The **adaptation** side folds finished slices into the model, trains, saves + the new weights and announces them on ``adapt_flag``. + +Then the running predictor picks up the new weights and the cycle closes. + +Every one of those handoffs used to be a poll. :class:`MemoryManager` and +:class:`AdaptationManager` are unthrottled loops, and the memory loop copies +both the feedback buffer and the whole model-input buffer on every pass just to +learn whether the feedback counter moved. The predictor re-read the adaptation +flag on every iteration of its own loop. + +The hooks here express the same three handoffs as reactions. Each stage +declares what it observes and the terms on which a change counts, and the +writes that used to be polled for now announce themselves. The chain is +``environment_feedback`` to ``memory_flag`` to ``adapt_flag``, each stage +waking the next. + +Why the criteria differ +----------------------- +The stages disagree about what a change means, which is the same disagreement +the rest of the reactive layer is built around. + +- Memory is dirty on **any** feedback row, because every judgement the person + produced is data worth keeping. +- Adaptation is dirty on a **completed slice**, not on a row. Training on a + fraction of a trial is worse than waiting for the whole one. +- The predictor is dirty only on a **new model**, which is rare and expensive + to act on, so it must not be confused with either of the above. + +Examples +--------- +>>> from libemg.adaptation.hooks import MemoryHook, AdaptationHook +>>> from libemg.reactive import ReactiveGraph +>>> graph = ReactiveGraph(adaptation_items, log=log) +>>> graph.add(MemoryHook(memory, save_dir='memories/'), executor='memory') +>>> graph.add(AdaptationHook(model, load_dir='memories/', save_dir='models/'), +... executor='adapt') +>>> graph.start() +""" + +import os +import pickle +import time + +import numpy as np + +from libemg.reactive import (DELTA, FULL, STATE, Criterion, Hook, Input, + OnCommit) + + +def ensure_directory(directory): + """Create ``directory`` if it is not already there. + + exist_ok, not a prior existence check. The memory side's save directory and + the adaptation side's load directory are by design the same one, and their + executors start at the same moment, so a check followed by a create lets + both see it missing and the loser die with FileExistsError. + """ + if directory: + os.makedirs(directory, exist_ok=True) + + +class OnNewSlice(Criterion): + """Dirty when the slice counter has advanced past what was consumed. + + The adaptation side's definition of a meaningful change. The counter it + watches is written as a running total of completed trials, so the gap + between it and what this observer has folded in is exactly the number of + slices still to load. Acting on anything less than a completed slice means + training on a fragment of a trial. + """ + + def is_dirty(self, snapshot, memory): + return self._value(snapshot) > memory.samples + + def consume(self, snapshot, memory): + memory.samples = self._value(snapshot) + memory.generation = snapshot.generation + memory.fired_at = time.perf_counter() + memory.epoch = snapshot.epoch + + @staticmethod + def _value(snapshot): + # The flag's own value is carried by the hook, which reads it; from the + # state block all that is visible is that it moved. Generation is the + # conservative stand-in and is what the hook reconciles against. + return snapshot.generation + + def describe(self): + return "OnNewSlice()" + + +class MemoryHook(Hook): + """Assemble training slices from what the environment judged. + + Observes ``environment_feedback`` and, for each judgement, finds the model + input that produced it and appends the pair to a memory. A judgement + carrying a new trial number closes the previous slice: the memory is saved + and ``memory_flag`` is advanced, which is what wakes the adaptation side. + + This is :class:`~libemg.adaptation.managers.MemoryManager` expressed as a + reaction. The behaviour is the same; what changes is that it runs when + feedback arrives instead of asking whether any has, and that asking used to + copy the whole model-input buffer as well as the feedback buffer. + + Parameters + ---------- + memory: Any + The memory object. Needs ``append``, ``save``, ``reset`` and ``__add__``, + matching what :class:`~libemg.adaptation.managers.MemoryManager` expects. + save_dir: str + Where finished slices are written. Must match the adaptation side's + ``load_dir``. + name: str (optional), default='memory' + Hook name, as it appears in the event log. + feedback_tag: str (optional), default='environment_feedback' + The item the environment writes its judgements to. Each row is + ``[timestamp, trial, feedback...]``. + input_tag: str (optional), default='model_input' + The item the predictor writes its inputs to. Each row is + ``[timestamp, features...]``. + flag_tag: str (optional), default='memory_flag' + The item advanced when a slice is finished. + + Notes + ----- + Feedback rows are joined to model inputs by timestamp rather than by + position, because the two are written by different processes at different + rates and their indices do not correspond. A row whose timestamp is not + found is dropped and counted; see :meth:`unmatched`. + """ + + def __init__(self, memory, save_dir, name="memory", + feedback_tag="environment_feedback", input_tag="model_input", + flag_tag="memory_flag"): + super().__init__( + name, + inputs=[ + # Any judgement at all is worth keeping, so this is dirty on a + # single row. + Input(feedback_tag, OnCommit(), mode=DELTA), + # The inputs are looked up by timestamp, not consumed, so this + # is read without being a trigger. + Input(input_tag, _NeverDirty(), mode=FULL), + ], + outputs=[], + # The slice flag is set, not appended to, so this hook writes it + # itself rather than returning it. It still has to be attached in + # the executor's process, which is what declaring it here does. + attach=[flag_tag], + ) + self.memory = memory + self.save_dir = save_dir + self.feedback_tag = feedback_tag + self.input_tag = input_tag + self.flag_tag = flag_tag + self.trial_counter = 1 + self._unmatched = 0 + self._appended = 0 + self._smm = None + self._log = None + + def setup(self, context): + ensure_directory(self.save_dir) + self.memory.reset() + # The slice flag is advanced directly rather than declared as an + # output, because it is a running count that this hook owns rather than + # a buffer of samples the runtime should append to. + self._smm = context.smm + self._log = context.log + + def step(self, data, snapshots): + feedback = data[self.feedback_tag] + if feedback.shape[0] == 0: + return None + inputs = data[self.input_tag] + + for row in feedback: + timestamp, trial = row[0], row[1] + if trial != self.trial_counter: + self._close_slice() + self.trial_counter = trial + match = np.where(inputs[:, 0] == timestamp)[0] + if match.size == 0: + # The predictor's buffer has already rolled past this + # timestamp, so the pair cannot be reconstructed. Dropping it + # silently would look like the person simply produced less + # data, so it is counted instead. + self._unmatched += 1 + continue + self.memory.append(row[2:], inputs[match, 1:], trial - 1) + self._appended += 1 + return None + + def _close_slice(self): + """Save the finished slice and tell the adaptation side it exists.""" + path = os.path.join(self.save_dir, f"memory_{int(self.trial_counter)}.pkl") + self.memory.save(path) + if self._smm is not None: + if self.flag_tag not in self._smm.variables: + # Announcing is the whole point of closing a slice. Silently + # skipping it produced a run where slices piled up on disk and + # nothing downstream ever heard about them, with no error + # anywhere to explain why adaptation never happened. + raise KeyError( + f"'{self.name}' cannot announce a finished slice: shared-memory " + f"item '{self.flag_tag}' is not attached. Declare it on the hook " + "(it is passed to attach=) and in the graph's items." + ) + value = int(self.trial_counter) + self._smm.apply(self.flag_tag, lambda block: value) + self.memory.reset() + + def flush(self): + """Close the slice in progress. Call when a session ends.""" + self._close_slice() + + def unmatched(self): + """How many feedback rows had no model input to pair with. + + Returns + ---------- + int + A non-zero count means the predictor's input buffer is too small + for the delay between a prediction and the environment's judgement + of it, and that training data is being lost. + """ + return self._unmatched + + def appended(self): + """How many feedback and input pairs reached the memory.""" + return self._appended + + +class _NeverDirty(Criterion): + """For an input that is read but never triggers. + + A hook sometimes needs a second item's contents without that item's + arrival being a reason to run. Declaring it with this criterion delivers + the data on every run without ever firing one. + """ + + def is_dirty(self, snapshot, memory): + return False + + def describe(self): + return "NeverDirty()" + + +class AdaptationHook(Hook): + """Fold finished slices into the model and publish the result. + + Observes ``memory_flag``, loads whatever slices have appeared since it last + looked, trains, saves the new weights and advances ``adapt_flag``, which is + what tells a running predictor to swap models. + + This is :class:`~libemg.adaptation.managers.AdaptationManager` expressed as + a reaction, with one deliberate behavioural difference. The manager calls + ``adapt`` on every pass of its loop, including passes where no new memory + arrived, so it retrains continuously on unchanged data and republishes a + model each time. This hook trains when a slice arrives. Pass + ``continuous=True`` for the old behaviour. + + Parameters + ---------- + model: Any + Needs ``adapt``, ``save`` and ``load``. + load_dir: str + Where slices are read from. Must match the memory side's ``save_dir``. + save_dir: str + Where adapted models are written. Must match the predictor's + ``file_path``, since that is where it looks for them. + initial_memory_loc: str or None (optional), default=None + A memory to start from, typically from screen-guided training. Strongly + worth providing: adapting from nothing is unstable early on. + name: str (optional), default='adaptation' + Hook name, as it appears in the event log. + flag_tag: str (optional), default='memory_flag' + The item announcing finished slices. + notify_tag: str (optional), default='adapt_flag' + The item advanced when a new model is ready. + notify: bool (optional), default=True + Whether to announce new models at all. False trains without ever + swapping the running model, which is what an offline comparison wants. + stop_after: int or None (optional), default=None + Stop adapting once this many slices have been folded in. + continuous: bool (optional), default=False + Retrain on every wake rather than only when a slice arrives. + """ + + def __init__(self, model, load_dir, save_dir, initial_memory_loc=None, + name="adaptation", flag_tag="memory_flag", + notify_tag="adapt_flag", notify=True, stop_after=None, + continuous=False): + super().__init__( + name, + inputs=[Input(flag_tag, OnNewSlice(), mode=FULL)], + outputs=[], + attach=[notify_tag], + ) + self.model = model + self.load_dir = load_dir + self.save_dir = save_dir + self.initial_memory_loc = initial_memory_loc + self.flag_tag = flag_tag + self.notify_tag = notify_tag + self.notify = notify + self.stop_after = stop_after + self.continuous = continuous + self.memory = None + self.memory_count = 0 + self.adaptation_count = 0 + # True while a slice was announced but could not be read yet. The + # retry in _load_when_ready normally clears it within the same step; + # this records the case where it did not, so pending() can report it. + self._pending = False + self._started = None + self._smm = None + self._log = None + + def setup(self, context): + ensure_directory(self.save_dir) + ensure_directory(self.load_dir) + # Loaded here rather than in __init__ so the hook stays picklable for a + # spawned executor, and so a large initial memory is not carried across + # the process boundary. + if self.initial_memory_loc is not None: + self.memory = self._load(self.initial_memory_loc) + self._smm = context.smm + self._log = context.log + self._started = time.time() + + def step(self, data, snapshots): + # Checked before loading, not after. Checking afterwards left slices + # being read off disk and folded into memory forever while adapt() was + # never called again, so a long session grew its held memory without + # bound for no benefit. + if self.stop_after is not None and self.memory_count >= self.stop_after: + return None + + flag = data[self.flag_tag] + available = int(np.asarray(flag).flat[0]) + if self.stop_after is not None: + available = min(available, self.stop_after) + loaded = 0 + while self.memory_count < available: + self.memory_count += 1 + path = os.path.join(self.load_dir, f"memory_{self.memory_count}.pkl") + slice_ = self._load_when_ready(path) + if slice_ is None: + # The announcement beat the file to disk. Step back so this + # slice is loaded rather than skipped. + self.memory_count -= 1 + self._pending = True + break + self.memory = slice_ if self.memory is None else self.memory + slice_ + loaded += 1 + else: + self._pending = False + if self.memory is None: + return None + if loaded == 0 and not self.continuous: + return None + + losses = self.model.adapt(self.memory) + self.adaptation_count += 1 + model_path = os.path.join(self.save_dir, f"mdl{self.adaptation_count}.pkl") + self.model.save(model_path) + self._record_losses(losses) + if self.notify and self._smm is not None \ + and self.notify_tag in self._smm.variables: + number = self.adaptation_count + self._smm.apply(self.notify_tag, lambda block: number) + return None + + def _record_losses(self, losses): + try: + with open(os.path.join(self.save_dir, "losses.txt"), "a") as handle: + handle.write(f"{time.time() - self._started}\t{losses}\n") + except Exception: + # Losing the loss record must not stop adaptation. + pass + + def teardown(self): + if self.model is not None: + try: + self.model.save(os.path.join(self.save_dir, "model_final.pkl")) + except Exception: + pass + + def pending(self): + """Whether a slice was announced but could not be read. + + Returns + ---------- + bool + True means a slice is still outstanding. It is retried on the next + wake, which arrives with the next completed trial; if that was the + last trial of a session, call :meth:`step` once more or check this + before shutting down. + """ + return self._pending + + def _load_when_ready(self, location, attempts=20, interval=0.01): + """Load a slice, tolerating an announcement that beat the file to disk. + + The memory side writes a slice and then advances the flag, so by the + time this runs the file is normally there. A brief retry covers the + window where it is not, and also the window where the file exists but + is still being written, which surfaces as an unpickling error rather + than a missing file. + + Returns + ---------- + object or None + The loaded slice, or None if it never became readable. The caller + steps its counter back so the slice is retried rather than skipped. + """ + for attempt in range(attempts): + try: + return self._load(location) + except (FileNotFoundError, EOFError, pickle.UnpicklingError): + if attempt == attempts - 1: + return None + time.sleep(interval) + return None + + @staticmethod + def _load(location): + with open(location, "rb") as handle: + return pickle.load(handle) + + +class ModelSwapHook(Hook): + """React to a newly adapted model becoming available. + + The last link in the chain. The predictor itself watches ``adapt_flag`` + from inside its own streaming process, because that is where the model it + would replace actually lives; see + :meth:`~libemg.emg_predictor.OnlineStreamer.on_model_update`. This hook is + for everything *else* that wants to know: a plot marking when the model + changed, a log, a counter of how often adaptation reached the user. + + Parameters + ---------- + name: str + Hook name. + fn: callable + Called as ``fn(number)`` with the new model number. + notify_tag: str (optional), default='adapt_flag' + The item to observe. + """ + + def __init__(self, name, fn, notify_tag="adapt_flag"): + super().__init__(name, inputs=[Input(notify_tag, OnCommit(), mode=FULL)]) + self.fn = fn + self.notify_tag = notify_tag + + def step(self, data, snapshots): + number = int(np.asarray(data[self.notify_tag]).flat[0]) + if number >= 0: + self.fn(number) + return None diff --git a/libemg/adaptation/managers.py b/libemg/adaptation/managers.py new file mode 100644 index 00000000..1c7c68d7 --- /dev/null +++ b/libemg/adaptation/managers.py @@ -0,0 +1,295 @@ +from multiprocessing import Process, Lock, Event +import libemg +from abc import abstractmethod +from typing import Any, Tuple +import pickle +import numpy as np +import time + +class MemoryManager(Process): + """ + This object is part of the adaptation suite provided by LibEMG. The memory manager is responsible for managing the data (both the inputs and pseudolabels) that arise from + a user-in-the-loop setting and storing them in pickled memory classes. These memory classes may represent segments of data that have been collected (e.g., a trial of Fitts' Law). + The memory manager contains a signal to trigger the adaptation manager to load these slices of memory, which is the other half of the adaptation suite provided by LibEMG. + + The memory slices are composed by monitoring shared memory output writers from the environment (which provide a timestamp and pseudo-label), and monitoring shared memory output writers from + the model (which provide an identical timestamp and model inputs such as EMG features). + + Parameters + ---------- + memory: Any + A custom object that defines the memory class. It should have a .append(), .save(), .load(), .reset(), and __add__ operator overload. + smi: list + a list containing information to construct shared memory managers that receive all data necessary to compile memories. Consult libemg.adaptation._base.get__adaptation_items() for more information. + ow: list[libemg.output_writer.OutputWriter] + A list of LibEMG output writers that are used by this class to write information out to other processes (i.e., the adpatation manager). Consult libemg.adaptation._base.get__adaptation_items() for more information. + save_dir: str + The location that memory slices will be saved. If adaptation is actually running, this should be same as the load_dir argument of the libemg.adaptation.managers.AdaptationManager. + """ + def __init__(self, + memory: Any, + smi: list, + ow: list[libemg.output_writer.OutputWriter], + save_dir: str): + Process.__init__(self, daemon=True) + + self.signal = Event() + + self.memory = memory + self.smi = smi + self.ow = ow + self.save_dir = save_dir + + self.environment_feedback_count = 0 + self.trial_counter = 1 + ensure_directory(self.save_dir) + + def run(self): + from libemg.reactive import default_notifier_pool + pool = getattr(self, "notifier_pool", None) or default_notifier_pool() + self.smm = libemg.shared_memory_manager.SharedMemoryManager(notifier_pool=pool) + for smi in self.smi: + self.smm.create_variable(*smi) + self.ow[0].write(0) # start at 0 + + # Wait to be told feedback arrived rather than asking. The old loop ran + # process_data() as fast as the interpreter allowed, and every pass + # copied the whole feedback buffer AND the whole model-input buffer + # just to compare one counter. Now the slot is woken by the + # environment's write and the predicate below reads only the state + # block, which is a handful of integers. + slot = getattr(self, "_notifier_slot", None) + if slot is None: + slot = pool.claim() + self.smm.subscribe("environment_feedback", slot) + seen = self.smm.snapshot("environment_feedback").generation + + def arrived(): + return int(self.smm._block("environment_feedback")[0]) > seen + + self.memory.reset() + while True: + if self.signal.is_set(): + self.memory.save(self.save_dir + "memory_"+str(self.trial_counter) + ".pkl") + break + + # The timeout is a safety net, not the mechanism: a writer that + # does not share the notifier pool still gets noticed, and the + # stop signal above is still reached promptly. + pool.wait(slot, arrived, getattr(self, "poll_fallback", 0.05)) + seen = self.smm.snapshot("environment_feedback").generation + # check if there is data to process, and if so process it + self.process_data() + + + def process_data(self): + # Snapshot the feedback buffer and its counter together (they share a + # lock) so num_to_grab is always sliced against the exact buffer state + # it was measured from. Reading them separately lets the writer append + # rows in between, which misaligns the slice and splices in wrong rows. + feedback = self.smm.get_variables(["environment_feedback", "environment_feedback_count"]) + environment_feedback_count = feedback["environment_feedback_count"][0,0] + # if there has been no new environment feedback, just continue + if environment_feedback_count == self.environment_feedback_count: + return + num_to_grab = environment_feedback_count - self.environment_feedback_count + feedback_data = feedback["environment_feedback"] + feedback_data = feedback_data[:num_to_grab,:] + + # model_input is joined to feedback rows by timestamp (not by index), so + # it doesn't need to be part of the atomic snapshot; reading it here also + # keeps it as fresh as possible so matching timestamps are present. + input_data = self.smm.get_variable('model_input') + # for every row in data: + for i in range(feedback_data.shape[0]): + feedback_row = feedback_data[i,:] + trial = feedback_row[1] + if trial != self.trial_counter: + # Save the memory + self.memory.save(self.save_dir + "memory_"+str(int(self.trial_counter)) + ".pkl") + self.ow[0].write(self.trial_counter) + self.trial_counter = trial + # tell the adaptation manager a new slice is ready + + # Start a fresh memory + self.memory.reset() + # Append the data to the memory object + row_timestamp = feedback_row[0] + # find timestamp in classifier_input + timestamp_id = np.where(input_data[:,0]== row_timestamp)[0] + input_row = input_data[timestamp_id,1:] # start at 2nd column to remove timestamp + self.append_to_memory(feedback_row[2:], input_row, trial-1) + + self.environment_feedback_count = environment_feedback_count + + def save_memory(self, loc: str): + with open(loc, 'wb') as f: + pickle.dump(self.memory, f) + + def run_helper(self, block=True): + """ + Helper function to run the process. This is used to avoid blocking the main thread. + """ + if block: + self.run() + else: + self.start() + + @abstractmethod + def setup_memorymanager() -> None: + pass + + @abstractmethod + def get_data() -> Tuple[Any, Any]: + pass + + @abstractmethod + def append_to_memory(self, feedback, input, trial) -> None: + self.memory.append(feedback, input, trial) + + + +class AdaptationManager(Process): + """ + This object is part of the adaptation suite provided by LibEMG. The adaptation manager is responsible for aggregating the slices of data that are packaged by the memory manager, and running + the adaptation of the model given this data. + + Parameters + ---------- + model: Any + A custom object that defines the model. It should have a .adapt, .save(), .load() and .predict() methods. + smi: list + a list containing information to construct shared memory managers that receive messages from the memory manager to update the aggregated data for adptation. Consult libemg.adaptation._base.get__adaptation_items() for more information. + ow: list[libemg.output_writer.OutputWriter] + A list of LibEMG output writers that are used by this class to write information out to other processes (i.e., the OnlineStreamer to load the updated model). Consult libemg.adaptation._base.get__adaptation_items() for more information. + initial_memory_loc: str + The location of the initial memory slice. A typical use for this would be to load data from screen guided training prior to gather user-in-the-loop data. Not entirely necessary, but very beneficial for stability. + load_dir: str + The location that the memory slices will be loaded. This should be the save_dir argument of the libemg.adaptation.managers.MemoryManager. + save_dir: str + The location that the adapted models will be saved. This should be the file_path argument of the libemg.emg_predictor.OnlineStreamer (the live model for the user-in-the-loop setting.) + """ + def __init__(self, + model, + smi: list, + ow: list[libemg.output_writer.OutputWriter], + initial_memory_loc: str|None, + load_dir: str, + save_dir: str, + stop_condition: callable = lambda x: True, + notify: bool = True): + + Process.__init__(self, daemon=True) + + self.signal = Event() + + self.model = model + self.smi = smi + self.ow = ow + self.initial_memory_loc = initial_memory_loc + self.load_dir = load_dir + self.save_dir = save_dir + self.stop_condition = stop_condition + self.notify = notify + + self.memory = self.load_memory(initial_memory_loc) + + self.memory_count = 0 + self.adaptation_count = 0 + + # ensure save and load directories exist + ensure_directory(self.save_dir) + ensure_directory(self.load_dir) + + def run_helper(self, block=True): + """ + Helper function to run the process. This is used to avoid blocking the main thread. + NOTE: if you're training on the GPU, you need to run this in the main loop. This is a CUDA thing and + not something that can be worked around without sacrificing multiprocessing latency elsewhere (streaming). + """ + if block: + self.run() + else: + self.start() + + def run(self): + from libemg.reactive import default_notifier_pool + start_time = time.time() + pool = getattr(self, "notifier_pool", None) or default_notifier_pool() + self.smm = libemg.shared_memory_manager.SharedMemoryManager(notifier_pool=pool) + for smi in self.smi: + self.smm.create_variable(*smi) + + # Subscribe so a finished memory slice wakes this process. Note the + # loop below still adapts on every pass by design, so unlike the memory + # manager this wait bounds how long an idle pass sleeps rather than + # removing the passes; set wait_for_memory=True to train only when a + # slice actually arrives. + slot = getattr(self, "_notifier_slot", None) + if slot is None: + slot = pool.claim() + self.smm.subscribe("memory_flag", slot) + wait_for_memory = getattr(self, "wait_for_memory", False) + + while not self.stop_condition(self.memory_count): + + if self.signal.is_set(): + break + + if wait_for_memory: + consumed = self.memory_count + + def arrived(): + return int(self.smm.get_variable("memory_flag")[0, 0]) > consumed + + pool.wait(slot, arrived, getattr(self, "poll_fallback", 0.05)) + if self.signal.is_set(): + break + + memory_count = self.smm.get_variable("memory_flag")[0,0] + if memory_count > self.memory_count: + num_memories_to_load = memory_count - self.memory_count + for m in range(num_memories_to_load): + # load the next memory + self.memory_count += 1 + # Load memory + new_memory = self.load_memory(self.load_dir + "memory_" + str(self.memory_count) + ".pkl") + print(f"{self.memory_count} : {new_memory.processed_data[0].shape}") + self.memory = self.memory + new_memory + + # Adapt the model + loss_list = self.model.adapt(self.memory) + with open(self.save_dir + "losses.txt", 'a') as f: + f.write(str(time.time() - start_time) + "\t" + str(loss_list) + "\n") + self.adaptation_count += 1 + self.model.save(self.save_dir + "mdl" + str(self.adaptation_count) + ".pkl") + if self.notify: + self.ow[0].write(self.adaptation_count) + + self.save_model(self.save_dir + "model_final.pkl") + + + def load_memory(self, loc: str): + with open(loc, 'rb') as f: + return pickle.load(f) + + def save_model(self, loc: str): + with open(loc, 'wb') as f: + pickle.dump(self.model, f) + +def ensure_directory(directory: str) -> None: + """ + Ensure that a directory exists. If it does not exist, create it. + + Parameters + ---------- + directory : str + The directory to ensure exists. + """ + import os + # exist_ok, not a prior existence check. The memory manager's save + # directory and the adaptation manager's load directory are by design the + # same one, and both processes start at the same moment, so a check + # followed by a create lets both see it missing and the loser die with + # FileExistsError. + os.makedirs(directory, exist_ok=True) \ No newline at end of file diff --git a/libemg/adaptation/memory.py b/libemg/adaptation/memory.py new file mode 100644 index 00000000..9c0c46f7 --- /dev/null +++ b/libemg/adaptation/memory.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + +class Memory(ABC): + + @abstractmethod + def append(self, data): + ... + + @abstractmethod + def reset(self): + ... + + @abstractmethod + def save(self): + ... + + @abstractmethod + def load(self): + ... + + @abstractmethod + def __add__(self, other): + ... \ No newline at end of file diff --git a/libemg/animator.py b/libemg/animator.py index 80e2716d..b392a713 100644 --- a/libemg/animator.py +++ b/libemg/animator.py @@ -1,3 +1,5 @@ +from contextlib import nullcontext +from pathlib import Path import os from typing import Callable, Sequence import warnings @@ -12,6 +14,60 @@ import cv2 +class _IncrementalVideoWriter: + """Encodes frames to a .mp4 file one at a time instead of buffering the whole animation. + + A single 480x480 RGBA frame is ~0.92 MB, so collecting every frame before encoding costs + ~1.3 GB of RAM for a 60 s @ 24 fps animation. Writing incrementally keeps memory flat. + cv2.VideoWriter needs frameSize up front, so the underlying writer is opened lazily from the + first frame handed to write(). + """ + def __init__(self, output_filepath: str, fps: int): + """ + Parameters + ---------- + output_filepath: string + Path to output .mp4 file. + fps: int + Frames per second of output file. + """ + self.output_filepath = output_filepath + self.fps = fps + self._writer = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.release() + return False + + def write(self, frame: Image.Image): + """Encode a single frame, which can then be discarded by the caller. + + Parameters + ---------- + frame: PIL.Image + Frame to append to the video. + """ + if self._writer is None: + # frameSize is only known once the first frame arrives, hence the lazy open + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + self._writer = cv2.VideoWriter(self.output_filepath, fourcc, fps=self.fps, frameSize=frame.size) + # np.asarray() already materializes a new array from the PIL buffer, so an additional + # frame.copy() would just be a wasted ~0.92 MB memcpy per frame. + # COLOR_RGB2BGR accepts 3- or 4-channel input and always emits 3 channels, so the alpha of + # RGBA frames (what matplotlib produces) is dropped here and never reaches the encoder. + bgr_frame = cv2.cvtColor(np.asarray(frame), cv2.COLOR_RGB2BGR) + self._writer.write(bgr_frame) + + def release(self): + """Finalize the output file. Safe to call more than once.""" + if self._writer is not None: + self._writer.release() + self._writer = None + + class Animator: def __init__(self, output_filepath: str = 'libemg.gif', fps: int = 24): """Animator object for creating .gif files from a list of images. @@ -53,14 +109,11 @@ def save_mp4(self, frames: Sequence[Image.Image]): frames: list List of frames, where each element is a PIL.Image object. """ - fourcc = cv2.VideoWriter_fourcc(*'avc1') - video = cv2.VideoWriter(self.output_filepath, fourcc, fps=self.fps, frameSize=frames[0].size) - - for frame in frames: - img = frame.copy() - bgr_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) - video.write(bgr_img) - video.release() + # Delegate to the incremental writer so callers that already hold every frame and callers + # that stream frames share a single encoding path. + with _IncrementalVideoWriter(self.output_filepath, self.fps) as video: + for frame in frames: + video.write(frame) def save_video(self, frames: Sequence[Image.Image]): """Save a video file from a list of images. @@ -119,24 +172,35 @@ def save_video_from_directory(self, directory_path: str, match_filename_function if match_filename_function is None: # Combine all images in directory match_filename_function = lambda x: True - frames = [] filenames = os.listdir(directory_path) filenames.sort() # sort alphabetically matching_filenames = [] # images used to create .gif - for filename in filenames: - absolute_path = os.path.join(directory_path, filename) - if match_filename_function(filename): - # File matches the user pattern and is an accepted image format - try: - image = Image.open(absolute_path) - frames.append(image) + # Only the .gif path needs every frame at once (PIL's save_all), so .mp4 frames are encoded + # and released one file at a time rather than opening the whole directory up front. + frames = [] + mp4_context = _IncrementalVideoWriter(self.output_filepath, self.fps) if self.video_format == '.mp4' else nullcontext() + with mp4_context as mp4_writer: + for filename in filenames: + absolute_path = os.path.join(directory_path, filename) + if match_filename_function(filename): + # File matches the user pattern and is an accepted image format + try: + image = Image.open(absolute_path) + except UnidentifiedImageError: + # Reading non-image file + print(f'Skipping {absolute_path} because it is not an image file.') + continue matching_filenames.append(absolute_path) - except UnidentifiedImageError: - # Reading non-image file - print(f'Skipping {absolute_path} because it is not an image file.') - - self.save_video(frames) + if mp4_writer is not None: + with image: + mp4_writer.write(image) # encode now so the image can be freed + else: + frames.append(image) + + if mp4_writer is None: + # .gif, or an unrecognized extension which save_video reports + self.save_video(frames) if delete_images: # Delete all images used to create .gif @@ -178,7 +242,7 @@ def __init__(self, output_filepath: str ='libemg.gif', fps: int = 24, show_direc self.fpd = fps * self.tpd # number of frames to generate to travel a distance of 1 - def convert_distance_to_frames(self, coordinates1: npt.NDArray[np.float_], coordinates2: npt.NDArray[np.float_]): + def convert_distance_to_frames(self, coordinates1: npt.NDArray[np.float64], coordinates2: npt.NDArray[np.float64]): """Calculate the number of frames needed to move from coordinates1 to coordinates2. Parameters @@ -198,7 +262,7 @@ def convert_distance_to_frames(self, coordinates1: npt.NDArray[np.float_], coord return int(distance * self.fpd) @staticmethod - def _normalize_to_unit_distance(x: npt.NDArray[np.float_], y: npt.NDArray[np.float_]): + def _normalize_to_unit_distance(x: npt.NDArray[np.float64], y: npt.NDArray[np.float64]): """Normalize coordinates to a unit circle distance. Parameters @@ -257,7 +321,7 @@ def _format_figure(self): ax = plt.gca() return fig, ax - def _preprocess_coordinates(self, coordinates: npt.NDArray[np.float_]): + def _preprocess_coordinates(self, coordinates: npt.NDArray[np.float64]): """Modify coordinates before plotting (e.g., normalization). Parameters @@ -272,7 +336,7 @@ def _show_boundary(self): """Plot boundary to axis.""" pass # going to be different for each implementation, so don't implement it here - def _show_countdown(self, coordinates: npt.NDArray[np.float_], text: str): + def _show_countdown(self, coordinates: npt.NDArray[np.float64], text: str): """Show a countdown based on the current coordinates and frame index. Parameters @@ -284,7 +348,7 @@ def _show_countdown(self, coordinates: npt.NDArray[np.float_], text: str): """ plt.text(coordinates[0], coordinates[1], text, fontweight='bold', c='red', ha='center', va='center') - def _show_direction(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0): + def _show_direction(self, coordinates: npt.NDArray[np.float64], alpha: float = 1.0): """Show the direction of the next part of the movement. Parameters @@ -298,7 +362,7 @@ def _show_direction(self, coordinates: npt.NDArray[np.float_], alpha: float = 1. self.plot_icon(coordinates, alpha=alpha, colour='green') - def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, colour: str = 'black'): + def plot_icon(self, coordinates: npt.NDArray[np.float64], alpha: float = 1.0, colour: str = 'black'): """Plot target / icon on axis. Parameters @@ -313,7 +377,7 @@ def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, col plt.plot(coordinates[0], coordinates[1], alpha=alpha, c=colour) - def save_plot_video(self, coordinates: npt.NDArray[np.float_], title: str = '', xlabel: str = '', ylabel: str = '', save_coordinates: bool = False, verbose: bool = False): + def save_plot_video(self, coordinates: npt.NDArray[np.float64], title: str = '', xlabel: str = '', ylabel: str = '', save_coordinates: bool = False, verbose: bool = False): """Save a video file of an icon moving around a 2D plane. Parameters @@ -340,8 +404,8 @@ def save_plot_video(self, coordinates: npt.NDArray[np.float_], title: str = '', """ if save_coordinates: # Save coordinates in .txt file - filename_no_extension = os.path.splitext(self.output_filepath)[0] - labels_filepath = filename_no_extension + '.txt' + labels_filepath = Path(self.output_filepath).with_suffix('.txt') + labels_filepath.parent.mkdir(parents=True, exist_ok=True) np.savetxt(labels_filepath, coordinates, delimiter=',') # Format figure @@ -351,64 +415,83 @@ def save_plot_video(self, coordinates: npt.NDArray[np.float_], title: str = '', fig.suptitle(title) fig.tight_layout() - # Calculate steady states - diff = np.diff(coordinates, axis=0) - max_diff = np.max(np.abs(diff), axis=1) - all_steady_state_indices = np.where(max_diff < 0.01)[0] # find where the differences at each frame is less than some threshold - # Only take starts and ends of each segment - split_steady_state_indices = np.split(all_steady_state_indices, np.where(np.diff(all_steady_state_indices) != 1)[0] + 1) # add 1 to align diff with original - split_steady_state_indices.append(np.array([coordinates.shape[0] - 1])) # append last frame - steady_state_start_indices = np.array([segment[0] for segment in split_steady_state_indices]) - steady_state_end_indices = np.array([segment[-1] for segment in split_steady_state_indices]) - + split_steady_state_indices = None + steady_state_start_indices = None + steady_state_end_indices = None + if self.show_direction or self.show_countdown: + # Calculate steady states + diff = np.diff(coordinates, axis=0) + max_diff = np.max(np.abs(diff), axis=1) + all_steady_state_indices = np.where(max_diff < 0.01)[0] # find where the differences at each frame is less than some threshold + try: + # Only take starts and ends of each segment + split_steady_state_indices = np.split(all_steady_state_indices, np.where(np.diff(all_steady_state_indices) != 1)[0] + 1) # add 1 to align diff with original + split_steady_state_indices.append(np.array([coordinates.shape[0] - 1])) # append last frame + steady_state_start_indices = np.array([segment[0] for segment in split_steady_state_indices]) + steady_state_end_indices = np.array([segment[-1] for segment in split_steady_state_indices]) + except IndexError as e: + raise IndexError('Could not find steady state frames. If these features are desired, please pass in steady state frames (i.e., consecutive frames with the same value).') from e + # Adjust coordinates if desired coordinates = self._preprocess_coordinates(coordinates) + # PIL's save_all needs the entire sequence in memory, so frames are only accumulated for + # the .gif path. For .mp4 each frame is encoded as soon as it is rendered, which keeps + # memory flat instead of growing by ~0.92 MB per frame (~1.3 GB for 60 s @ 24 fps). frames = [] + mp4_context = _IncrementalVideoWriter(self.output_filepath, self.fps) if self.video_format == '.mp4' else nullcontext() + current_steady_state_idx = 0 target_alpha = 0.05 - for frame_idx, frame_coordinates in enumerate(coordinates): - if verbose and frame_idx % 10 == 0: - print(f'Frame {frame_idx} / {coordinates.shape[0]}') - - # Calculate next steady state frame - next_steady_state_idx = min(current_steady_state_idx, len(steady_state_end_indices) - 1) # limit max index - if frame_idx > steady_state_end_indices[current_steady_state_idx]: - current_steady_state_idx += 1 - target_alpha = 0.05 # reset alpha - # Plot additional information - if self.show_boundary: - # Show boundaries - self._show_boundary() - - if self.show_direction: - # Show path until a change in direction - next_steady_state_idx = current_steady_state_idx + 1 if frame_idx > steady_state_start_indices[current_steady_state_idx] else current_steady_state_idx - next_steady_state_start = steady_state_start_indices[next_steady_state_idx] - target_alpha += 0.01 # add in fade - target_alpha = min(0.4, target_alpha) # limit alpha to 0.4 - self._show_direction(coordinates[next_steady_state_start], alpha=target_alpha) - - if self.show_countdown: - # Show countdown during steady state - steady_state_end = steady_state_end_indices[current_steady_state_idx] - time_until_movement = (steady_state_end - frame_idx) * self.duration / 1000 # convert from frames to seconds - if time_until_movement >= 0.25 and frame_idx in split_steady_state_indices[current_steady_state_idx]: - # Only show countdown if the steady state is longer than 1 second - self._show_countdown(frame_coordinates, str(int(time_until_movement))) - - # Plot icon - self.plot_icon(frame_coordinates) - - # Save frame - frame = self._convert_plot_to_image(fig) - frames.append(frame) - # Clear axis while retaining formatting - for artist in ax.lines + ax.collections + ax.patches + ax.texts: - artist.remove() - - # Save file - self.save_video(frames) + with mp4_context as mp4_writer: + for frame_idx, frame_coordinates in enumerate(coordinates): + if verbose and frame_idx % 10 == 0: + print(f'Frame {frame_idx} / {coordinates.shape[0]}') + + # Plot additional information + if self.show_boundary: + # Show boundaries + self._show_boundary() + + if (self.show_direction or self.show_countdown) and split_steady_state_indices is not None and steady_state_start_indices is not None and steady_state_end_indices is not None: + # Calculate next steady state frame + next_steady_state_idx = min(current_steady_state_idx, len(steady_state_end_indices) - 1) # limit max index + if frame_idx > steady_state_end_indices[current_steady_state_idx]: + current_steady_state_idx += 1 + target_alpha = 0.05 # reset alpha + + if self.show_direction: + # Show path until a change in direction + next_steady_state_idx = current_steady_state_idx + 1 if frame_idx > steady_state_start_indices[current_steady_state_idx] else current_steady_state_idx + next_steady_state_start = steady_state_start_indices[next_steady_state_idx] + target_alpha += 0.01 # add in fade + target_alpha = min(0.4, target_alpha) # limit alpha to 0.4 + self._show_direction(coordinates[next_steady_state_start], alpha=target_alpha) + + if self.show_countdown: + # Show countdown during steady state + steady_state_end = steady_state_end_indices[current_steady_state_idx] + time_until_movement = (steady_state_end - frame_idx) * self.duration / 1000 # convert from frames to seconds + if time_until_movement >= 0.25 and frame_idx in split_steady_state_indices[current_steady_state_idx]: + # Only show countdown if the steady state is longer than 1 second + self._show_countdown(frame_coordinates, str(int(time_until_movement))) + + # Plot icon + self.plot_icon(frame_coordinates) + + # Save frame + frame = self._convert_plot_to_image(fig) + if mp4_writer is not None: + mp4_writer.write(frame) # encode immediately instead of buffering the animation + else: + frames.append(frame) + # Clear axis while retaining formatting + for artist in ax.lines + ax.collections + ax.patches + ax.texts: + artist.remove() + + if mp4_writer is None: + # Save file (.gif, or an unrecognized extension which save_video reports) + self.save_video(frames) class CartesianPlotAnimator(PlotAnimator): @@ -474,7 +557,7 @@ def _format_figure(self): ax.set(xlim=axis_limits, ylim=axis_limits) return fig, ax - def _preprocess_coordinates(self, coordinates: npt.NDArray[np.float_]): + def _preprocess_coordinates(self, coordinates: npt.NDArray[np.float64]): coordinates = super()._preprocess_coordinates(coordinates) if self.normalize_distance: @@ -486,7 +569,7 @@ def _show_boundary(self): an = np.linspace(0, 2 * np.pi, 100) plt.plot(np.cos(an), np.sin(an), 'b--', alpha=0.7) - def _show_countdown(self, coordinates: npt.NDArray[np.float_], text: str): + def _show_countdown(self, coordinates: npt.NDArray[np.float64], text: str): x = coordinates[0] y = coordinates[1] - 0.2 return super()._show_countdown((x, y), text) @@ -558,7 +641,7 @@ def __init__(self, output_filepath: str = 'libemg.gif', fps: int = 24, show_dire self.plot_line = plot_line - def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, colour: str = 'black'): + def plot_icon(self, coordinates: npt.NDArray[np.float64], alpha: float = 1.0, colour: str = 'black'): # Parse coordinates x = coordinates[0] y = coordinates[1] @@ -572,7 +655,7 @@ def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, col class ArrowPlotAnimator(CartesianPlotAnimator): - def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, colour: str = 'black'): + def plot_icon(self, coordinates: npt.NDArray[np.float64], alpha: float = 1.0, colour: str = 'black'): # Parse coordinates x_tail = coordinates[0] y_tail = coordinates[1] @@ -597,7 +680,7 @@ def _plot_circle(xy: tuple[float, float], radius: float, edgecolor: str, facecol circle = Circle(xy, radius=radius, edgecolor=edgecolor, facecolor=facecolor, alpha=alpha) plt.gca().add_patch(circle) - def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1.0, colour: str = 'black'): + def plot_icon(self, coordinates: npt.NDArray[np.float64], alpha: float = 1.0, colour: str = 'black'): # Parse coordinates x = coordinates[0] y = coordinates[1] @@ -658,13 +741,13 @@ def _format_figure(self): ax.set(ylim=axis_limits) return fig, ax - def _plot_border(self, coordinates: npt.NDArray[np.float_], edgecolor: str = 'black'): + def _plot_border(self, coordinates: npt.NDArray[np.float64], edgecolor: str = 'black'): plt.bar(self.bar_labels, coordinates, color='none', edgecolor=edgecolor, linewidth=2, width=self.bar_width) - def _show_direction(self, coordinates: npt.NDArray[np.float_], alpha: float = 1): + def _show_direction(self, coordinates: npt.NDArray[np.float64], alpha: float = 1): self._plot_border(coordinates, edgecolor='green') - def _show_countdown(self, coordinates: npt.NDArray[np.float_], text: str): + def _show_countdown(self, coordinates: npt.NDArray[np.float64], text: str): adjustment = 0.05 for label, dof_value in zip(self.bar_labels, coordinates): modifier = -adjustment if dof_value < 0 else adjustment @@ -674,7 +757,7 @@ def _show_boundary(self): self._plot_border(-1) self._plot_border(1) - def plot_icon(self, coordinates: npt.NDArray[np.float_], alpha: float = 1, colour: str = 'black'): + def plot_icon(self, coordinates: npt.NDArray[np.float64], alpha: float = 1, colour: str = 'black'): plt.bar(self.bar_labels, coordinates, alpha=alpha, color=colour, width=self.bar_width) axis_limits = plt.gca().get_ylim() diff --git a/libemg/data_handler.py b/libemg/data_handler.py index ec057c98..f1bfad03 100644 --- a/libemg/data_handler.py +++ b/libemg/data_handler.py @@ -5,16 +5,13 @@ import pandas as pd import os import re -import socket -import csv -import pickle import time import math import wfdb import copy import matplotlib.pyplot as plt import matplotlib.cm as cm -from sklearn.decomposition import PCA +from matplotlib.axes import Axes from scipy.ndimage import zoom from scipy.signal import decimate from matplotlib import pyplot @@ -23,26 +20,30 @@ from glob import glob from multiprocessing import Process from multiprocessing import Process, Event +import threading from libemg.feature_extractor import FeatureExtractor from libemg.shared_memory_manager import SharedMemoryManager from scipy.signal import welch -from libemg.utils import get_windows, _get_fn_windows, _get_mode_windows, make_regex +from libemg.utils import get_windows, _get_fn_windows, _get_mode_windows, make_regex, _release_interactive_plot class RegexFilter: - def __init__(self, left_bound: str, right_bound: str, values: Sequence, description: str): - """Filters files based on filenames that match the associated regex pattern and grabs metadata based on the regex pattern. + """ + Filters files based on filenames that match the associated regex pattern and grabs metadata based on the regex pattern. - Parameters - ---------- - left_bound: str - The left bound of the regex. - right_bound: str - The right bound of the regex. - values: list - The values between the two regexes. - description: str - Description of filter - used to name the metadata field. - """ + Parameters + ---------- + left_bound: str + The left bound of the regex. + right_bound: str + The right bound of the regex. + values: list + The values between the two regexes. + description: str + Description of filter - used to name the metadata field. Pass in an empty string to filter files without storing the values as metadata. + """ + def __init__(self, left_bound: str, right_bound: str, values: Sequence[str], description: str): + if values is None: + raise ValueError('Expected a list of values for RegexFilter, but got None. Using regex wildcard is not supported with the RegexFilter.') self.pattern = make_regex(left_bound, right_bound, values) self.values = values self.description = description @@ -83,19 +84,21 @@ def get_metadata(self, filename: str): class MetadataFetcher(ABC): - def __init__(self, description: str): - """Describes a type of metadata and implements a method to fetch it. + """ + Describes a type of metadata and implements a method to fetch it. - Parameters - ---------- - description: str - Description of metadata. - """ + Parameters + ---------- + description: str + Description of metadata. + """ + def __init__(self, description: str): self.description = description @abstractmethod - def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[str]): + def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[str]) -> npt.NDArray: """Fetch metadata. Must return a (N x M) numpy.ndarray, where N is the number of samples in the EMG data and M is the number of columns in the metadata. + If a single value array is returned (0D or 1D), it will be cast to a N x 1 array where all values are the original value. Parameters ---------- @@ -115,32 +118,54 @@ def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[st class FilePackager(MetadataFetcher): - def __init__(self, regex_filter: RegexFilter, package_function: Callable[[str, str], bool], align_method: str | Callable[[npt.NDArray, npt.NDArray], npt.NDArray] = 'zoom', load = None, column_mask = None): - """Package data file with another file that contains relevant metadata (e.g., a labels file). Cycles through all files - that match the RegexFilter and packages a data file with a metadata file based on a packaging function. + """Package data file with another file that contains relevant metadata (e.g., a labels file). Cycles through all files + that match the RegexFilter and packages a data file with a metadata file based on a packaging function. - Parameters - ---------- - regex_filter: RegexFilter - Used to find the type of metadata files. - package_function: callable - Function handle used to determine if two files should be packaged together (i.e., found the metadata file that goes with the data file). - Takes in the filename of a metadata file and the filename of the data file. Should return True if the files should be packaged together and False if not. - align_method: str or callable, default='zoom' - Method for aligning the samples of the metadata file and data file. Pass in 'zoom' for the metadata file to be zoomed using spline interpolation to the size of the data file or - pass in a callable that takes in the metadata and the EMG data and returns the aligned metadata. - load: callable or None, default=None - Custom loading function for metadata file. If None is passed, the metadata is loaded based on the file extension (only .csv and .txt are supported). - column_mask: list or None, default=None - List of integers corresponding to the indices of the columns that should be extracted from the raw file data. If None is passed, all columns are extracted. - """ + Parameters + ---------- + regex_filter: RegexFilter + Used to find the type of metadata files. The description of this RegexFilter is used to assign the name of the field for this metadata in the OfflineDataHandler. + package_function: callable or Sequence[RegexFilter] + Function handle used to determine if two files should be packaged together (i.e., found the metadata file that goes with the data file). + Takes in the filename of a metadata file and the filename of the data file. Should return True if the files should be packaged together and False if not. + Alternatively, a list of RegexFilters can be passed in and a function will be created that packages files only if the regex metadata of the data filename + and metadata filename match. + align_method: str or callable, default='zoom' + Method for aligning the samples of the metadata file and data file. Pass in 'zoom' for the metadata file to be zoomed using spline interpolation to the size of the data file or + pass in a callable that takes in the metadata and the EMG data and returns the aligned metadata. + load: callable, str, or None, default=None + Determines how metadata file is loaded. If a custom loading function, should take in the filename and return an array. If a string, + it is assumed to be the MRDF key of a .hea file. If None is passed, the metadata is loaded based on the file extension (only .csv and .txt are supported). + column_mask: list or None, default=None + List of integers corresponding to the indices of the columns that should be extracted from the raw file data. If None is passed, all columns are extracted. + """ + def __init__(self, regex_filter: RegexFilter, package_function: Callable[[str, str], bool] | Sequence[RegexFilter], + align_method: str | Callable[[npt.NDArray, npt.NDArray], npt.NDArray] = 'zoom', load: Callable[[str], npt.NDArray] | str | None = None, column_mask: Sequence[int] | None = None): super().__init__(regex_filter.description) self.regex_filter = regex_filter + self.package_filters = None + + if isinstance(package_function, Sequence): + # Create function to ensure metadata matches + self.package_filters = copy.deepcopy(package_function) + package_function = self._match_regex_patterns self.package_function = package_function self.align_method = align_method self.load = load self.column_mask = column_mask + def _match_regex_patterns(self, metadata_file: str, data_file: str): + assert self.package_filters is not None, 'Attempting to match package filters, but None found.' + for filter in self.package_filters: + if len(filter.get_matching_files([metadata_file])) == 0: + # Doesn't match filters + return False + matching_metadata = filter.get_metadata(metadata_file) == filter.get_metadata(data_file) + if not matching_metadata: + return False + return True + + def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[str]): potential_files = self.regex_filter.get_matching_files(all_files) packaged_files = [Path(potential_file) for potential_file in potential_files if self.package_function(potential_file, filename)] @@ -148,13 +173,19 @@ def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[st # I think it's easier to enforce a single file per FilePackager, but we could build in functionality to allow multiple files then just vstack all the data if there's a use case for that. raise ValueError(f"Found {len(packaged_files)} files to be packaged with {filename} when trying to package {self.regex_filter.description} file (1 file should be found). Please check filter and package functions.") packaged_file = packaged_files[0] + suffix = packaged_file.suffix + packaged_file = packaged_file.as_posix() if callable(self.load): # Passed in a custom loading function packaged_file_data = self.load(packaged_file) - elif packaged_file.suffix == '.txt': + elif isinstance(self.load, str): + # Passed in a MRDF key + assert suffix == '.hea', f"Provided string for load parameter, but packaged file doesn't have extension .hea. Please pass in a custom load function and/or ensure the correct file is packaged." + packaged_file_data = (wfdb.rdrecord(packaged_file.replace('.hea', ''))).__getattribute__(self.load) + elif suffix == '.txt': packaged_file_data = np.loadtxt(packaged_file, delimiter=',') - elif packaged_file.suffix == '.csv': + elif suffix == '.csv': packaged_file_data = pd.read_csv(packaged_file) packaged_file_data = packaged_file_data.to_numpy() else: @@ -163,7 +194,7 @@ def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[st # Align with EMG data if self.align_method == 'zoom': zoom_rate = file_data.shape[0] / packaged_file_data.shape[0] - zoom_factor = [zoom_rate if idx == 0 else 1 for idx in range(packaged_file_data.shape[1])] # only align the 0th axis (samples) + zoom_factor = (zoom_rate, 1) # only align the 0th axis (samples) packaged_file_data = zoom(packaged_file_data, zoom=zoom_factor) elif callable(self.align_method): packaged_file_data = self.align_method(packaged_file_data, file_data) @@ -181,19 +212,20 @@ def __call__(self, filename: str, file_data: npt.NDArray, all_files: Sequence[st return packaged_file_data -class ColumnFetch(MetadataFetcher): - def __init__(self, description: str, column_mask: Sequence[int] | int, values: Sequence | None = None): - """Fetch metadata from columns within data file. +class ColumnFetcher(MetadataFetcher): + """ + Fetch metadata from columns within data file. - Parameters - ---------- - description: str - Description of metadata. - column_mask: list or int - Integers corresponding to indices of columns that should be fetched. - values: list or None, default=None - List of potential values within metadata column. If a list is passed in, the metadata will be stored as the location (index) of the value within the provided list. If None, the value within the columns will be stored. - """ + Parameters + ---------- + description: str + Description of metadata. + column_mask: list or int + Integers corresponding to indices of columns that should be fetched. + values: list or None, default=None + List of potential values within metadata column. If a list is passed in, the metadata will be stored as the location (index) of the value within the provided list. If None, the value within the columns will be stored. + """ + def __init__(self, description: str, column_mask: Sequence[int] | int, values: Sequence | None = None): super().__init__(description) self.column_mask = column_mask self.values = values @@ -294,6 +326,9 @@ def get_data(self, folder_location: str, regex_filters: Sequence[RegexFilter], m Raises ValueError if folder_location is not a valid directory. """ def append_to_attribute(name, value): + if name == '': + # Don't want this data saved to data handler, so skip it + return if not hasattr(self, name): setattr(self, name, []) self.extra_attributes.append(name) @@ -345,6 +380,9 @@ def append_to_attribute(name, value): # Fetch remaining metadata for metadata_fetcher in metadata_fetchers: metadata = metadata_fetcher(file, file_data, all_files) + if metadata.ndim == 0 or metadata.shape[0] == 1: + # Cast to array with the same # of samples as EMG data + metadata = np.full((file_data.shape[0], 1), fill_value=metadata) if metadata.ndim == 1: # Ensure that output is always 2D array metadata = np.expand_dims(metadata, axis=1) @@ -395,6 +433,13 @@ def parse_windows(self, window_size, window_increment, metadata_operations=None) The number of samples in a window. window_increment: int The number of samples that advances before next window. + metadata_operations: dict or None (optional),default=None + Specifies which operations should be performed on metadata attributes when performing windowing. By default, + all metadata is stored as its mode in a window. To change this behaviour, specify the metadata attribute as the key and + the operation as the value in the dictionary. The operation (value) should either be an accepted string (mean, median, last_sample) or + a function handle that takes in an ndarray of size (window_size, ) and returns a single value to represent the metadata for that window. Passing in a string + will map from that string to the specified operation. The windowing of only the attributes specified in this dictionary will be modified - all other + attributes will default to the mode. If None, all attributes default to the mode. Defaults to None. Returns ---------- @@ -407,34 +452,46 @@ def parse_windows(self, window_size, window_increment, metadata_operations=None) return self._parse_windows_helper(window_size, window_increment, metadata_operations) def _parse_windows_helper(self, window_size, window_increment, metadata_operations): - metadata_ = {} + common_metadata_operations = { + 'mean': np.mean, + 'median': np.median, + 'last_sample': lambda x: x[-1] + } + window_data = [] + metadata = {k: [] for k in self.extra_attributes} for i, file in enumerate(self.data): # emg data windowing - windows = get_windows(file,window_size,window_increment) - if "windows_" in locals(): - windows_ = np.concatenate((windows_, windows)) - else: - windows_ = windows - # metadata windowing + file_windows = get_windows(file,window_size,window_increment) + if file_windows.shape[0] == 0: + # A file shorter than one window yields no windows at all (it + # used to yield a single short one). Skip it: there is nothing + # to stack, and the per-window metadata below would be empty. + continue + window_data.append(file_windows) + for k in self.extra_attributes: if type(getattr(self,k)[i]) != np.ndarray: - file_metadata = np.ones((windows.shape[0])) * getattr(self, k)[i] + file_metadata = np.ones((window_data[-1].shape[0])) * getattr(self, k)[i] else: if metadata_operations is not None: if k in metadata_operations.keys(): # do the specified operation - file_metadata = _get_fn_windows(getattr(self,k)[i], window_size, window_increment, metadata_operations[k]) + operation = metadata_operations[k] + + if isinstance(operation, str): + try: + operation = common_metadata_operations[operation] + except KeyError as e: + raise KeyError(f"Unexpected metadata operation string. Please pass in a function or an accepted string {tuple(common_metadata_operations.keys())}. Got: {operation}.") + file_metadata = _get_fn_windows(getattr(self,k)[i], window_size, window_increment, operation) else: file_metadata = _get_mode_windows(getattr(self,k)[i], window_size, window_increment) else: file_metadata = _get_mode_windows(getattr(self,k)[i], window_size, window_increment) - if k not in metadata_.keys(): - metadata_[k] = file_metadata - else: - metadata_[k] = np.concatenate((metadata_[k], file_metadata)) - + + metadata[k].append(file_metadata) - return windows_, metadata_ + return np.vstack(window_data), {k: np.concatenate(metadata[k], axis=0) for k in metadata.keys()} def isolate_channels(self, channels): @@ -461,7 +518,7 @@ def isolate_channels(self, channels): new_odh.data[i] = new_odh.data[i][:,channels] return new_odh - def isolate_data(self, key, values): + def isolate_data(self, key, values, fast=True): """Entry point for isolating a single key of data within the offline data handler. First, error checking is performed within this method, then if it passes, the isolate_data_helper is called to make a new OfflineDataHandler that contains only that data. @@ -471,6 +528,8 @@ def isolate_data(self, key, values): The metadata key that will be used to filter (e.g., "subject", "rep", "class", "set", whatever you'd like). values: list A list of values that you want to isolate. (e.g. [0,1,2,3]). Indexing starts at 0. + fast: Boolean (default=False) + If true, it iterates over the median value for each EMG element. This should be used when parsing on things like reps, subjects, classes, etc. Returns ---------- @@ -479,47 +538,31 @@ def isolate_data(self, key, values): """ assert key in self.extra_attributes assert type(values) == list - return self._isolate_data_helper(key,values) + return self._isolate_data_helper(key,values,fast) - def _isolate_data_helper(self, key, values): + def _isolate_data_helper(self, key, values,fast): new_odh = OfflineDataHandler() setattr(new_odh, "extra_attributes", self.extra_attributes) key_attr = getattr(self, key) - - # if these end up being ndarrays, it means that the metadata was IN the csv file. - - if type(key_attr[0]) == np.ndarray: - # for every file (list element) - data = [] - for f in range(len(key_attr)): - # get the keep_mask + for e in self.extra_attributes: + setattr(new_odh, e, []) + + for f in range(len(key_attr)): + if fast: + if key_attr[f][0][0] in values: + keep_mask = [True] * len(key_attr[f]) + else: + keep_mask = [False] * len(key_attr[f]) + else: keep_mask = list([i in values for i in key_attr[f]]) - # append the valid data - if self.data[f][keep_mask,:].shape[0]> 0: - data.append(self.data[f][keep_mask,:]) - setattr(new_odh, "data", data) + + if self.data[f][keep_mask,:].shape[0]> 0: + new_odh.data.append(self.data[f][keep_mask,:]) + for e in self.extra_attributes: + updated_arr = getattr(new_odh, e) + updated_arr.append(getattr(self, e)[f][keep_mask]) + setattr(new_odh, e, updated_arr) - for k in self.extra_attributes: - key_value = getattr(self, k) - if type(key_value[0]) == np.ndarray: - # the other metadata that is in the csv file should be sliced the same way as the ndarray - key = [] - for f in range(len(key_attr)): - keep_mask = list([i in values for i in key_attr[f]]) - if key_value[f][keep_mask,:].shape[0]>0: - key.append(key_value[f][keep_mask,:]) - setattr(new_odh, k, key) - - else: - assert False # we should never get here - # # if the other metadata was not in the csv file (i.e. subject label in filename but classes in csv), then just keep it - # setattr(new_odh, k, key_value) - else: - assert False # we should never get here - # keep_mask = list([i in values for i in key_attr]) - # setattr(new_odh, "data", list(compress(self.data, keep_mask))) - # for k in self.extra_attributes: - # setattr(new_odh, k,list(compress(getattr(self, k), keep_mask))) return new_odh def visualize(): @@ -536,17 +579,51 @@ class OnlineDataHandler(DataHandler): ---------- shared_memory_items: Object The shared memory object returned from the streamer. + channel_mask: list or None (optional), default=None + Mask of active channels to use online. Allows certain channels to be ignored when streaming in real-time. If None, all channels are used. + Defaults to None. """ - def __init__(self, shared_memory_items): + def __init__(self, shared_memory_items, channel_mask = None): self.shared_memory_items = shared_memory_items self.prepare_smm() self.log_signal = Event() self.visualize_signal = Event() self.fi = None - + self.channel_mask = channel_mask + # File logging state. log_to_file() spawns a process, start_log() runs a + # thread against the shared memory this process already holds open. + self._log_process = None + self._log_thread = None + self._log_stop_event = None + self._log_counts = {} + # Samples the last start_log() recording lost to shared memory being + # overwritten before the logger could read it. See stop_log(). + self.log_dropped = {} + + def __getstate__(self): + # Thread/process handles are local to the interpreter that made them. + # Drop them so the handler stays picklable for log_to_file()'s Process. + state = self.__dict__.copy() + state["_log_process"] = None + state["_log_thread"] = None + state["_log_stop_event"] = None + return state + def prepare_smm(self): + # visualize() re-runs this on every call. Building a fresh manager + # without releasing the previous one leaks an OS handle per segment per + # call, so close what we already hold first. parent=False closes only + # *our* handles -- parent=True would unlink the segments and destroy the + # streamer's buffers out from under it. + previous = getattr(self, "smm", None) + if previous is not None: + previous.cleanup(parent=False) self.modalities = [] - self.smm = SharedMemoryManager() + # The pool is what lets a writer in another process wake an observer + # here. Sharing the process-wide one means a streamer started earlier + # in the same script can reach whatever is built later. + from libemg.reactive import default_notifier_pool + self.smm = SharedMemoryManager(notifier_pool=default_notifier_pool()) for i in self.shared_memory_items: counter = 0 while not self.smm.find_variable(*i): @@ -565,9 +642,37 @@ def stop_all(self): self.stop_visualize() def stop_log(self): - self.log_signal.set() - time.sleep(0.5) - self.log_signal.clear() + """Stop any active file logging. + + A logger started by :meth:`start_log` is torn down promptly: the call + returns once the logging thread has drained the samples that arrived up + to this instant and closed its files. + + Returns + ---------- + counts: dict + A dictionary keyed by modality holding the number of samples the + thread logger wrote. Empty when no thread logger was running. + ``log_dropped`` holds, for the same recording, the samples shared + memory overwrote before the logger could read them; anything + non-zero there means the file it just closed is missing data. + """ + counts = {} + thread = getattr(self, "_log_thread", None) + if thread is not None: + self._log_stop_event.set() + thread.join(timeout=5) + counts = dict(self._log_counts) + self._log_thread = None + self._log_stop_event = None + # Only signal the spawned logger when one could be listening, otherwise + # every stop pays the half second the process needs to notice the flag. + if thread is None or getattr(self, "_log_process", None) is not None: + self.log_signal.set() + time.sleep(0.5) + self.log_signal.clear() + self._log_process = None + return counts def stop_visualize(self): self.visualize_signal.set() @@ -584,6 +689,17 @@ def install_filter(self, fi): """ self.fi = fi + def install_channel_mask(self, mask): + """Install a channel mask to isolate certain channels for online streaming. + + Parameters + ---------- + mask: list or None (optional), default=None + Mask of active channels to use online. Allows certain channels to be ignored when streaming in real-time. If None, all channels are used. + Defaults to None. + """ + self.channel_mask = mask + def analyze_hardware(self, analyze_time=10): """Analyzes several metrics from the hardware: @@ -641,10 +757,16 @@ def visualize(self, num_samples=500, block=True): if block: self._visualize(num_samples) else: - p = Process(target=self._visualize, kwargs={"num_samples":num_samples}) + p = Process(target=self._visualize, kwargs={"num_samples":num_samples}, daemon=True) p.start() def _visualize(self, num_samples): + # Built and shown one frame down so that nothing of the window outlives + # the call, and the collection that follows can finalize it here rather + # than leaving it for a worker thread. See _release_interactive_plot. + _release_interactive_plot(lambda: self._show_raw_data_plot(num_samples)) + + def _show_raw_data_plot(self, num_samples): self.prepare_smm() pyplot.style.use('ggplot') @@ -655,7 +777,15 @@ def on_close(event): fig.canvas.mpl_connect('close_event', on_close) fig.suptitle('Raw Data', fontsize=16) for i,mod in enumerate(self.modalities): - num_channels = self.smm.get_variable(mod).shape[1] + # Read the channel count from the recorded shape metadata rather + # than get_variable(), which copies the whole buffer under the + # writer's lock just to look at .shape[1]. A channel mask narrows + # what get_data() (and so update() below) actually returns, so the + # mask has to be applied here too or the plot lines and the data + # they are fed come out misaligned. + num_channels = self.smm.variables[mod]["shape"][1] + if self.channel_mask is not None: + num_channels = len(np.arange(num_channels)[self.channel_mask]) for j in range(0,num_channels): plots.append(ax[i][0].plot([],[],label=mod+"_CH"+str(j+1))) @@ -683,12 +813,15 @@ def update(frame): ax[i][0].set_title(self.modalities[i]) return plots, - while True: - animation = FuncAnimation(fig, update, interval=100, repeat=False) - pyplot.show() - if self.visualize_signal.is_set(): - print("ODH->visualize ended.") - break + try: + while True: + animation = FuncAnimation(fig, update, interval=100, repeat=False) + pyplot.show() + if self.visualize_signal.is_set(): + print("ODH->visualize ended.") + break + finally: + pyplot.close(fig) def visualize_channels(self, channels, num_samples=500, y_axes=None): """Visualize individual channels (each channel in its own plot). @@ -702,6 +835,12 @@ def visualize_channels(self, channels, num_samples=500, y_axes=None): y_axes: list (optional) A list of two elements consisting of the y-axes. """ + # One frame down, so the window can be disposed of when it returns. + # See _release_interactive_plot. + _release_interactive_plot( + lambda: self._show_channel_plots(channels, num_samples, y_axes)) + + def _show_channel_plots(self, channels, num_samples, y_axes): self.prepare_smm() pyplot.style.use('ggplot') while not self._check_streaming(): @@ -730,8 +869,11 @@ def update(frame): return emg_plots, animation = FuncAnimation(fig, update, interval=100) - pyplot.show() - + try: + pyplot.show() + finally: + pyplot.close(fig) + def visualize_heatmap(self, num_samples = 500, feature_list = None, remap_function = None, cmap = None): """Visualize heatmap representation of EMG signals. This is commonly used to represent HD-EMG signals. @@ -749,6 +891,12 @@ def visualize_heatmap(self, num_samples = 500, feature_list = None, remap_functi cmap: colormap or None (optional), default=None matplotlib colormap used to plot heatmap. """ + # One frame down, so the window can be disposed of when it returns. + # See _release_interactive_plot. + _release_interactive_plot( + lambda: self._show_heatmap(num_samples, feature_list, remap_function, cmap)) + + def _show_heatmap(self, num_samples, feature_list, remap_function, cmap): # Create figure pyplot.style.use('ggplot') if not self._check_streaming(): @@ -771,7 +919,8 @@ def extract_data(): # Extract features along each channel windows = data[np.newaxis].transpose(0, 2, 1) # add axis and tranpose to convert to (windows x channels x samples) fe = FeatureExtractor() - feature_set_dict = fe.extract_features(feature_list, windows) + feature_set_dict = fe.extract_features(feature_list, windows, array=False) + assert isinstance(feature_set_dict, dict), f"Expected dictionary of features. Got: {type(feature_set_dict)}." if remap_function is not None: # Remap raw data to image format for key in feature_set_dict: @@ -798,6 +947,8 @@ def extract_data(): # Format figure sample_data = extract_data() # access sample data to determine heatmap size fig, axs = plt.subplots(len(sample_data.keys()), 1) + if isinstance(axs, Axes): + axs = np.array([axs]) fig.suptitle(f'HD-EMG Heatmap') plots = [] for (feature_key, feature_data), ax in zip(sample_data.items(), axs): @@ -830,7 +981,10 @@ def update(frame): return plots, animation = FuncAnimation(fig, update, interval=100) - pyplot.show() + try: + pyplot.show() + finally: + pyplot.close(fig) # TODO: Update this # def visualize_feature_space(self, feature_dic, window_size, window_increment, sampling_rate, hold_samples=20, projection="PCA", classes=None, normalize=True): @@ -940,7 +1094,11 @@ def get_data(self, N=0, filter=True): val = {} count = {} for mod in self.modalities: - data = self.smm.get_variable(mod) + # Read the buffer and its sample counter as a single atomic + # snapshot. They share a lock (see assign_shared_memory_locks), so + # the count can never run ahead of the data copied alongside it. + snapshot = self.smm.get_variables([mod, mod + "_count"]) + data = snapshot[mod] if filter: if self.fi is not None: if mod == "emg": # TODO: enable filter for each modality @@ -949,9 +1107,92 @@ def get_data(self, N=0, filter=True): val[mod] = data[:N,:] else: val[mod] = data[:,:] - count[mod] = self.smm.get_variable(mod+"_count") + if self.channel_mask is not None: + val[mod] = val[mod][:, self.channel_mask] + count[mod] = snapshot[mod + "_count"] return val,count + def get_state(self, modality=None): + """Read what has happened to a modality without copying its data. + + This is the cheap question :meth:`get_data` cannot answer cheaply: how + many samples have arrived, how many writes there have been, whether the + streamer has stopped. It reads a handful of integers rather than the + whole buffer, which is why the reactive layer can afford to ask it + often. + + Parameters + ---------- + modality: str or None (optional), default=None + The modality to inspect. If None, all modalities are returned. + + Returns + ---------- + Snapshot or dict + A :class:`~libemg.shared_memory_manager.Snapshot` for a single + modality, or a dict of them keyed by modality. + + Examples + --------- + >>> odh.get_state('emg').total_samples + 14000 + """ + if modality is None: + return self.smm.snapshots(self.modalities) + return self.smm.snapshot(modality) + + def install_hook(self, hook, executor="odh"): + """Run a hook against the data this handler is receiving. + + A convenience over building a :class:`~libemg.reactive.ReactiveGraph` + by hand, for the common case of hanging one or two observers off a live + stream. Hooks are started by :meth:`start_hooks` and stopped by + :meth:`stop_hooks`. + + Parameters + ---------- + hook: libemg.reactive.Hook + What to run. Its inputs should name this handler's modalities. + executor: str (optional), default='odh' + Which process to run it in. Hooks sharing a name share a process. + + Examples + --------- + >>> from libemg.reactive import ProbeHook + >>> odh.install_hook(ProbeHook('watch', 'emg', print, hz=5)) + >>> odh.start_hooks() + """ + from libemg.reactive import ReactiveGraph, default_notifier_pool + if getattr(self, "_hook_graph", None) is None: + self._hook_graph = ReactiveGraph( + self.shared_memory_items, + log=getattr(self, "_event_log", None), + notifier_pool=default_notifier_pool()) + self._hook_graph.add(hook, executor=executor) + return self._hook_graph + + def start_hooks(self): + """Start the hooks registered with :meth:`install_hook`.""" + if getattr(self, "_hook_graph", None) is not None: + self._hook_graph.start() + + def stop_hooks(self): + """Stop the hooks registered with :meth:`install_hook`.""" + if getattr(self, "_hook_graph", None) is not None: + self._hook_graph.stop() + + def install_event_log(self, log): + """Record hook activity on this handler's data. + + Parameters + ---------- + log: libemg.event_log.EventLog + Install it before :meth:`install_hook`, since the graph is built on + first registration. + """ + self._event_log = log + self.smm.log = log + def reset(self, modality=None): """Reset the data within the shared memory buffer. @@ -967,10 +1208,22 @@ def reset(self, modality=None): for mod in modality: self.smm.modify_variable(mod, lambda x: np.zeros_like(x)) self.smm.modify_variable(mod+"_count", lambda x: np.zeros_like(x)) - - def log_to_file(self, block=False, file_path='', timestamps=True): + # The stateful counters have to go back to zero alongside the + # buffer, and the epoch has to advance so observers can tell this + # apart from a long silence. An observer that missed the reset + # would compare its consumed-sample count against a total that had + # gone backwards and conclude nothing had arrived, or fire + # immediately on a buffer that is now all zeros. + self.smm.reset_state(mod) + + def log_to_file(self, block=False, file_path='', timestamps=True, delimiter=','): """Logs the raw data being read to a file. + The logger runs in a spawned process, so it only becomes active once a + fresh interpreter has started. Use :meth:`start_log` instead when the + logging window has to line up tightly with something else (e.g. a + prompt shown during screen guided training). + Parameters ---------- block: bool (optional), default=False @@ -979,47 +1232,142 @@ def log_to_file(self, block=False, file_path='', timestamps=True): The prefix to the file path that will be logged for each modality. timestamps: bool (optional), default=True If true, this will log the timestamps with each recording. + delimiter: str (optional), default=',' + The delimiter separating columns in the log files. Matches the + default of :meth:`OfflineDataHandler.get_data` so logged files can + be read back without extra configuration. """ print("ODH->log_to_file begin.") self.file_path = file_path self.timestamps = timestamps + self.delimiter = delimiter if block: self._log_to_file() - print("ODH->log_to_file ended.") else: - p = Process(target=self._log_to_file) + p = Process(target=self._log_to_file, daemon=True) p.start() + self._log_process = p - def _log_to_file(self): + def start_log(self, file_path='', timestamps=True, delimiter=',', poll_delay=0.005): + """Logs the raw data being read to a file from a background thread. - files = {} - # start shared memory manager to access sensor + The thread reads the shared memory this process already has open, so + logging starts as soon as this call is made rather than after a process + spawn. The call only returns once the logger has recorded its baseline + sample counters, meaning every sample produced from here on is written + to file. Call :meth:`stop_log` to end the recording. + + Files are opened in append mode, matching :meth:`log_to_file`. Delete + the previous files first if a recording should not be added onto. + + Parameters + ---------- + file_path: str (optional), default='' + The prefix to the file path that will be logged for each modality. + The modality and '.csv' are appended to it. + timestamps: bool (optional), default=True + If true, this will log the timestamps with each recording. + delimiter: str (optional), default=',' + The delimiter separating columns in the log files. + poll_delay: float (optional), default=0.005 + Seconds to wait between reads of shared memory. Keep it well under + the time the device takes to fill its shared memory buffer. + """ + if getattr(self, "_log_thread", None) is not None: + self.stop_log() + self._log_counts = {} + self.log_dropped = {} + self._log_stop_event = threading.Event() + armed = threading.Event() + thread = threading.Thread( + target=self._run_log_loop, + args=(self._log_stop_event.is_set, file_path, timestamps, delimiter), + kwargs={"poll_delay": poll_delay, "armed": armed, "counts_out": self._log_counts, + "dropped_out": self.log_dropped}, + daemon=True) + thread.start() + self._log_thread = thread + if not armed.wait(timeout=5): + self._log_stop_event.set() + self._log_thread = None + raise RuntimeError("Timed out waiting for the file logger to read the shared memory sample counters.") + + def _log_to_file(self): + # Entry point for the process spawned by log_to_file. Shared memory has + # to be re-attached here because this runs in a fresh interpreter. self.smm = SharedMemoryManager() for item in self.shared_memory_items: self.smm.find_variable(*item) - # initialize sample count for all modalities + self._run_log_loop(self.log_signal.is_set, self.file_path, self.timestamps, + getattr(self, "delimiter", ",")) + + def _run_log_loop(self, should_stop, file_path, timestamps, delimiter, + poll_delay=0.0, armed=None, counts_out=None, dropped_out=None): + """Append every newly arrived sample to a file, per modality. + + Parameters + ---------- + should_stop: callable + Polled once per pass; the loop exits after the pass that returns True, + so the samples produced up to that point are still written. + armed: threading.Event or None + Set once the baseline sample counters have been read, i.e. once no + further samples can be missed. + counts_out: dict or None + Updated in place with the number of samples logged per modality. + dropped_out: dict or None + Updated in place with the number of samples per modality that shared + memory overwrote before this loop could read them. Non-zero means the + file has a hole in it: the samples either side of the gap are written + adjacent to each other, so nothing in the file marks where it is. + """ + files = {} + # Baseline the counters so only data produced from this point on is + # logged. The counter is written after the samples it counts, so reading + # it alone is enough to fix the starting point. last_count = {} for m in self.modalities: - last_count[m] = 0 - while True: - timestamp = time.time() - vals, counts = self.get_data(N=0, filter=False) - for m in vals.keys(): - new_count = counts[m][0,0] - num_new_samples = new_count - last_count[m] - new_samples = vals[m][:num_new_samples,:] - last_count[m] = new_count - if num_new_samples: + last_count[m] = int(self.smm.get_variable(m + "_count")[0, 0]) + if counts_out is not None: + counts_out[m] = 0 + if dropped_out is not None: + dropped_out[m] = 0 + if armed is not None: + armed.set() + try: + while True: + timestamp = time.time() + for m in self.modalities: + new_count, new_samples, dropped = self.smm.get_samples_since(m, last_count[m]) + last_count[m] = new_count + if dropped: + if dropped_out is not None: + dropped_out[m] = dropped_out.get(m, 0) + dropped + print(f"ODH->log_to_file: {dropped} {m} samples " + "were overwritten in shared memory before they could be logged.") + if new_samples.shape[0] == 0: + continue + if self.channel_mask is not None: + new_samples = new_samples[:, self.channel_mask] + # Shared-memory buffers are newest-first. Restore chronological + # order before appending each batch to the log file. + new_samples = np.flip(new_samples, axis=0) if not m in files.keys(): - files[m] = open(self.file_path + m + '.csv', "a", newline='') - if self.timestamps: - np.savetxt(files[m], np.hstack((np.ones((new_samples.shape[0],1))*timestamp, new_samples))) - # check to see if they're in the right order, or if they need to be reversed again! + files[m] = open(file_path + m + '.csv', "a", newline='') + if timestamps: + np.savetxt(files[m], np.hstack((np.ones((new_samples.shape[0],1))*timestamp, new_samples)), delimiter=delimiter) else: - np.savetxt(files[m], new_samples) - if self.log_signal.is_set(): - print("ODH->log_to_file ended.") - break + np.savetxt(files[m], new_samples, delimiter=delimiter) + if counts_out is not None: + counts_out[m] = counts_out.get(m, 0) + new_samples.shape[0] + if should_stop(): + break + if poll_delay: + time.sleep(poll_delay) + finally: + print("ODH->log_to_file ended.") + for file in files.values(): + file.close() def _check_streaming(self, timeout=15): wt = time.time() diff --git a/libemg/datasets.py b/libemg/datasets.py index 04f67e8c..fe6f95ce 100644 --- a/libemg/datasets.py +++ b/libemg/datasets.py @@ -1,490 +1,495 @@ -import os +from libemg._datasets._3DC import _3DCDataset +from libemg._datasets.one_subject_myo import OneSubjectMyoDataset +from libemg._datasets.one_subject_emager import OneSubjectEMaGerDataset +from libemg._datasets.emg_epn612 import EMGEPN_UserDependent, EMGEPN_UserIndependent +from libemg._datasets.ciil import CIIL_MinimalData, CIIL_ElectrodeShift, CIIL_WeaklySupervised +from libemg._datasets.grab_myo import GRABMyoBaseline, GRABMyoCrossDay +from libemg._datasets.continous_transitions import ContinuousTransitions +from libemg._datasets.nina_pro import NinaproDB2, NinaproDB8 +from libemg._datasets.user_compliance import UserComplianceDataset +from libemg._datasets.fors_emg import FORSEMG +from libemg._datasets.radmand_lp import RadmandLP +from libemg._datasets.fougner_lp import FougnerLP +from libemg._datasets.intensity import ContractionIntensity +from libemg._datasets.hyser import Hyser1DOF, HyserNDOF, HyserRandom, HyserPR, HyserMVC # HyserMVC is not used in this script but is imported so it's public in libemg API +from libemg._datasets.kaufmann_md import KaufmannMD +from libemg._datasets.tmr_shirleyryanabilitylab import TMR_Post, TMR_Pre +from libemg.feature_extractor import FeatureExtractor +from libemg.emg_predictor import EMGClassifier, EMGRegressor +from libemg.offline_metrics import OfflineMetrics +from libemg.filtering import Filter +from libemg._datasets.emg2pose import EMG2POSEUD, EMG2POSECU +from sklearn.preprocessing import StandardScaler +import pickle import numpy as np -import zipfile -import scipy.io as sio -from libemg.data_handler import ColumnFetch, MetadataFetcher, OfflineDataHandler, RegexFilter, FilePackager -from libemg.utils import make_regex -from glob import glob -from os import walk -from pathlib import Path -from datetime import datetime -# this assumes you have git downloaded (not pygit, but the command line program git) - -class Dataset: - def __init__(self, save_dir='.', redownload=False): - self.save_dir = save_dir - self.redownload=redownload - - def download(self, url, dataset_name): - clone_command = "git clone " + url + " " + dataset_name - os.system(clone_command) + +def get_dataset_list(type='CLASSIFICATION', cross_user=False): + """Gets a list of all available datasets. + + Parameters + ---------- + type: str (default='CLASSIFICATION') + The type of datasets to return. Valid Options: 'CLASSIFICATION', 'REGRESSION', 'WEAKLYSUPERVISED', and 'ALL'. + cross_user: bool (default=False) + + + Returns + ---------- + dictionary + A dictionary with the all available datasets and their respective classes. + """ + type = type.upper() + if type not in ['CLASSIFICATION', 'REGRESSION', 'WEAKLYSUPERVISED', 'CROSSUSER', 'ALL']: + print('Valid Options for type parameter: \'CLASSIFICATION\', \'REGRESSION\', or \'ALL\'.') + return {} + + cross_user_classification = { + 'EMGEPN612': EMGEPN_UserIndependent, + } + + cross_user_regression = { + 'EMG2POSE': EMG2POSECU, + } - def remove_dataset(self, dataset_folder): - remove_command = "rm -rf " + dataset_folder - os.system(remove_command) - - def check_exists(self, dataset_folder): - return os.path.exists(dataset_folder) - - def prepare_data(self, format=OfflineDataHandler): - pass - - -class _3DCDataset(Dataset): - def __init__(self, save_dir='.', redownload=False, dataset_name="_3DCDataset"): - Dataset.__init__(self, save_dir, redownload) - self.url = "https://github.com/libemg/3DCDataset" - self.dataset_name = dataset_name - self.dataset_folder = os.path.join(self.save_dir , self.dataset_name) - self.class_list = ["Neutral", "Radial Deviation", "Wrist Flexion", "Ulnar Deviation", "Wrist Extension", "Supination", - "Pronation", "Power Grip", "Open Hand", "Chuck Grip", "Pinch Grip"] - - if (not self.check_exists(self.dataset_folder)): - self.download(self.url, self.dataset_folder) - elif (self.redownload): - self.remove_dataset(self.dataset_folder) - self.download(self.url, self.dataset_folder) - - - - def prepare_data(self, format=OfflineDataHandler, subjects_values = [str(i) for i in range(1,23)], - sets_values = ["train", "test"], - reps_values = ["0","1","2","3"], - classes_values = [str(i) for i in range(11)]): - if format == OfflineDataHandler: - regex_filters = [ - RegexFilter(left_bound = "/", right_bound="/EMG", values = sets_values, description='sets'), - RegexFilter(left_bound = "_", right_bound=".txt", values = classes_values, description='classes'), - RegexFilter(left_bound = "EMG_gesture_", right_bound="_", values = reps_values, description='reps'), - RegexFilter(left_bound="Participant", right_bound="/",values=subjects_values, description='subjects') - ] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") - return odh - -class Ninapro(Dataset): - def __init__(self, save_dir='.', dataset_name="Ninapro"): - # downloading the Ninapro dataset is not supported (no permission given from the authors)' - # however, you can download it from http://ninapro.hevs.ch/DB8 - # the subject zip files should be placed at: /NinaproDB8/DB8_s#.zip - Dataset.__init__(self, save_dir) - self.dataset_name = dataset_name - self.dataset_folder = os.path.join(self.save_dir , self.dataset_name, "") - self.exercise_step = [] + classification = { + 'OneSubjectMyo': OneSubjectMyoDataset, + '3DC': _3DCDataset, + 'MinimalTrainingData': CIIL_MinimalData, + 'ElectrodeShift': CIIL_ElectrodeShift, + 'GRABMyoBaseline': GRABMyoBaseline, + 'GRABMyoCrossDay': GRABMyoCrossDay, + 'ContinuousTransitions': ContinuousTransitions, + 'NinaProDB2': NinaproDB2, + 'FORS-EMG': FORSEMG, + 'EMGEPN612': EMGEPN_UserDependent, + 'ContractionIntensity': ContractionIntensity, + 'RadmandLP': RadmandLP, + 'FougnerLP': FougnerLP, + 'KaufmannMD': KaufmannMD, + 'TMR_Post' : TMR_Post, + 'TMR_Pre': TMR_Pre, + 'HyserPR': HyserPR, + } + + regression = { + 'OneSubjectEMaGer': OneSubjectEMaGerDataset, + 'NinaProDB8': NinaproDB8, + 'Hyser1DOF': Hyser1DOF, + 'HyserNDOF': HyserNDOF, + 'HyserRandom': HyserRandom, + 'UserCompliance': UserComplianceDataset, + 'EMG2POSE': EMG2POSEUD + } + + weaklysupervised = { + 'WeaklySupervised': CIIL_WeaklySupervised + } - def convert_to_compatible(self): - # get the zip files (original format they're downloaded in) - zip_files = find_all_files_of_type_recursively(self.dataset_folder,".zip") - # unzip the files -- if any are there (successive runs skip this) - for zip_file in zip_files: - with zipfile.ZipFile(zip_file, 'r') as zip_ref: - zip_ref.extractall(zip_file[:-4]+'/') - os.remove(zip_file) - # get the mat files (the files we want to convert to csv) - mat_files = find_all_files_of_type_recursively(self.dataset_folder,".mat") - for mat_file in mat_files: - self.convert_to_csv(mat_file) + if type == 'CLASSIFICATION': + if cross_user: + return cross_user_classification + return classification + elif type == 'REGRESSION': + if cross_user: + return cross_user_regression + return regression + elif type == "WEAKLYSUPERVISED": + return weaklysupervised + else: + # Concatenate all datasets + classification.update(regression) + classification.update(weaklysupervised) + return classification - def convert_to_csv(self, mat_file): - # read the mat file - mat_file = mat_file.replace("\\", "/") - mat_dir = mat_file.split('/') - mat_dir = os.path.join(*mat_dir[:-1],"") - mat = sio.loadmat(mat_file) - # get the data - exercise = int(mat_file.split('_')[3][1]) - exercise_offset = self.exercise_step[exercise-1] # 0 reps already included - data = mat['emg'] - restimulus = mat['restimulus'] - rerepetition = mat['rerepetition'] - if data.shape[0] != restimulus.shape[0]: # this happens in some cases - min_shape = min([data.shape[0], restimulus.shape[0]]) - data = data[:min_shape,:] - restimulus = restimulus[:min_shape,] - rerepetition = rerepetition[:min_shape,] - # remove 0 repetition - collection buffer - remove_mask = (rerepetition != 0).squeeze() - data = data[remove_mask,:] - restimulus = restimulus[remove_mask] - rerepetition = rerepetition[remove_mask] - # important little not here: - # the "rest" really is only the rest between motions, not a dedicated rest class. - # there will be many more rest repetitions (as it is between every class) - # so usually we really care about classifying rest as its important (most of the time we do nothing) - # but for this dataset it doesn't make sense to include (and not its just an offline showcase of the library) - # I encourage you to plot the restimulus to see what I mean. -> plt.plot(restimulus) - # so we remove the rest class too - remove_mask = (restimulus != 0).squeeze() - data = data[remove_mask,:] - restimulus = restimulus[remove_mask] - rerepetition = rerepetition[remove_mask] - tail = 0 - while tail < data.shape[0]-1: - rep = rerepetition[tail][0] # remove the 1 offset (0 was the collection buffer) - motion = restimulus[tail][0] # remove the 1 offset (0 was between motions "rest") - # find head - head = np.where(rerepetition[tail:] != rep)[0] - if head.shape == (0,): # last segment of data - head = data.shape[0] -1 +def get_dataset_info(dataset): + """Prints out the information about a certain dataset. + + Parameters + ---------- + dataset: string + The name of the dataset you want the information of. + """ + if dataset in get_dataset_list(): + get_dataset_list()[dataset]().get_info() + else: + print("ERROR: Invalid dataset name") + +def evaluate(model, window_size, window_inc, feature_list=['MAV'], feature_dic={}, included_datasets=['OneSubjectMyo', '3DC'], output_file='out.pkl', regression=False, metrics=['CA'], normalize_data=False, normalize_features=False): + """Evaluates an algorithm against all included datasets. + + Parameters + ---------- + window_size: int + The window size (**in ms**). + window_inc: int + The window increment (**in ms**). + feature_list: list (default=['MAV']) + A list of features. Pass in None for CNN. + feature_dic: dic (default={}) + A dictionary of parameters for the passed in features. + included_datasets: list (str) or list (DataSets) + The name of the datasets you want to evaluate your model on. Either pass in strings (e.g., '3DC') for names or the dataset objects (e.g., _3DCDataset()). + output_file: string (default='out.pkl') + The name of the directory you want to incrementally save the results to (it will be a pickle file). + regression: boolean (default=False) + If True, will create an EMGRegressor object. Otherwise creates an EMGClassifier object. + metrics: list (default=['CA']/['MSE']) + The metrics to extract from each dataset. + normalize_data: boolean (default=False) + If True, the data will be normalized. + normalize_features: boolean (default=False) + If True, features will get normalized. + Returns + ---------- + dictionary + A dictionary with a set of accuracies for different datasets + """ + + # -------------- Setup ------------------- + if metrics == ['CA'] and regression: + metrics = ['MSE'] + + metadata_operations = None + label_val = 'classes' + if regression: + metadata_operations = {'labels': 'last_sample'} + label_val = 'labels' + + om = OfflineMetrics() + + # --------------- Run ----------------- + accuracies = {} + for d_i, d in enumerate(included_datasets): + print(f"Evaluating {d} dataset...") + if isinstance(d, str): + dataset = get_dataset_list('ALL')[d]() + else: + dataset = d + + # Get feature dic + if isinstance(feature_dic, list): + f_dic = feature_dic[d_i] + else: + f_dic = feature_dic + + accs = [] + for s_i in range(0, dataset.num_subjects): + data = dataset.prepare_data(split=True, subjects=[s_i]) + + if data == None: + print('Skipping Subject... No data found.') + continue + + s_train_dh = data['Train'] + s_test_dh = data['Test'] + + print(str(s_i) + '/' + str(dataset.num_subjects) + ' completed.') + + # Normalize Data + if normalize_data: + filter = Filter(dataset.sampling) + filter.install_filters({'name': 'standardize', 'data': s_train_dh}) + filter.filter(s_train_dh) + filter.filter(s_test_dh) + + train_windows, train_meta = s_train_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + test_windows, test_meta = s_test_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + + if feature_list is not None: + fe = FeatureExtractor() + if normalize_features: + train_feats, scaler = fe.extract_features(feature_list, train_windows, feature_dic=f_dic, normalize=True, fix_feature_errors=True) + test_feats, _ = fe.extract_features(feature_list, test_windows, feature_dic=f_dic, normalize=True, normalizer=scaler, fix_feature_errors=True) + else: + train_feats = fe.extract_features(feature_list, train_windows, feature_dic=f_dic, fix_feature_errors=True) + test_feats = fe.extract_features(feature_list, test_windows, feature_dic=f_dic, fix_feature_errors=True) else: - head = head[0] + tail - # downsample to 1kHz from 2kHz using decimation - data_for_file = data[tail:head,:] - data_for_file = data_for_file[::2, :] - # write to csv - csv_file = mat_dir + 'C' + str(motion-1) + 'R' + str(rep-1 + exercise_offset) + '.csv' - np.savetxt(csv_file, data_for_file, delimiter=',') - tail = head - os.remove(mat_file) - -class NinaproDB8(Ninapro): - def __init__(self, save_dir='.', dataset_name="NinaproDB8"): - Ninapro.__init__(self, save_dir, dataset_name) - self.class_list = ["Thumb Flexion/Extension", "Thumb Abduction/Adduction", "Index Finger Flexion/Extension", "Middle Finger Flexion/Extension", "Combined Ring and Little Fingers Flexion/Extension", - "Index Pointer", "Cylindrical Grip", "Lateral Grip", "Tripod Grip"] - self.exercise_step = [0,10,20] - - def prepare_data(self, format=OfflineDataHandler, subjects_values = [str(i) for i in range(1,13)], - reps_values = [str(i) for i in range(22)], - classes_values = [str(i) for i in range(9)]): + train_feats = train_windows + test_feats = test_windows + + ds = { + 'training_features': train_feats, + 'training_labels': train_meta[label_val] + } + + if not regression: + clf = EMGClassifier(model) + else: + clf = EMGRegressor(model) + clf.fit(ds) + + if regression: + preds = clf.run(test_feats) + else: + preds, _ = clf.run(test_feats) + + metrics = om.extract_offline_metrics(metrics, test_meta[label_val], preds) + accs.append(metrics) + + print(metrics) + accuracies[str(d)] = accs + + with open(output_file, 'wb') as handle: + pickle.dump(accuracies, handle, protocol=pickle.HIGHEST_PROTOCOL) + + +def evaluate_crossuser(model, window_size, window_inc, feature_list=['MAV'], feature_dic={}, included_datasets=['EMGEPN612'], output_file='out_cross.pkl', regression=False, metrics=['CA'], normalize_data=False, normalize_features=False, memory_efficient=False): + """Evaluates an algorithm against all the cross-user datasets. + + Parameters + ---------- + window_size: int + The window size (**in ms**). + window_inc: int + The window increment (**in ms**). + feature_list: list (default=['MAV']) + A list of features. + feature_dic: dic or list (default={}) + A dictionary or list of dictionaries of parameters for the passed in features. + included_datasets: list (str) or list (DataSets) + The name of the datasets you want to evaluate your model on. Either pass in strings (e.g., '3DC') for names or the dataset objects (e.g., _3DCDataset()). + output_file: string (default='out.pkl') + The name of the directory you want to incrementally save the results to (it will be a pickle file). + regression: boolean (default=False) + If True, will create an EMGRegressor object. Otherwise creates an EMGClassifier object. + metrics: list (default=['CA']) + The metrics to extract from each dataset. + normalize_data: boolean (default=False) + If True, the data will be normalized. + normalize_features: boolean (default=False) + If True, features will get normalized. + memory_efficient: boolean (default=false) + If True, features will be extracted in the prepare method. You wont have access to the raw data and normalization won't be possible. + Returns + ---------- + dictionary + A dictionary with a set of accuracies for different datasets + """ + # -------------- Setup ------------------- + if metrics == ['CA'] and regression: + metrics = ['MSE'] + + metadata_operations = None + label_val = 'classes' + if regression: + metadata_operations = {'labels': 'last_sample'} + label_val = 'labels' + + om = OfflineMetrics() + fe = FeatureExtractor() + + # --------------- Run ----------------- + accuracies = {} + for d_i, d in enumerate(included_datasets): + print(f"Evaluating {d} dataset...") + if isinstance(d, str): + if regression: + dataset = get_dataset_list('REGRESSION', True)[d]() + else: + dataset = get_dataset_list('CLASSIFICATION', True)[d]() + else: + dataset = d + + # Get feature dic + if isinstance(feature_dic, list): + f_dic = feature_dic[d_i] + else: + f_dic = feature_dic - if format == OfflineDataHandler: - regex_filters = [ - RegexFilter(left_bound = "/C", right_bound="R", values = classes_values, description='classes'), - RegexFilter(left_bound = "R", right_bound=".csv", values = reps_values, description='reps'), - RegexFilter(left_bound="DB8_s", right_bound="/",values=subjects_values, description='subjects') - ] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") - return odh - -class NinaproDB2(Ninapro): - def __init__(self, save_dir='.', dataset_name="NinaproDB2"): - Ninapro.__init__(self, save_dir, dataset_name) - self.class_list = ["TODO"] - self.exercise_step = [0,0,0] - - def prepare_data(self, format=OfflineDataHandler, subjects_values = [str(i) for i in range(1,41)], - reps_values = [str(i) for i in range(6)], - classes_values = [str(i) for i in range(50)]): + if memory_efficient: + data = dataset.prepare_data(split=True, feature_list=feature_list, feature_dic=f_dic, window_size=int(dataset.sampling/1000 * window_size), window_inc=int(dataset.sampling/1000 * window_inc)) + else: + data = dataset.prepare_data(split=True) - if format == OfflineDataHandler: - regex_filters = [ - RegexFilter(left_bound = "/C", right_bound="R", values = classes_values, description='classes'), - RegexFilter(left_bound = "R", right_bound=".csv", values = reps_values, description='reps'), - RegexFilter(left_bound="DB2_s", right_bound="/",values=subjects_values, description='subjects') - ] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") - return odh - -# given a directory, return a list of files in that directory matching a format -# can be nested -# this is just a handly utility -def find_all_files_of_type_recursively(dir, terminator): - files = os.listdir(dir) - file_list = [] - for file in files: - if file.endswith(terminator): - file_list.append(dir+file) + train_data = data['Train'] + test_data = data['Test'] + + # Normalize Data + if normalize_data and not memory_efficient: + filter = Filter(dataset.sampling) + filter.install_filters({'name': 'standardize', 'data': train_data}) + filter.filter(train_data) + filter.filter(test_data) + + if memory_efficient: + train_feats = np.vstack(train_data.data) + train_labels = np.vstack(getattr(train_data, label_val)).squeeze() + if normalize_features: + normalizer = StandardScaler() + train_feats = normalizer.fit_transform(train_feats) else: - if os.path.isdir(dir+file): - file_list += find_all_files_of_type_recursively(dir+file+'/',terminator) - return file_list - - -class OneSubjectMyoDataset(Dataset): - def __init__(self, save_dir='.', redownload=False, dataset_name="OneSubjectMyoDataset"): - Dataset.__init__(self, save_dir, redownload) - self.url = "https://github.com/libemg/OneSubjectMyoDataset" - self.dataset_name = dataset_name - self.dataset_folder = os.path.join(self.save_dir , self.dataset_name) - - if (not self.check_exists(self.dataset_folder)): - self.download(self.url, self.dataset_folder) - elif (self.redownload): - self.remove_dataset(self.dataset_folder) - self.download(self.url, self.dataset_folder) - - def prepare_data(self, format=OfflineDataHandler): - if format == OfflineDataHandler: - sets_values = ["1","2","3","4","5","6"] - classes_values = ["0","1","2","3","4"] - reps_values = ["0","1"] - regex_filters = [ - RegexFilter(left_bound = "/trial_", right_bound="/", values = sets_values, description='sets'), - RegexFilter(left_bound = "C_", right_bound=".csv", values = classes_values, description='classes'), - RegexFilter(left_bound = "R_", right_bound="_", values = reps_values, description='reps') - ] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, delimiter=",") - return odh - - -class _SessionFetcher(MetadataFetcher): - def __init__(self): - super().__init__('sessions') - - def __call__(self, filename, file_data, all_files): - def split_filename(f): - # Split date and name into separate variables - date_idx = f.find('2018') - date = datetime.strptime(Path(f[date_idx:]).stem, '%Y-%m-%d-%H-%M-%S-%f') - description = f[:date_idx] - return date, description - - data_file_date, data_file_description = split_filename(filename) - - # Grab the other file of a different date. Return the index of which session it is - same_subject_files = [f for f in all_files if data_file_description in f] - file_dates = [split_filename(subject_filename)[0] for subject_filename in same_subject_files] - file_dates.sort() - session_idx = file_dates.index(data_file_date) - return session_idx * np.ones((file_data.shape[0], 1), dtype=int) - - -class _RepFetcher(ColumnFetch): - def __call__(self, filename, file_data, all_files): - column_data = super().__call__(filename, file_data, all_files) + train_windows, train_meta = train_data.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + if normalize_features: + train_feats, normalizer = fe.extract_features(feature_list, train_windows, feature_dic=f_dic, normalize=True, fix_feature_errors=True) + else: + train_feats = fe.extract_features(feature_list, train_windows, feature_dic=f_dic, fix_feature_errors=True) + del train_windows + train_labels = train_meta[label_val] + + ds = { + 'training_features': train_feats, + 'training_labels': train_labels + } - # Get rep transitions - diff = np.diff(column_data, axis=0) - rep_end_row_mask, rep_end_col_mask = np.nonzero((diff < 0) & (column_data[1:] == 0)) - unique_rep_end_row_mask = np.unique(rep_end_row_mask) # remove duplicate start indices (for combined movements) - # rest_end_row_mask = np.nonzero(np.diff(np.nonzero(column_data == 0)[0]) > 1)[0] - # rest_end_row_mask = np.nonzero(np.diff(np.nonzero(np.all(column_data == 0, axis=1))[0]) > 1)[0] - # unique_rep_end_row_mask = np.concatenate((unique_rep_end_row_mask, rest_end_row_mask)) - # unique_rep_end_row_mask = np.sort(unique_rep_end_row_mask) - - - # Populate metadata array - metadata = np.empty((column_data.shape[0], 1), dtype=np.int16) - rep_counters = [0 for _ in range(5)] # 5 different press types - previous_rep_start = 0 - for idx, rep_start in enumerate(unique_rep_end_row_mask): - movement_idx = 4 if np.sum(rep_end_row_mask == rep_start) > 1 else rep_end_col_mask[idx] # if multiple columns are nonzero then it's a combined movement - rep = rep_counters[movement_idx] - metadata[previous_rep_start:rep_start] = rep - rep_counters[movement_idx] += 1 - previous_rep_start = rep_start - - # Fill in final samples - metadata[rep_start:] = rep - - return metadata - - -class PutEMGForceDataset(Dataset): - def __init__(self, save_dir = '.', dataset_name = 'PutEMGForceDataset', data_filetype = None): - """Dataset wrapper for putEMG-Force dataset. Used for regression of finger forces. - - Parameters - ---------- - save_dir : str, default='.' - Base data directory. - dataset_name : str, default='PutEMGForceDataset' - Name of dataset. Looks for dataset in filepath created by appending save_dir and dataset_name. - data_filetype : list or None, default=None - Type of data file to use. Accepted values are 'repeats_long', 'repeats_short', 'sequential', or any combination of those. If None is passed, all will be used. - """ - # TODO: Implement downloading dataset using .sh or .py file - super().__init__(save_dir) - self.dataset_name = dataset_name - self.dataset_folder = os.path.join(self.save_dir, self.dataset_name) - if data_filetype is None: - data_filetype = ['repeats_short', 'repeats_long', 'sequential'] - elif not isinstance(data_filetype, list): - data_filetype = [data_filetype] - self.data_filetype = data_filetype - - def prepare_data(self, format=OfflineDataHandler, subjects = None, sessions = None, reps = None, labels = 'forces', label_dof_mask = None): - if subjects is None: - subjects = [str(idx).zfill(2) for idx in range(60)] - - if labels == 'forces': - column_mask = np.arange(25, 35) - elif labels == 'trajectories': - column_mask = np.arange(36, 40) + if not regression: + clf = EMGClassifier(model) else: - raise ValueError(f"Expected either 'forces' or trajectories' for labels parameter, but received {labels}.") - - if label_dof_mask is not None: - column_mask = column_mask[label_dof_mask] - - if format == OfflineDataHandler: - regex_filters = [ - RegexFilter(left_bound='/emg_force-', right_bound='-', values=subjects, description='subjects'), - RegexFilter(left_bound='-', right_bound='-', values=self.data_filetype, description='data_filetype'), - ] - metadata_fetchers = [ - _SessionFetcher(), - ColumnFetch('labels', column_mask), - _RepFetcher('reps', list(range(36, 40))) - ] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers, delimiter=',', skiprows=1, data_column=list(range(1, 25))) - if sessions is not None: - odh = odh.isolate_data('sessions', sessions) - if reps is not None: - odh = odh.isolate_data('reps', reps) - return odh - - -class OneSubjectEMaGerDataset(Dataset): - def __init__(self, save_dir = '.', redownload = False, dataset_name = 'OneSubjectEMaGerDataset'): - super().__init__(save_dir, redownload) - self.url = 'https://github.com/LibEMG/OneSubjectEMaGerDataset' - self.dataset_name = dataset_name - self.dataset_folder = os.path.join(self.save_dir, self.dataset_name) - - if (not self.check_exists(self.dataset_folder)): - self.download(self.url, self.dataset_folder) - elif (self.redownload): - self.remove_dataset(self.dataset_folder) - self.download(self.url, self.dataset_folder) - - def prepare_data(self, format=OfflineDataHandler): - if format == OfflineDataHandler: - regex_filters = [ - RegexFilter(left_bound='/', right_bound='/', values=['open-close', 'pro-sup'], description='movements'), - RegexFilter(left_bound='_R_', right_bound='_emg.csv', values=[str(idx) for idx in range(5)], description='reps') - ] - package_function = lambda x, y: Path(x).parent.absolute() == Path(y).parent.absolute() - metadata_fetchers = [FilePackager(RegexFilter(left_bound='/', right_bound='.txt', values=['labels'], description='labels'), package_function)] - odh = OfflineDataHandler() - odh.get_data(folder_location=self.dataset_folder, regex_filters=regex_filters, metadata_fetchers=metadata_fetchers) - return odh - + clf = EMGRegressor(model) + clf.fit(ds) -# class GRABMyo(Dataset): -# def __init__(self, save_dir='.', redownload=False, subjects=list(range(1,44)), sessions=list(range(1,4)), dataset_name="GRABMyo"): -# Dataset.__init__(self, save_dir, redownload) -# self.url = "https://physionet.org/files/grabmyo/1.0.2/" -# self.dataset_name = dataset_name -# self.dataset_folder = os.path.join(self.save_dir , self.dataset_name) -# self.subjects = subjects -# self.sessions = sessions - -# if (not self.check_exists(self.dataset_folder)): -# self.download_data() -# elif (self.redownload): -# self.remove_dataset(self.dataset_folder) -# self.download_data() -# else: -# print("Data Already Downloaded.") - -# def download_data(self): -# curl_command = "curl --create-dirs" + " -O --output-dir " + str(self.dataset_folder) + "/ " -# # Download files -# print("Starting download...") -# files = ['readme.txt', 'subject-info.csv', 'MotionSequence.txt'] -# for f in files: -# os.system(curl_command + self.url + f) -# for session in self.sessions: -# curl_command = "curl --create-dirs" + " -O --output-dir " + str(self.dataset_folder) + "/" + "Session" + str(session) + "/ " -# for p in self.subjects: -# for t in range(1,8): -# for g in range(1,18): -# endpoint = self.url + "Session" + str(session) + "/session" + str(session) + "_participant" + str(p) + "/session" + str(session) + "_participant" + str(p) + "_gesture" + str(g) + "_trial" + str(t) -# os.system(curl_command + endpoint + '.hea') -# os.system(curl_command + endpoint + '.dat') -# print("Download complete.") - -# def prepare_data(self, format=OfflineDataHandler, subjects=[str(i) for i in range(1,44)], sessions=["1","2","3"]): -# if format == OfflineDataHandler: -# sets_regex = make_regex(left_bound = "session", right_bound="_", values = sessions) -# classes_values = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17"] -# classes_regex = make_regex(left_bound = "_gesture", right_bound="_", values = classes_values) -# reps_values = ["1","2","3","4","5","6","7"] -# reps_regex = make_regex(left_bound = "trial", right_bound=".hea", values = reps_values) -# subjects_regex = make_regex(left_bound="participant", right_bound="_",values=subjects) -# dic = { -# "sessions": sessions, -# "sessions_regex": sets_regex, -# "reps": reps_values, -# "reps_regex": reps_regex, -# "classes": classes_values, -# "classes_regex": classes_regex, -# "subjects": subjects, -# "subjects_regex": subjects_regex -# } -# odh = OfflineDataHandler() -# odh.get_data(folder_location=self.dataset_folder, filename_dic=dic, delimiter=",") -# return odh - -# def print_info(self): -# print('Reference: https://www.physionet.org/content/grabmyo/1.0.2/') -# print('Name: ' + self.dataset_name) -# print('Gestures: 17') -# print('Trials: 7') -# print('Time Per Rep: 5s') -# print('Subjects: 43') -# print("Forearm EMG (16): Columns 0-15\nWrist EMG (12): 18-23 and 26-31\nUnused (4): 16,23,24,31") - - -# class NinaDB1(Dataset): -# def __init__(self, dataset_dir, subjects): -# Dataset.__init__(self, dataset_dir) -# self.dataset_folder = dataset_dir -# self.subjects = subjects - -# if (not self.check_exists(self.dataset_folder)): -# print("The dataset does not currently exist... Please download it from: http://ninaweb.hevs.ch/data1") -# exit(1) -# else: -# filenames = next(walk(self.dataset_folder), (None, None, []))[2] -# if not any("csv" in f for f in filenames): -# self.setup(filenames) -# print("Extracted and set up repo.") -# self.prepare_data() - -# def setup(self, filenames): -# for f in filenames: -# if "zip" in f: -# file_path = os.path.join(self.dataset_folder, f) -# with zipfile.ZipFile(file_path, 'r') as zip_ref: -# zip_ref.extractall(self.dataset_folder) -# self.convert_data() - -# def convert_data(self): -# mat_files = [y for x in os.walk(self.dataset_folder) for y in glob(os.path.join(x[0], '*.mat'))] -# for f in mat_files: -# mat_dict = sio.loadmat(f) -# output_ = np.concatenate((mat_dict['emg'], mat_dict['restimulus'], mat_dict['rerepetition']), axis=1) -# mask_ids = output_[:,11] != 0 -# output_ = output_[mask_ids,:] -# np.savetxt(f[:-4]+'.csv', output_,delimiter=',') - -# def cleanup_data(self): -# mat_files = [y for x in os.walk(self.dataset_folder) for y in glob(os.path.join(x[0], '*.mat'))] -# zip_files = [y for x in os.walk(self.dataset_folder) for y in glob(os.path.join(x[0], '*.zip'))] -# files = mat_files + zip_files -# for f in files: -# os.remove(f) + del train_feats + del ds + + unique_subjects = np.unique(np.hstack([t.flatten() for t in test_data.subjects])) + + accs = [] + for s_i, s in enumerate(unique_subjects): + print(str(s_i) + '/' + str(len(unique_subjects)) + ' completed.') + s_test_dh = test_data.isolate_data('subjects', [s]) + + if memory_efficient: + test_feats = np.vstack(s_test_dh.data) + test_labels = np.vstack(getattr(s_test_dh, label_val)).squeeze() + if normalize_features: + test_feats = normalizer.transform(test_feats) + else: + test_windows, test_meta = s_test_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + if normalize_features: + test_feats, _ = fe.extract_features(feature_list, test_windows, feature_dic=f_dic, normalize=True, normalizer=normalizer, fix_feature_errors=True) + else: + test_feats = fe.extract_features(feature_list, test_windows, feature_dic=f_dic, fix_feature_errors=True) + test_labels = test_meta[label_val] + + if regression: + preds = clf.run(test_feats) + else: + preds, _ = clf.run(test_feats) + + metrics = om.extract_offline_metrics(metrics, test_labels, preds) + accs.append(metrics) + + print(metrics) + accuracies[str(d)] = accs + + with open(output_file, 'wb') as handle: + pickle.dump(accuracies, handle, protocol=pickle.HIGHEST_PROTOCOL) + +def evaluate_weaklysupervised(model, window_size, window_inc, feature_list=['MAV'], feature_dic={}, included_datasets=['CIIL_WeaklySupervised'], output_file='out.pkl', regression=False, metrics=['CA'], normalize_data=False, normalize_features=False): + """Evaluates an algorithm against all included datasets. -# def prepare_data(self, format=OfflineDataHandler): -# if format == OfflineDataHandler: -# classes_values = list(range(1,24)) -# classes_column = [10] -# classset_values = [str(i) for i in list(range(1,4))] -# classset_regex = make_regex(left_bound="_E", right_bound=".csv", values=classset_values) -# reps_values = list(range(1,11)) - -# reps_column = [11] -# subjects_values = [str(s) for s in self.subjects] -# subjects_regex = make_regex(left_bound="S", right_bound="_A", values=subjects_values) -# data_column = list(range(0,10)) -# dic = { -# "reps": reps_values, -# "reps_column": reps_column, -# "classes": classes_values, -# "classes_column": classes_column, -# "subjects": subjects_values, -# "subjects_regex": subjects_regex, -# "classset": classset_values, -# "classset_regex": classset_regex, -# "data_column": data_column -# } -# odh = OfflineDataHandler() -# odh.get_data(folder_location=self.dataset_folder, filename_dic=dic, delimiter=",") -# return odh + Parameters + ---------- + window_size: int + The window size (**in ms**). + window_inc: int + The window increment (**in ms**). + feature_list: list (default=['MAV']) + A list of features. Pass in None for CNN. + feature_dic: dic (default={}) + A dictionary of parameters for the passed in features. + included_datasets: list (str) or list (DataSets) + The name of the datasets you want to evaluate your model on. Either pass in strings (e.g., '3DC') for names or the dataset objects (e.g., _3DCDataset()). + output_file: string (default='out.pkl') + The name of the directory you want to incrementally save the results to (it will be a pickle file). + regression: boolean (default=False) + If True, will create an EMGRegressor object. Otherwise creates an EMGClassifier object. + metrics: list (default=['CA']/['MSE']) + The metrics to extract from each dataset. + normalize_data: boolean (default=False) + If True, the data will be normalized. + normalize_features: boolean (default=False) + If True, features will get normalized. + Returns + ---------- + dictionary + A dictionary with a set of accuracies for different datasets + """ + + # -------------- Setup ------------------- + if metrics == ['CA'] and regression: + metrics = ['MSE'] + + metadata_operations = None + label_val = 'classes' + if regression: + metadata_operations = {'labels': 'last_sample'} + label_val = 'labels' + + om = OfflineMetrics() + + # --------------- Run ----------------- + accuracies = {} + for d in included_datasets: + print(f"Evaluating {d} dataset...") + if isinstance(d, str): + dataset = get_dataset_list('WEAKLYSUPERVISED')[d]() + else: + dataset = d + + accs = [] + for s_i in range(0, dataset.num_subjects): + data = dataset.prepare_data(split=True, subjects=[s_i]) + + if data == None: + print('Skipping Subject... No data found.') + continue + + s_pretrain_dh = data['Pretrain'] + s_pretrain_dh.extra_attributes.remove('classes') + delattr(s_pretrain_dh,"classes") + s_train_dh = data['Train'] + s_test_dh = data['Test'] + + # Normalize Data + if normalize_data: + filter = Filter(dataset.sampling) + filter.install_filters({'name': 'standardize', 'data': s_pretrain_dh}) + filter.filter(s_pretrain_dh) + filter.filter(s_train_dh) + filter.filter(s_test_dh) + + pretrain_windows, pretrain_meta = s_pretrain_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + train_windows, train_meta = s_train_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + test_windows, test_meta = s_test_dh.parse_windows(int(dataset.sampling/1000 * window_size), int(dataset.sampling/1000 * window_inc), metadata_operations=metadata_operations) + + if feature_list is not None: + fe = FeatureExtractor() + if normalize_features: + pretrain_feats, scaler = fe.extract_features(feature_list, pretrain_windows, feature_dic=feature_dic, normalize=True, fix_feature_errors=True) + train_feats, _ = fe.extract_features(feature_list, train_windows, feature_dic=feature_dic, normalize=True, normalizer=scaler, fix_feature_errors=True) + test_feats, _ = fe.extract_features(feature_list, test_windows, feature_dic=feature_dic, normalize=True, normalizer=scaler, fix_feature_errors=True) + else: + pretrain_feats = fe.extract_features(feature_list, pretrain_windows, feature_dic=feature_dic, fix_feature_errors=True) + train_feats = fe.extract_features(feature_list, train_windows, feature_dic=feature_dic, fix_feature_errors=True) + test_feats = fe.extract_features(feature_list, test_windows, feature_dic=feature_dic, fix_feature_errors=True) + else: + pretrain_feats = pretrain_windows + train_feats = train_windows + test_feats = test_windows + + ds = { + 'training_features': train_feats, + 'training_labels': train_meta[label_val], + 'pretraining_features': pretrain_feats + } + + model.fit(ds) + + if not regression: + clf = EMGClassifier(model) + else: + clf = EMGRegressor(model) + + if regression: + preds = clf.run(test_feats) + else: + preds, _ = clf.run(test_feats) + + metrics = om.extract_offline_metrics(metrics, test_meta[label_val], preds) + accs.append(metrics) + print(str(s_i) + '/' + str(dataset.num_subjects) + ' completed.') + print(metrics) + accuracies[str(dataset)] = accs + + with open(output_file, 'wb') as handle: + pickle.dump(accuracies, handle, protocol=pickle.HIGHEST_PROTOCOL) \ No newline at end of file diff --git a/libemg/emg_predictor.py b/libemg/emg_predictor.py index 2528861c..b107e528 100644 --- a/libemg/emg_predictor.py +++ b/libemg/emg_predictor.py @@ -7,9 +7,8 @@ from sklearn.naive_bayes import GaussianNB from sklearn.neural_network import MLPClassifier, MLPRegressor from sklearn.svm import SVC, SVR -from libemg.feature_extractor import FeatureExtractor -from libemg.shared_memory_manager import SharedMemoryManager -from multiprocessing import Process, Lock +from sklearn.preprocessing import StandardScaler +from multiprocessing import Process, Lock, Event import numpy as np import pickle import socket @@ -23,36 +22,75 @@ from scipy import stats import csv from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import re +from matplotlib.animation import FuncAnimation +from functools import partial +from libemg.feature_extractor import FeatureExtractor +from libemg.shared_memory_manager import SharedMemoryManager from libemg.utils import get_windows - +from libemg.environments.controllers import RegressorController, ClassifierController +from libemg.data_handler import OnlineDataHandler + +# The models each predictor accepts, as {name: (class, default parameters)}. +# Module level so tooling can enumerate the available models without having to +# construct a predictor first; the pipeline editor's registry generates its +# model dropdowns from these. +CLASSIFIER_MODELS = { + 'LDA': (LinearDiscriminantAnalysis, {}), + 'KNN': (KNeighborsClassifier, {"n_neighbors": 5}), + 'SVM': (SVC, {"kernel": "linear", "probability": True, "random_state": 0}), + 'QDA': (QuadraticDiscriminantAnalysis, {}), + 'RF': (RandomForestClassifier, {"random_state": 0}), + 'NB': (GaussianNB, {}), + 'GB': (GradientBoostingClassifier, {"random_state": 0}), + 'MLP': (MLPClassifier, {"random_state": 0, "hidden_layer_sizes": 126}) +} + +REGRESSOR_MODELS = { + 'LR': (LinearRegression, {}), + 'SVM': (SVR, {"kernel": "linear"}), + 'RF': (RandomForestRegressor, {"random_state": 0}), + 'GB': (GradientBoostingRegressor, {"random_state": 0}), + 'MLP': (MLPRegressor, {"random_state": 0, "hidden_layer_sizes": 126}) +} + + class EMGPredictor: - def __init__(self, model, model_parameters = None, random_seed = 0, fix_feature_errors = False, silent = False) -> None: - """Base class for EMG prediction. + """Base class for EMG prediction. Parent class that shares common functionality between classifiers and regressors. - Parameters - ---------- - model: custom model (must have fit, predict and predict_proba functions) - Object that will be used to fit and provide predictions. - model_parameters: dictionary, default=None - Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. - random_seed: int, default=0 - Constant value to control randomization seed. - fix_feature_errors: bool (default=False) - If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. - silent: bool (default=False) - If True, the outputs from the fix_feature_errors parameter will be silenced. - """ + Parameters + ---------- + model: custom model (must have fit, predict and predict_proba functions) + Object that will be used to fit and provide predictions. + model_parameters: dictionary, default=None + Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. + random_seed: int, default=0 + Constant value to control randomization seed. + fix_feature_errors: bool (default=False) + If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. + silent: bool (default=False) + If True, the outputs from the fix_feature_errors parameter will be silenced. + """ + def __init__(self, + model, + model_parameters: Optional[Dict[str, Any]] = None, + random_seed: int = 0, + fix_feature_errors: bool = False, + silent: bool = False) -> None: + self.model = model self.model_parameters = model_parameters - # default for feature parameters self.feature_params = {} self.fix_feature_errors = fix_feature_errors self.silent = silent random.seed(random_seed) - def fit(self, feature_dictionary = None, dataloader_dictionary = None, training_parameters = None): + def fit(self, + feature_dictionary: Optional[Dict[str, Any]] = None, + dataloader_dictionary: Optional[Dict[str, Any]] = None, + training_parameters: Optional[Dict[str, Any]] = None) -> None: """The fit function for the EMG Prediction class. This is the method called that actually optimizes model weights for the dataset. This method presents a fork for two @@ -85,7 +123,8 @@ def fit(self, feature_dictionary = None, dataloader_dictionary = None, training_ raise ValueError("Incorrect combination of values passed to fit method. A feature dictionary is needed for statistical models and a dataloader dictionary is needed for deep models.") @classmethod - def from_file(self, filename): + def from_file(self, + filename: str) -> "EMGPredictor": """Loads a classifier - rather than creates a new one. After saving a statistical model, you can recreate it by running EMGClassifier.from_file(). By default @@ -109,19 +148,48 @@ def from_file(self, filename): model = pickle.load(f) return model - def _predict(self, data): + def _predict(self, + data: Any) -> Any: + """ + Predict using the model. + + Parameters + ---------- + data: np.ndarray or torch.tensor + The input to be processed + + Returns + ---------- + prediction: int + the output prediction (categorical) + """ try: return self.model.predict(data) except AttributeError as e: raise AttributeError("Attempted to perform prediction when model doesn't have a predict() method. Please ensure model has a valid predict() method.") from e - def _predict_proba(self, data): + def _predict_proba(self, + data: Any) -> Any: + """ + Predict probabilities using the model. + + Parameters + ---------- + data: np.ndarray or torch.tensor + The input to be processed + + Returns + ---------- + probabilities: np.ndarray or torch.tensor + the output probabilities (continuous valued) + """ try: return self.model.predict_proba(data) except AttributeError as e: raise AttributeError("Attempted to perform prediction when model doesn't have a predict_proba() method. Please ensure model has a valid predict_proba() method.") from e - def save(self, filename): + def save(self, + filename: str) -> None: """Saves (pickles) the EMGClassifier object to a file. Use this save function if you want to load the object later using the from_file function. Note that @@ -135,7 +203,8 @@ def save(self, filename): with open(filename, 'wb') as f: pickle.dump(self, f) - def install_feature_parameters(self, feature_params): + def install_feature_parameters(self, + feature_params: Dict[str, Any]) -> None: """Installs the feature parameters for the classifier. This function is used to install the feature parameters for the classifier. This is necessary for the classifier @@ -149,7 +218,13 @@ def install_feature_parameters(self, feature_params): self.feature_params = feature_params @staticmethod - def _validate_model_parameters(model, model_parameters, model_config): + def _validate_model_parameters(model, + model_parameters: Optional[Dict[str, Any]], + model_config: Dict[str, Any]) -> Any: + """ + Provide a string representing a sklearn model and this function will validate if the model parameter dictionary is valid + by checking the sklearn model constructor arguments. + """ if not isinstance(model, str): # Custom model return model @@ -157,7 +232,10 @@ def _validate_model_parameters(model, model_parameters, model_config): assert model in valid_models, f"Please pass in one of the approved models: {valid_models}." model_reference, default_parameters = model_config[model] - valid_parameters = default_parameters + # A copy. The defaults now live in a module-level table shared by every + # predictor, so updating them in place would leak one caller's model + # parameters into the next caller's defaults. + valid_parameters = dict(default_parameters) if model_parameters is not None: signature = list(inspect.signature(model_reference).parameters.keys()) @@ -170,8 +248,12 @@ def _validate_model_parameters(model, model_parameters, model_config): valid_model = model_reference(**valid_parameters) return valid_model - def _format_data(self, feature_dictionary): - if not isinstance(feature_dictionary, np.ndarray): + def _format_data(self, + feature_dictionary: Union[Dict[str, Any], Any]) -> Any: + """ + Format dictionary format of features into a single np.ndarray. + """ + if isinstance(feature_dictionary, dict): # Loop through each element and stack arr = None for feat in feature_dictionary: @@ -187,7 +269,11 @@ def _format_data(self, feature_dictionary): arr = np.nan_to_num(arr, neginf=0, nan=0, posinf=0) return arr - def _fit_statistical_model(self, feature_dictionary): + def _fit_statistical_model(self, + feature_dictionary: Dict[str, Any]) -> None: + """ + Fit the model using a feature dictionary. + """ assert 'training_features' in feature_dictionary.keys() assert 'training_labels' in feature_dictionary.keys() # convert dictionary of features format to np.ndarray for test/train set (NwindowxNfeature) @@ -195,43 +281,43 @@ def _fit_statistical_model(self, feature_dictionary): # self._set_up_classifier(model, feature_dictionary, parameters) self.model.fit(feature_dictionary['training_features'], feature_dictionary['training_labels']) - def _fit_deeplearning_model(self, dataloader_dictionary, parameters): + def _fit_deeplearning_model(self, + dataloader_dictionary: Dict[str, Any], + parameters: Dict[str, Any]) -> None: + """ + Fit a deep learning model using a dataloader dictionary. + """ assert 'training_dataloader' in dataloader_dictionary.keys() assert 'validation_dataloader' in dataloader_dictionary.keys() self.model.fit(dataloader_dictionary, **parameters) - class EMGClassifier(EMGPredictor): - def __init__(self, model, model_parameters = None, random_seed = 0, fix_feature_errors = False, silent = False): - """The Offline EMG Classifier. + """The Offline EMG Classifier. - This is the base class for any offline EMG classification. + This is the base class for any offline EMG classification. - Parameters - ---------- - model: string or custom classifier (must have fit, predict and predic_proba functions) - The type of machine learning model. Valid options include: 'LDA', 'QDA', 'SVM', 'KNN', 'RF' (Random Forest), - 'NB' (Naive Bayes), 'GB' (Gradient Boost), 'MLP' (Multilayer Perceptron). Note, these models are all default sklearn - models with no hyperparameter tuning and may not be optimal. Pass in custom classifiers or parameters for more control. - model_parameters: dictionary, default=None - Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. - random_seed: int, default=0 - Constant value to control randomization seed. - fix_feature_errors: bool (default=False) - If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. - silent: bool (default=False) - If True, the outputs from the fix_feature_errors parameter will be silenced. - """ - model_config = { - 'LDA': (LinearDiscriminantAnalysis, {}), - 'KNN': (KNeighborsClassifier, {"n_neighbors": 5}), - 'SVM': (SVC, {"kernel": "linear", "probability": True, "random_state": 0}), - 'QDA': (QuadraticDiscriminantAnalysis, {}), - 'RF': (RandomForestClassifier, {"random_state": 0}), - 'NB': (GaussianNB, {}), - 'GB': (GradientBoostingClassifier, {"random_state": 0}), - 'MLP': (MLPClassifier, {"random_state": 0, "hidden_layer_sizes": 126}) - } + Parameters + ---------- + model: string or custom classifier (must have fit, predict and predic_proba functions) + The type of machine learning model. Valid options include: 'LDA', 'QDA', 'SVM', 'KNN', 'RF' (Random Forest), + 'NB' (Naive Bayes), 'GB' (Gradient Boost), 'MLP' (Multilayer Perceptron). Note, these models are all default sklearn + models with no hyperparameter tuning and may not be optimal. Pass in custom classifiers or parameters for more control. + model_parameters: dictionary, default=None + Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. + random_seed: int, default=0 + Constant value to control randomization seed. + fix_feature_errors: bool (default=False) + If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. + silent: bool (default=False) + If True, the outputs from the fix_feature_errors parameter will be silenced. + """ + def __init__(self, + model: Union[str, Any], + model_parameters: Optional[Dict[str, Any]] = None, + random_seed: int = 0, + fix_feature_errors: bool = False, + silent: bool = False): + model_config = CLASSIFIER_MODELS model = self._validate_model_parameters(model, model_parameters, model_config) super().__init__(model, model_parameters, random_seed=random_seed, fix_feature_errors=fix_feature_errors, silent=silent) @@ -245,7 +331,8 @@ def __init__(self, model, model_parameters = None, random_seed = 0, fix_feature_ - def run(self, test_data): + def run(self, + test_data: Any) -> Tuple[np.ndarray, np.ndarray]: """Runs the classifier on a pre-defined set of training data. Parameters @@ -261,7 +348,6 @@ def run(self, test_data): A list of the probabilities (for each prediction), based on the passed in testing features. """ test_data = self._format_data(test_data) - prob_predictions = self._predict_proba(test_data) # Default @@ -280,7 +366,8 @@ def run(self, test_data): # Accumulate Metrics return predictions, probabilities - def add_rejection(self, threshold=0.9): + def add_rejection(self, + threshold: float=0.9) -> None: """Adds the rejection post-processing block onto a classifier. Parameters @@ -291,7 +378,8 @@ def add_rejection(self, threshold=0.9): self.rejection = True self.rejection_threshold = threshold - def add_majority_vote(self, num_samples=5): + def add_majority_vote(self, + num_samples: int=5) -> None: """Adds the majority voting post-processing block onto a classifier. Parameters @@ -301,9 +389,11 @@ def add_majority_vote(self, num_samples=5): """ self.majority_vote = num_samples - def add_velocity(self, train_windows, train_labels, - velocity_metric_handle = None, - velocity_mapping_handle = None): + def add_velocity(self, + train_windows: np.ndarray, + train_labels: np.ndarray, + velocity_metric_handle: Optional[Callable[[Any], Any]] = None, + velocity_mapping_handle: Optional[Callable[[Any], Any]] = None): """Adds velocity (i.e., proportional) control where a multiplier is generated for the level of contraction intensity. Note, that when using this optional, ramp contractions should be captured for training. @@ -314,7 +404,6 @@ def add_velocity(self, train_windows, train_labels, self.velocity_metric_handle = velocity_metric_handle self.velocity_mapping_handle = velocity_mapping_handle self.velocity = True - self.th_min_dic, self.th_max_dic = self._set_up_velocity_control(train_windows, train_labels) @@ -322,7 +411,21 @@ def add_velocity(self, train_windows, train_labels, ''' ---------------------- Private Helper Functions ---------------------- ''' - def _prediction_helper(self, predictions): + def _prediction_helper(self, + predictions: Any) -> Tuple[np.ndarray, np.ndarray]: + """ + Helper function to extract prediction and probability. + + Parameters + ---------- + predictions : Any + Raw predictions. + + Returns + ------- + Tuple[np.ndarray, np.ndarray] + Tuple of predicted classes and probabilities. + """ probabilities = [] prediction_vals = [] for i in range(0, len(predictions)): @@ -331,7 +434,12 @@ def _prediction_helper(self, predictions): probabilities.append(pred_list[pred_list.index(max(pred_list))]) return np.array(prediction_vals), np.array(probabilities) - def _rejection_helper(self, prediction, prob): + def _rejection_helper(self, + prediction: Any, + prob: Any) -> Any: + """ + Helper function for rejection. + """ if self.rejection: if prob > self.rejection_threshold: return prediction @@ -339,7 +447,11 @@ def _rejection_helper(self, prediction, prob): return -1 return prediction - def _majority_vote_helper(self, predictions): + def _majority_vote_helper(self, + predictions: np.ndarray) -> np.ndarray: + """ + Helper function for majority voting. + """ updated_predictions = [] for i in range(0, len(predictions)): idxs = np.array(range(i-self.majority_vote+1, i+1)) @@ -348,7 +460,24 @@ def _majority_vote_helper(self, predictions): updated_predictions.append(stats.mode(group, keepdims=False)[0]) return np.array(updated_predictions) - def _get_velocity(self, window, c): + def _get_velocity(self, + window: Dict[str, Any], + c: Any) -> str: + """ + Compute velocity output based on window data. + + Parameters + ---------- + window : dict + Window data. + c : Any + Class or index. + + Returns + ------- + str + Formatted velocity. + """ mod = "emg" # todo: specify another way to do this is needed if self.th_max_dic and self.th_min_dic: @@ -362,7 +491,17 @@ def _get_velocity(self, window, c): velocity_output = self.velocity_mapping_handle(velocity_output) return '{0:.2f}'.format(min([1, max([velocity_output, 0])])) - def _set_up_velocity_control(self, train_windows, train_labels): + def _set_up_velocity_control(self, + train_windows: np.ndarray, + train_labels: np.ndarray) -> Tuple[Dict[Any, float], Dict[Any, float]]: + """ + Sets up velocity control thresholds. + + Returns + ------- + Tuple[dict, dict] + Dictionaries for min and max thresholds. + """ # Extract classes th_min_dic = {} th_max_dic = {} @@ -385,7 +524,10 @@ def _set_up_velocity_control(self, train_windows, train_labels): th_max_dic[c] = th_max return th_min_dic, th_max_dic - def visualize(self, test_labels, predictions, probabilities): + def visualize(self, + test_labels: np.ndarray, + predictions: np.ndarray, + probabilities: np.ndarray) -> None: """Visualize the decision stream of the classifier on the testing data. You can call this visualize function to get a visual output of what the decision stream of what @@ -434,42 +576,36 @@ def visualize(self, test_labels, predictions, probabilities): plt.legend(loc='lower right') plt.show() - class EMGRegressor(EMGPredictor): """The Offline EMG Regressor. This is the base class for any offline EMG regression. + Parameters + ---------- + model: string or custom regressor (must have fit and predict functions) + The type of machine learning model. Valid options include: 'LR' (Linear Regression), 'SVM' (Support Vector Machine), 'RF' (Random Forest), + 'GB' (Gradient Boost), 'MLP' (Multilayer Perceptron). Note, these models are all default sklearn + models with no hyperparameter tuning and may not be optimal. Pass in custom regressors or parameters for more control. + model_parameters: dictionary, default=None + Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. + random_seed: int, default=0 + Constant value to control randomization seed. + fix_feature_errors: bool (default=False) + If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. + silent: bool (default=False) + If True, the outputs from the fix_feature_errors parameter will be silenced. + deadband_threshold: float, default=0.0 + Threshold that controls deadband around 0 for output predictions. Values within this deadband will be output as 0 instead of their original prediction. """ - def __init__(self, model, model_parameters = None, random_seed = 0, fix_feature_errors = False, silent = False, deadband_threshold = 0.): - """The Offline EMG Regressor. - - This is the base class for any offline EMG regression. - - Parameters - ---------- - model: string or custom regressor (must have fit and predict functions) - The type of machine learning model. Valid options include: 'LR' (Linear Regression), 'SVM' (Support Vector Machine), 'RF' (Random Forest), - 'GB' (Gradient Boost), 'MLP' (Multilayer Perceptron). Note, these models are all default sklearn - models with no hyperparameter tuning and may not be optimal. Pass in custom regressors or parameters for more control. - model_parameters: dictionary, default=None - Mapping from parameter name to value based on the constructor of the specified model. Only used when a string is passed in for model. - random_seed: int, default=0 - Constant value to control randomization seed. - fix_feature_errors: bool (default=False) - If True, the model will update any feature errors (INF, -INF, NAN) using the np.nan_to_num function. - silent: bool (default=False) - If True, the outputs from the fix_feature_errors parameter will be silenced. - deadband_threshold: float, default=0.0 - Threshold that controls deadband around 0 for output predictions. Values within this deadband will be output as 0 instead of their original prediction. - """ - model_config = { - 'LR': (LinearRegression, {}), - 'SVM': (SVR, {"kernel": "linear"}), - 'RF': (RandomForestRegressor, {"random_state": 0}), - 'GB': (GradientBoostingRegressor, {"random_state": 0}), - 'MLP': (MLPRegressor, {"random_state": 0, "hidden_layer_sizes": 126}) - } + def __init__(self, + model: Union[str, Any], + model_parameters: Optional[Dict[str, Any]] = None, + random_seed: int = 0, + fix_feature_errors: bool = False, + silent: bool = False, + deadband_threshold: float = 0.): + model_config = REGRESSOR_MODELS convert_to_multioutput = isinstance(model, str) model = self._validate_model_parameters(model, model_parameters, model_config) if convert_to_multioutput: @@ -478,16 +614,18 @@ def __init__(self, model, model_parameters = None, random_seed = 0, fix_feature_ super().__init__(model, model_parameters, random_seed=random_seed, fix_feature_errors=fix_feature_errors, silent=silent) - def run(self, test_data): + def run(self, + test_data: Any) -> np.ndarray: """Runs the regressor on a pre-defined set of training data. Parameters ---------- test_data: list A dictionary, np.ndarray of inputs appropriate for the model of the EMGRegressor. + Returns ---------- - list + np.ndarray A list of predictions, based on the passed in testing features. """ test_data = self._format_data(test_data) @@ -499,15 +637,20 @@ def run(self, test_data): return predictions - def visualize(self, test_labels, predictions): + def visualize(self, + test_labels: np.ndarray, + predictions: np.ndarray) -> None: """Visualize the decision stream of the regressor on test data. You can call this visualize function to get a visual output of what the decision stream looks like. - :param test_labels: np.ndarray - :type test_labels: N x M array, where N = # samples and M = # DOFs, containing the labels for the test data. - :param predictions: np.ndarray - :type predictions: N x M array, where N = # samples and M = # DOFs, containing the predictions for the test data. + Parameters + ---------- + test_labels: np.ndarray + N x M array, where N = # samples and M = # DOFs, containing the labels for the test data. + predictions: np.ndarray + N x M array, where N = # samples and M = # DOFs, containing the predictions for the test data. + """ assert len(predictions) > 0, 'Empty list passed in for predictions to visualize.' @@ -534,7 +677,8 @@ def visualize(self, test_labels, predictions): plt.show() - def add_deadband(self, threshold): + def add_deadband(self, + threshold: float) -> None: """Add a deadband around regressor predictions that will instead be output as 0. Parameters @@ -544,7 +688,6 @@ def add_deadband(self, threshold): """ self.deadband_threshold = threshold - class OnlineStreamer(ABC): """OnlineStreamer. @@ -568,148 +711,126 @@ class OnlineStreamer(ABC): A location that the inputs and output of the classifier will be saved to. file: bool (optional) A toggle for activating the saving of inputs and outputs of the classifier. - smm: bool (optional) + enable_smm: bool (optional) A toggle for activating the storing of inputs and outputs of the classifier in the shared memory manager. smm_items: list (optional) A list of lists containing the tag, size, and multiprocessing locks for shared memory. parameters: dict (optional) A dictionary including all of the parameters for the sklearn models. These parameters should match those found in the sklearn docs for the given model. - port: int (optional), default = 12346 - The port used for streaming predictions over UDP. - ip: string (optional), default = '127.0.0.1' - The ip used for streaming predictions over UDP. velocity: bool (optional), default = False If True, the classifier will output an associated velocity (used for velocity/proportional based control). std_out: bool (optional), default = False If True, prints predictions to std_out. - tcp: bool (optional), default = False - If True, will stream predictions over TCP instead of UDP. """ def __init__(self, - offline_predictor, - window_size, - window_increment, - online_data_handler, - file_path, file, - smm, smm_items, - features, - port, ip, - std_out, - tcp): + offline_predictor: EMGPredictor, + window_size: int, + window_increment: int, + online_data_handler: OnlineDataHandler, + file_path: str, + file: bool, + enable_smm: bool, + smm_items: List[List[Any]], + features: Optional[List[Any]], + std_out: bool, + output_writers: Optional[List[Any]] = None): + + # setting arguments as class attributes self.window_size = window_size self.window_increment = window_increment self.odh = online_data_handler self.features = features - self.port = port - self.ip = ip self.predictor = offline_predictor + self.file = file + self.file_path = file_path + self.std_out = std_out + self.scaler = None + self.output_writers = output_writers if output_writers is not None else [] + self.signal = Event() - self.options = {'file': file, 'file_path': file_path, 'std_out': std_out} - - required_smm_items = [ # these tags are also required - ["adapt_flag", (1,1), np.int32], - ["active_flag", (1,1), np.int8] - ] - smm_items.extend(required_smm_items) - self.smm = smm + self.enable_smm = enable_smm self.smm_items = smm_items - self.files = {} - self.tcp = tcp - if not tcp: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - else: - print("Waiting for TCP connection...") - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - self.sock.bind((ip, port)) - self.sock.listen() - self.conn, addr = self.sock.accept() - print(f"Connected by {addr}") + self.smm = None + self.model_smm_writes = 0 + + # Reactive streaming. The streamer is woken when a window's worth of + # samples has arrived instead of asking repeatedly whether one has. + # Set reactive=False to fall back to the original polling loop; a + # custom window trigger selects that automatically. + self.reactive = True + self.event_log = None + self.notifier_pool = None + self._notifier_slot = None + # Only consulted when the writer does not share our notifier pool, in + # which case this is how often we re-read the state block. That read is + # a few integers, not a buffer copy. + self.poll_fallback = 0.002 self.process = Process(target=self._run_helper, daemon=True,) - def start_stream(self, block=True): + def stop(self): + self.signal.set() + + def start_stream(self, + block: bool =True) -> None: + """ + Start the streaming process. + + Parameters + ---------- + block : bool, default=True + Whether to run in blocking mode. + """ + # The slot and the pool are claimed here, in the parent, so they reach + # the streaming process as part of its state. Claiming inside the child + # would hand out a slot the parent never recorded, and two children + # could claim the same one. + if self.reactive and self._notifier_slot is None: + from libemg.reactive import default_notifier_pool + if self.notifier_pool is None: + self.notifier_pool = default_notifier_pool() + self._notifier_slot = self.notifier_pool.claim() if block: self._run_helper() else: self.process.start() - - def write_output(self, prediction, probabilities, probability, calculated_velocity, model_input): - time_stamp = time.time() - if calculated_velocity == "": - printed_velocity = "-1" - else: - printed_velocity = float(calculated_velocity) - if self.options['std_out']: - print(f"{int(prediction)} {printed_velocity} {time.time()}") - # Write classifier output: - if self.options['file']: - if not 'file_handle' in self.files.keys(): - self.files['file_handle'] = open(self.options['file_path'] + 'classifier_output.txt', "a", newline="") - writer = csv.writer(self.files['file_handle']) - feat_str = str(model_input[0]).replace('\n','')[1:-1] - row = [f"{time_stamp} {prediction} {probability[0]} {printed_velocity} {feat_str}"] - writer.writerow(row) - self.files['file_handle'].flush() - if "smm" in self.options.keys(): - # assumed to have "classifier_input" and "classifier_output" keys - # these are (1+) - def insert_classifier_input(data): - input_size = self.options['smm'].variables['classifier_input']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, model_input[0]]), data))[:input_size,:] - return data - def insert_classifier_output(data): - output_size = self.options['smm'].variables['classifier_output']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, prediction, probability[0], float(printed_velocity)]), data))[:output_size,:] - return data - self.options['smm'].modify_variable("classifier_input", - insert_classifier_input) - self.options['smm'].modify_variable("classifier_output", - insert_classifier_output) - self.options['classifier_smm_writes'] += 1 - - if self.output_format == "predictions": - message = str(prediction) + calculated_velocity + '\n' - elif self.output_format == "probabilities": - message = ' '.join([f'{i:.2f}' for i in probabilities[0]]) + calculated_velocity + " " + str(time_stamp) - if not self.tcp: - self.sock.sendto(bytes(message, 'utf-8'), (self.ip, self.port)) - else: - self.conn.sendall(str.encode(message)) - def prepare_smm(self): - for i in self.smm_items: - if len(i) == 3: - i.append(Lock()) + def prepare_smm(self) -> None: + """ + Prepare shared memory by creating required variables. + """ smm = SharedMemoryManager() for item in self.smm_items: smm.create_variable(*item) - self.options['smm'] = smm - self.options['classifier_smm_writes'] = 0 + self.smm = smm + self.model_smm_writes = 0 - def analyze_predictor(self, analyze_time=10): + def analyze_predictor(self, + ip: str="127.0.0.1", + port: int=12346, + analyze_time: int=10) -> None: """Analyzes the latency of the designed predictor. - - Parameters - ---------- - analyze_time: int (optional), default=10 (seconds) - The time in seconds that you want to analyze the model for. - port: int (optional), default = 12346 - The port used for streaming predictions over UDP. - ip: string (optional), default = '127.0.0.1' - The ip used for streaming predictions over UDP. (1) Time Between Prediction (Average): The average time between subsequent predictions. (2) STD Between Predictions (Standard Deviation): The standard deviation between predictions. (3) Total Number of Predictions: The number of predictions that were made. Sometimes if the increment is too small, samples will get dropped and this may be less than expected. + + Parameters + ---------- + ip: str (optional), default=localhost + The ip address to listen to for model outputs. + port: int (optional), default=12346 + The port to listen to for model outputs. + analyze_time: int (optional), default=10 (seconds) + The time in seconds that you want to analyze the model for. """ print("Starting analysis of predictor " + "(" + str(analyze_time) + "s)...") sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind((self.ip, self.port)) + sock.bind((ip, port)) st = time.time() times = [] while(time.time() - st < analyze_time): @@ -723,7 +844,11 @@ def analyze_predictor(self, analyze_time=10): print("Total Number of Predictions: " + str(len(times) + 1)) self.stop_running() - def _format_data_sample(self, data): + def _format_data_sample(self, + data: Dict[str, Any]) -> np.ndarray: + """ + Stack data from a dictionary into one array. In this case 'data' is the feature dictionary. + """ arr = None for feat in data: if arr is None: @@ -732,84 +857,427 @@ def _format_data_sample(self, data): arr = np.hstack((arr, data[feat])) return arr - def _get_data_helper(self): + def _get_data_helper(self) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Retrieve and reverse data. + """ data, counts = self.odh.get_data(N=self.window_size) + # TODO: this probably adds latency and isn't really needed + # ODH has index 0=most recent sample + # if you trained using typical csv formats, the most recent sample is at the end of the list + # This inversion makes them the same, but is unnecessary like 99% of the time. for key in data.keys(): data[key] = data[key][::-1] return data, counts - def get_interaction_items(self): + def get_interaction_items(self) -> List[List[Any]]: + """ + Return the shared memory items. + """ return self.smm_items - def load_emg_predictor(self, number): - with open(self.options['file_path'] + 'mdl' + str(number) + '.pkl', 'rb') as handle: - self.predictor = pickle.load(handle) - print(f"Loaded model #{number}.") + def load_emg_predictor(self, + number: int) -> None: + """ + Load a predictor from a file. Assumes that the files is treated as an EMGPredictor (LibEMG object) or the underlying model. + + Parameters + ---------- + number : int + Model number. + """ + filepath = self.file_path + 'mdl' + str(number) + '.pkl' + loaded_content = self._load_emg_predictor_helper(filepath) + # isinstance, not exact type equality. EMGClassifier and EMGRegressor + # both subclass EMGPredictor, and they are what an adaptation run + # actually saves, so an exact-type check sent a saved classifier down + # the branch meant for a bare sklearn model. It landed in .model, and + # the next prediction failed on a classifier having no predict_proba -- + # after the swap had already been reported as successful. + if isinstance(loaded_content, EMGPredictor): + self.predictor = loaded_content + else: + self.predictor.model = loaded_content + print(f"Loaded model #{number}.") + def _load_emg_predictor_helper(self, file_path: str) -> Any: + try: + with open(file_path,'rb') as handle: + return pickle.load(handle) + except pickle.UnpicklingError as e: + #Only catch the specific "persistent id" error that comes from pytorch + msg = str(e) + if 'persistent id' not in msg: + raise + try: + import torch + except ImportError: + raise RuntimeError("File to load contains pytorch tensor but torch is not installed.") + return torch.load(file_path) + - def _run_helper(self): - if self.smm: + + # ----- Default functions for the streaming pipeline ----- + def default_startup(self) -> None: + """ + Default startup: prepare shared memory and reset online data handler. + """ + if self.enable_smm: self.prepare_smm() - self.options['smm'].modify_variable("active_flag", lambda x:1) - self.options["smm"].modify_variable("adapt_flag", lambda x: -1) - + self.smm.modify_variable("active_flag", lambda x: 1) + self.smm.modify_variable("adapt_flag", lambda x: -1) self.odh.prepare_smm() + self.expected_count = {mod: self.window_size for mod in self.odh.modalities} + self.odh.reset() - if self.features is not None: - fe = FeatureExtractor() + def default_model_flag_handler(self) -> bool: + """ + Checks and handles the shared memory flags: if the active flag is not set, + returns False immediately. Also checks if the adapt flag is set and if so, + loads a new predictor. + + The adaptation flag is consulted through its state block rather than by + reading its value. A new model is rare and the check runs on every wake, + so the common answer -- nothing has changed -- is now a few integers + instead of three locked reads of the variable itself. The value is only + read when the state says somebody wrote it. + + Returns + ------- + bool + True if flags are acceptable to run model; False otherwise. + """ + if self.enable_smm: + if not self.smm.get_variable("active_flag")[0, 0]: + return False + if self._adapt_flag_changed(): + number = self.smm.get_variable("adapt_flag")[0][0] + if number != -1: + self.load_emg_predictor(number) + self.on_model_update(number) + # Cleared with modify_variable on purpose: that write does + # not advance the state block, so clearing the flag cannot + # look like somebody publishing another model. + self.smm.modify_variable("adapt_flag", lambda x: -1) + return True + + def _adapt_flag_changed(self) -> bool: + """Whether the adaptation flag has been written since we last looked.""" + try: + generation = int(self.smm._block("adapt_flag")[0]) + except (AssertionError, KeyError, AttributeError): + # No state block for the flag, which is the case when something + # other than an adaptation hook owns it. Fall back to reading the + # value every time, as this always used to. + return True + seen = getattr(self, "_adapt_generation", None) + if seen is None: + # First look. The flag is initialised to -1 at startup, so there is + # nothing to load; record where it stands and wait for a change. + self._adapt_generation = generation + return False + if generation > seen: + self._adapt_generation = generation + return True + return False + + def on_model_update(self, number) -> None: + """Called in the streaming process when a newly adapted model is loaded. + + An extension point for anything that has to happen where the model + actually lives: resetting a decision history that the old model's + outputs populated, re-fitting a scaler, marking the moment in a log. + Override it, or assign to it. + + Anything that does *not* need to be in this process should observe the + adaptation flag instead; see + :class:`libemg.adaptation.hooks.ModelSwapHook`. + + Override this in a subclass rather than assigning a lambda to it. The + streamer is a spawned process, so whatever is attached here has to + survive being pickled, and a lambda does not. + + Parameters + ---------- + number: int + The model number that was loaded. + """ + + + def default_window_trigger(self) -> bool: + """ + Check whether enough data samples are collected. - self.expected_count = {mod:self.window_size for mod in self.odh.modalities} - # todo: deal with different sampling frequencies for different modalities - self.odh.reset() + Returns + ------- + bool + True if window is ready. + """ + val, count = self.odh.get_data(N=self.window_size) + modality_ready = [count[mod] > self.expected_count[mod] for mod in self.odh.modalities] + return all(modality_ready) - files = {} - while True: - if self.smm: - if not self.options["smm"].get_variable("active_flag")[0,0]: - continue - - if not (self.options["smm"].get_variable("adapt_flag")[0][0] == -1): - self.load_emg_predictor(self.options["smm"].get_variable("adapt_flag")[0][0]) - self.options["smm"].modify_variable("adapt_flag", lambda x: -1) - - val, count = self.odh.get_data(N=self.window_size) - modality_ready = [count[mod] > self.expected_count[mod] for mod in self.odh.modalities] - - if all(modality_ready): - data, count = self._get_data_helper() - - # Extract window and predict sample - window = {mod:get_windows(data[mod], self.window_size, self.window_increment) for mod in self.odh.modalities} - - # Dealing with the case for CNNs when no features are used - if self.features: - model_input = None - for mod in self.odh.modalities: - # todo: features for each modality can be different - mod_features = fe.extract_features(self.features, window[mod], self.predictor.feature_params) - mod_features = self._format_data_sample(mod_features) - if model_input is None: - model_input = mod_features - else: - model_input = np.hstack((model_input, mod_features)) - + def default_on_window(self) -> Tuple[Any, Dict[str, Any]]: + """ + Extract window and prepare model input. This is the same for OnlineEMGRegressors or OnlineEMGClassifiers. + + Returns + ------- + Tuple[Any, dict] + The model input (processed single window ready for model, optionally scaled) and the raw window (raw samples pre scaling). + """ + data, count = self._get_data_helper() + window = {mod: get_windows(data[mod], self.window_size, self.window_increment) for mod in self.odh.modalities} + fe = FeatureExtractor() + if self.features is not None: + model_input_raw = None + for mod in self.odh.modalities: + mod_features = fe.extract_features(self.features, window[mod], feature_dic=self.predictor.feature_params, array=True) + if model_input_raw is None: + model_input_raw = mod_features else: - model_input = window[list(window.keys())[0]] #TODO: Change this - - for mod in self.odh.modalities: - self.expected_count[mod] += self.window_increment - - self.write_output(model_input, window) + model_input_raw = np.hstack((model_input_raw, mod_features)) + if self.scaler is not None: + model_input = self.scaler.transform(model_input_raw) + else: + model_input = model_input_raw + else: + model_input_raw = window[list(window.keys())[0]] + if self.scaler is not None: + model_input = self.scaler.transform(model_input_raw) + else: + model_input = model_input_raw + # TODO: This should be adding a per modality increment since they don't typically have the same Fs + for mod in self.odh.modalities: + self.expected_count[mod] += self.window_increment + return model_input, model_input_raw, window + + def run(self, + block: bool=True): + """Runs the streamer. + + Parameters + ---------- + block: bool (optional), default = True + If True, the run function blocks the main thread. Otherwise it runs in a + seperate process. + """ + self.start_stream(block) + + def _run_helper(self) -> None: + """ + Main loop for online streaming. + + Two implementations sit behind this. The reactive one hooks the data + the handler is receiving and is woken when a window's worth of samples + has arrived; the polled one is the original loop and is used only when + a custom window trigger has been installed, since such a trigger is an + arbitrary predicate the reactive path cannot express as a criterion. + """ + # Startup stage + self.on_startup_function_handle() + if self._reactive_is_available(): + self._run_reactive() + else: + self._run_polled() + + def _reactive_is_available(self) -> bool: + """Whether this streamer can be driven by hooks rather than by polling. + + A caller that replaced ``window_trigger_function_handle`` gets the + original loop, because their predicate may depend on anything at all + and cannot be restated as a per-item criterion. Everything else -- the + startup, window, prediction and postprocessing handles -- is used + identically either way. + """ + if not getattr(self, "reactive", True): + return False + try: + return self.window_trigger_function_handle == self.default_window_trigger + except Exception: + return False + + def _run_reactive(self) -> None: + """Wait to be told a window is ready, rather than asking repeatedly. + + The old loop asked ``window_trigger_function_handle`` as fast as the + interpreter allowed, and each ask copied and filtered a whole + shared-memory buffer to read one counter. Here the streamer subscribes + to the items it consumes and blocks. It is woken when one of them is + committed to, then applies :class:`~libemg.reactive.OnSamples` -- its + own definition of a meaningful change, one window increment -- to + decide whether to run. + """ + from libemg.reactive import (CriterionMemory, OnSamples, + default_notifier_pool) + from libemg import event_log + + log = getattr(self, "event_log", None) or event_log.NULL_LOG + pool = getattr(self, "notifier_pool", None) or default_notifier_pool() + slot = self._notifier_slot if getattr(self, "_notifier_slot", None) is not None \ + else pool.claim() + + smm = self.odh.smm + modalities = list(self.odh.modalities) + for mod in modalities: + smm.subscribe(mod, slot) + + criteria = {mod: OnSamples(self.window_increment) for mod in modalities} + memories = {mod: CriterionMemory() for mod in modalities} + # The fallback only matters when the writer does not hold the notifier + # pool. It re-reads state blocks, which is a few integers rather than a + # buffer copy, so a short interval here is cheap. + fallback = getattr(self, "poll_fallback", 0.002) + name = type(self).__name__ + + log.emit(event_log.LIFECYCLE, origin="online_streamer", observer=name, + phase="reactive", slot=slot, modalities=",".join(modalities)) + + def ready(): + for mod in modalities: + block = smm._block(mod) + if int(block[1]) - memories[mod].samples >= self.window_increment: + return True + return False + + while True: + if self.signal.is_set(): + self.cleanup() + break + pool.wait(slot, ready, fallback) + if self.signal.is_set(): + self.cleanup() + break + if not self.model_flag_handle(): + continue + + snapshots = smm.snapshots(modalities) + verdicts = {} + for mod in modalities: + snapshot = snapshots[mod] + memory = memories[mod] + if snapshot.epoch != memory.epoch: + # default_startup resets the handler, which moves the + # counters backwards. Without noticing that, the criterion + # would wait for samples that have already been renumbered. + memory.generation = 0 + memory.samples = 0 + memory.epoch = snapshot.epoch + verdicts[mod] = criteria[mod].is_dirty(snapshot, memory) + if log.enabled: + log.emit(event_log.DIRTY if verdicts[mod] else event_log.CLEAN, + origin=mod, observer=name, + criterion=criteria[mod].describe(), + generation=snapshot.generation, + total_samples=snapshot.total_samples, + consumed_samples=memory.samples) + # Every modality has to have advanced: a window built from one + # modality's new samples and another's stale ones is not a window. + if not all(verdicts.values()): + continue + for mod in modalities: + criteria[mod].consume(snapshots[mod], memories[mod]) + + if log.enabled: + log.emit(event_log.INVOKE, origin=",".join(modalities), observer=name) + self._process_window(log=log, name=name) + + def _run_polled(self) -> None: + """The original polling loop, kept for custom window triggers.""" + while True: + if self.signal.is_set(): + self.cleanup() + break + # Check flags + if not self.model_flag_handle(): + continue + # Window trigger stage + if not self.window_trigger_function_handle(): + continue + self._process_window() + + def _process_window(self, log=None, name="") -> None: + """Window, predict, postprocess and write. Shared by both loops.""" + from libemg import event_log + + started = time.perf_counter() + # Window processing stage + model_input, model_input_raw, window = self.on_window_function_handle() + if model_input is None: + return + + # Prediction/Postprocessing stage + raw = self.prediction_function_handle(model_input) + processed = self.postprocessing_function_handle(raw, model_input, window) + info = self.format_output_info(processed, model_input, model_input_raw, window) + for writer in self.output_writers: + writer.write(info) + if log is not None and log.enabled: + log.emit(event_log.COMPLETE, origin=name, observer=name, + duration_ms=(time.perf_counter() - started) * 1e3) + + def install_event_log(self, log) -> None: + """Record what wakes this streamer and what it decides. + + Parameters + ---------- + log: libemg.event_log.EventLog + The log to write to. Install it before :meth:`run`, because the + streaming process receives it when it is created. + """ + self.event_log = log + + def cleanup(self) -> None: + # smm is only built when enable_smm was requested, so a streamer + # running without it reaches shutdown with nothing to release. + if self.smm is not None: + self.smm.cleanup() + print("LibEMG -> OnlineStreamer (smm cleaned up).") + print("LibEMG -> OnlineStreamer (process ended).") + + def install_standardization(self, + standardization: np.ndarray | StandardScaler) -> None: + """Install standardization to online model. Standardizes each feature based on training data (i.e., standardizes across windows). + Standardization is only applied when features are extracted and is applied before feature queueing (i.e., features are standardized then queued) + if relevant. To standardize data, use the standardize Filter. + + standardization : np.ndarray or StandardScaler + Data or pre-fit scaler for standardization. + """ + scaler = standardization + + if not isinstance(scaler, StandardScaler): + # Fit scaler to provided data + scaler = StandardScaler().fit(np.array(standardization)) + + self.scaler = scaler + + def stop_running(self) -> None: + """Kills the process streaming decisions. + """ + self.process.terminate() # ----- All of these are unique to each online streamer ---------- - def run(self): - pass + + @abstractmethod + def default_prediction_function(self, model_input: np.ndarray) -> Tuple[Any, Any]: + """ + Default prediction routine. + """ + pass - def stop_running(self): + @abstractmethod + def default_postprocessing_function(self, raw: Any, model_input: np.ndarray, window: Dict[str, Any]) -> Tuple: + """ + Default prediction routine. + """ pass @abstractmethod - def write_output(self, model_input, window): + def format_output_info(self, processed: Tuple[Any, Any, Any], model_input: Any, model_input_raw: Any, window: Dict[str, Any]) -> Dict[str, Any]: + """ + Format output info as dictionary. + """ pass @@ -841,154 +1309,122 @@ class OnlineEMGClassifier(OnlineStreamer): When modifying this variable, items with the name 'classifier_output' and 'classifier_input' are expected to be passed in to track classifier inputs and outputs. The 'classifier_input' item should be of the format ['classifier_input', (100, 1 + num_features), np.double] The 'classifier_output' item should be of the format ['classifier_output', (100, 1 + num_dofs), np.double]. - If None, defaults to: - [ - ["classifier_output", (100,4), np.double], #timestamp, class prediction, confidence, velocity - ['classifier_input', (100, 1 + 32), np.double], # timestamp <- features -> - ] - port: int (optional), default = 12346 - The port used for streaming predictions over UDP. - ip: string (optional), default = '127.0.0.1' - The ip used for streaming predictions over UDP. + If None, defaults to:: + + [ + ["classifier_output", (100,4), np.double], #timestamp, class prediction, confidence, velocity + ['classifier_input', (100, 1 + 32), np.double], # timestamp <- features -> + ] + std_out: bool (optional), default = False If True, prints predictions to std_out. - tcp: bool (optional), default = False - If True, will stream predictions over TCP instead of UDP. - output_format: str (optional), default=predictions - If predictions, it will broadcast an integer of the prediction, if probabilities it broacasts the posterior probabilities + output_writers: OutputWriter, default = None + A list of OutputWriters. This defines what is typically done with the output of the OnlineStreamer. """ - def __init__(self, offline_classifier, window_size, window_increment, online_data_handler, features, - file_path = '.', file=False, smm=False, - smm_items= None, - port=12346, ip='127.0.0.1', std_out=False, tcp=False, - output_format="predictions"): + def __init__(self, + offline_classifier: EMGClassifier, + window_size: int, + window_increment: int, + online_data_handler: Any, + features: Optional[List[Any]], + file_path: str = '.', + file: bool=False, + smm: bool=False, + smm_items: Optional[List[List[Any]]]= None, + std_out: bool=False, + output_writers: Optional[List[Any]]=None) -> None: + - if smm_items is None: - smm_items = [ - ["classifier_output", (100,4), np.double], #timestamp, class prediction, confidence, velocity - ["classifier_input", (100,1+32), np.double], # timestamp, <- features -> - ] - assert 'classifier_input' in [item[0] for item in smm_items], f"'model_input' tag not found in smm_items. Got: {smm_items}." - assert 'classifier_output' in [item[0] for item in smm_items], f"'model_output' tag not found in smm_items. Got: {smm_items}." super(OnlineEMGClassifier, self).__init__(offline_classifier, window_size, window_increment, online_data_handler, - file_path, file, smm, smm_items, features, port, ip, std_out, tcp) - self.output_format = output_format - self.previous_predictions = deque(maxlen=self.predictor.majority_vote) + file_path, file, smm, smm_items, features, std_out, output_writers) + # majority_vote defaults to None, and deque(maxlen=None) is UNBOUNDED -- with voting off the + # deque would grow for the lifetime of the stream (~1.7M entries/day) holding values nothing + # reads. `or 1` keeps it bounded in that case. + self.previous_predictions = deque(maxlen=self.predictor.majority_vote or 1) self.smi = smm_items - - def run(self, block=True): - """Runs the classifier - continuously streams predictions over UDP. - Parameters - ---------- - block: bool (optional), default = True - If True, the run function blocks the main thread. Otherwise it runs in a - seperate process. - """ - self.start_stream(block) - - def stop_running(self): - """Kills the process streaming classification decisions. - """ - self.process.terminate() - - def write_output(self, model_input, window): - # Make prediction - probabilities = self.predictor.model.predict_proba(model_input) - prediction, probability = self.predictor._prediction_helper(probabilities) - prediction = prediction[0] - - # Check for rejection + # Set the streaming pipeline function handles in the classifier subclass. + self.on_startup_function_handle = self.default_startup + self.window_trigger_function_handle = self.default_window_trigger + self.model_flag_handle = self.default_model_flag_handler + self.on_window_function_handle = self.default_on_window + self.prediction_function_handle = self.default_prediction_function + self.postprocessing_function_handle = self.default_postprocessing_function + + def default_prediction_function(self, model_input: np.ndarray) -> Tuple[Any, Any]: + probabilities = self.predictor._predict_proba(model_input) + prediction, _ = self.predictor._prediction_helper(probabilities) + return (prediction[0], probabilities[0]) + + def default_postprocessing_function(self, raw: Any, model_input: np.ndarray, window: Dict[str, Any]): + prediction, probabilities = raw if self.predictor.rejection: - #TODO: Right now this will default to -1 - prediction = self.predictor._rejection_helper(prediction, probability) - self.previous_predictions.append(prediction) - - # Check for majority vote + prediction = self.predictor._rejection_helper(prediction, probabilities[prediction]) if self.predictor.majority_vote: + # Only accumulate history when it is actually voted over; the deque is unread otherwise. + self.previous_predictions.append(prediction) values, counts = np.unique(list(self.previous_predictions), return_counts=True) prediction = values[np.argmax(counts)] - - # Check for velocity based control calculated_velocity = "" if self.predictor.velocity: calculated_velocity = " 0" - # Dont check if rejected if prediction >= 0: calculated_velocity = " " + str(self.predictor._get_velocity(window, prediction)) + return (prediction, probabilities, calculated_velocity) + + def format_output_info(self, + processed: Tuple[Any, Any, Any], + model_input: Any, + model_input_raw: Any, + window: Dict[str, Any]) -> Dict[str, Any]: + # Compose a dictionary with all information you wish to send. + prediction, probabilities, calculated_velocity = processed + if isinstance(prediction, np.ndarray): + prediction = prediction.item() + timestamp = time.time() + message = ' '.join([f'{i:.2f}' for i in probabilities]) + calculated_velocity + " " + str(timestamp) + + info = { + "timestamp": timestamp, + "model_output": prediction, + "probability": probabilities, + "velocity": calculated_velocity, + "model_input": model_input, + "model_input_raw": model_input_raw, + "window": window, + "message": message + } + return info - - time_stamp = time.time() - if calculated_velocity == "": - printed_velocity = "-1" - else: - printed_velocity = float(calculated_velocity) - if self.options['std_out']: - print(f"{int(prediction)} {printed_velocity} {time.time()}") - - # Write classifier output: - if self.options['file']: - if not 'file_handle' in self.files.keys(): - self.files['file_handle'] = open(self.options['file_path'] + 'classifier_output.txt', "a", newline="") - writer = csv.writer(self.files['file_handle']) - feat_str = str(model_input[0]).replace('\n','')[1:-1] - row = [f"{time_stamp} {prediction} {probability[0]} {printed_velocity} {feat_str}"] - writer.writerow(row) - self.files['file_handle'].flush() - if "smm" in self.options.keys(): - #assumed to have "classifier_input" and "classifier_output" keys - # these are (1+) - def insert_classifier_input(data): - input_size = self.options['smm'].variables['classifier_input']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, model_input[0]]), data))[:input_size,:] - return data - def insert_classifier_output(data): - output_size = self.options['smm'].variables['classifier_output']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, prediction, probability[0], float(printed_velocity)]), data))[:output_size,:] - return data - self.options['smm'].modify_variable("classifier_input", - insert_classifier_input) - self.options['smm'].modify_variable("classifier_output", - insert_classifier_output) - self.options['classifier_smm_writes'] += 1 - - if self.output_format == "predictions": - message = str(prediction) + calculated_velocity + '\n' - elif self.output_format == "probabilities": - message = ' '.join([f'{i:.2f}' for i in probabilities[0]]) + calculated_velocity + " " + str(time_stamp) - else: - raise ValueError(f"Unexpected value for output_format. Accepted values are 'predictions' and 'probabilities'. Got: {self.output_format}.") - if not self.tcp: - self.sock.sendto(bytes(message, 'utf-8'), (self.ip, self.port)) - else: - self.conn.sendall(str.encode(message)) - - def visualize(self, max_len=50, legend=None): + def visualize(self, + ip: str="127.0.0.1", + port: int=12346, + max_len: int=50, + legend: Optional[List[str]]=None): """Produces a live plot of classifier decisions -- Note this consumes the decisions. Do not use this alongside the actual control operation of libemg. Online classifier has to be running in "probabilties" output mode for this plot. Parameters ---------- + ip: (str) (optional), default=localhost + The ip address the classifier outputs decisions to. + port: (int) (optional), default=12346 + The port the classifier outputs decisions to. max_len: (int) (optional) number of decisions to visualize legend: (list) (optional) - The legend to display on the plot + Labels used to populate legend. Must be passed in order of output classes. """ - #### NOT CURRENTLY WORKING - assert 1==0, "Method not ready" plt.style.use("ggplot") figure, ax = plt.subplots() figure.suptitle("Live Classifier Output", fontsize=16) plot_handle = ax.scatter([],[],c=[]) - - - # make a new socket that subscribes to the libemg events - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(('127.0.0.1', 12346)) - num_classes = len(self.predictor.model.classes_) + num_classes = len(self.predictor.model.classes_) # assumes that user is using an sklearn model cmap = cm.get_cmap('turbo', num_classes) + controller = ClassifierController(output_format="probabilities", num_classes=num_classes, ip=ip, port=port) + if legend is not None: for i in range(num_classes): plt.plot(i, label=legend[i], color=cmap.colors[i]) @@ -999,14 +1435,15 @@ def visualize(self, max_len=50, legend=None): timestamps = [] start_time = time.time() while True: - data, _ = sock.recvfrom(1024) - data = str(data.decode("utf-8")) - probabilities = np.array([float(i) for i in data.split(" ")[:num_classes]]) + data = controller.get_data(['probabilities', 'timestamp']) + if data is None: + continue + probabilities, timestamp = data max_prob = np.max(probabilities) prediction = np.argmax(probabilities) decision_horizon_classes.append(prediction) decision_horizon_probabilities.append(max_prob) - timestamps.append(float(data.split(" ")[-1]) - start_time) + timestamps.append(timestamp - start_time) decision_horizon_classes = decision_horizon_classes[-max_len:] decision_horizon_probabilities = decision_horizon_probabilities[-max_len:] @@ -1026,13 +1463,11 @@ def visualize(self, max_len=50, legend=None): else: return - def _get_data_helper(self): + def _get_data_helper(self) -> Tuple[Dict[str, Any], Dict[str, Any]]: data, counts = self.odh.get_data(N=self.window_size) for key in data.keys(): data[key] = data[key][::-1] return data, counts - - class OnlineEMGRegressor(OnlineStreamer): """OnlineEMGRegressor. @@ -1062,144 +1497,131 @@ class OnlineEMGRegressor(OnlineStreamer): When modifying this variable, items with the name 'model_output' and 'model_input' are expected to be passed in to track model inputs and outputs. The 'model_input' item should be of the format ['model_input', (100, 1 + num_features), np.double] The 'model_output' item should be of the format ['model_output', (100, 1 + num_dofs), np.double]. - If None, defaults to: - [ - ['model_output', (100, 3), np.double], # timestamp, prediction 1, prediction 2... (assumes 2 DOFs) - ['model_input', (100, 1 + 32), np.double], # timestamp <- features -> - ] - port: int (optional), default = 12346 - The port used for streaming predictions over UDP. - ip: string (optional), default = '127.0.0.1' - The ip used for streaming predictions over UDP. + If None, defaults to:: + + [ + ['model_output', (100, 3), np.double], # timestamp, prediction 1, prediction 2... (assumes 2 DOFs) + ['model_input', (100, 1 + 32), np.double], # timestamp <- features -> + ] + std_out: bool (optional), default = False If True, prints predictions to std_out. - tcp: bool (optional), default = False - If True, will stream predictions over TCP instead of UDP. + output_writers: OutputWriter, default = None + A list of OutputWriters. This defines what is typically done with the output of the OnlineStreamer. """ - def __init__(self, offline_regressor, window_size, window_increment, online_data_handler, features, - file_path = '.', file = False, smm = False, smm_items = None, - port=12346, ip='127.0.0.1', std_out=False, tcp=False): - if smm_items is None: - # I think probably just have smm_items default to None and remove the smm flag. Then if the user wants to track stuff, they can pass in smm_items and a function to handle them? - smm_items = [ - ['model_input', (100, 1 + 32), np.double], # timestamp <- features -> - ['model_output', (100, 3), np.double] # timestamp, prediction 1, prediction 2... (assumes 2 DOFs) - ] - assert 'model_input' in [item[0] for item in smm_items], f"'model_input' tag not found in smm_items. Got: {smm_items}." - assert 'model_output' in [item[0] for item in smm_items], f"'model_output' tag not found in smm_items. Got: {smm_items}." + def __init__(self, + offline_regressor: EMGRegressor, + window_size: int, + window_increment: int, + online_data_handler: Any, + features: Optional[List[Any]], + file_path: str = '.', + file: bool = False, + smm: bool = False, + smm_items: Optional[List[Any]] = None, + std_out: bool = False, + output_writers: Optional[List[Any]]=None) -> None: super(OnlineEMGRegressor, self).__init__(offline_regressor, window_size, window_increment, online_data_handler, file_path, - file, smm, smm_items, features, port, ip, std_out, tcp) + file, smm, smm_items, features, std_out, output_writers) self.smi = smm_items - - def run(self, block=True): - """Runs the regressor - continuously streams predictions over UDP or TCP. - Parameters - ---------- - block: bool (optional), default = True - If True, the run function blocks the main thread. Otherwise it runs in a - seperate process. - """ - self.start_stream(block) + # Set the streaming pipeline function handles in the classifier subclass. + self.on_startup_function_handle = self.default_startup + self.window_trigger_function_handle = self.default_window_trigger + self.model_flag_handle = self.default_model_flag_handler + self.on_window_function_handle = self.default_on_window + self.prediction_function_handle = self.default_prediction_function + self.postprocessing_function_handle = self.default_postprocessing_function + + def default_prediction_function(self, model_input: np.ndarray) -> Tuple[Any, Any]: + return self.predictor.run(model_input).squeeze() - def stop_running(self): - """Kills the process streaming classification decisions. + def default_postprocessing_function(self, raw: Any, model_input: np.ndarray, window: Dict[str, Any]): """ - self.process.terminate() + Postprocessing: apply additional processing if needed (e.g., deadband). + In this simple example, we return the predictions unmodified (currently a pass-through). + """ + return raw + + def format_output_info(self, + processed: Any, + model_input: Any, + model_input_raw: Any, + window: Dict[str, Any]) -> Dict[str, Any]: + predictions = processed + info = { + "timestamp": time.time(), + "model_output": predictions, + "model_input": model_input, + "model_input_raw": model_input_raw, + "window": window + } + return info - def write_output(self, model_input, window): - # Make prediction - predictions = self.predictor.run(model_input).squeeze() - - time_stamp = time.time() - if self.options['std_out']: - print(f"{predictions} {time.time()}") - - # Write model output: - if self.options['file']: - if not 'file_handle' in self.files.keys(): - self.files['file_handle'] = open(self.options['file_path'] + 'model_output.txt', "a", newline="") - writer = csv.writer(self.files['file_handle']) - feat_str = str(model_input[0]).replace('\n','')[1:-1] - row = [f"{time_stamp} {predictions} {feat_str}"] - writer.writerow(row) - self.files['file_handle'].flush() - - if "smm" in self.options.keys(): - #assumed to have "model_input" and "model_output" keys - # these are (1+) - # This could maybe be moved to OnlineStreamer instead - def insert_model_input(data): - input_size = self.options['smm'].variables['model_input']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, model_input[0]]), data))[:input_size,:] - return data - def insert_model_output(data): - output_size = self.options['smm'].variables['model_output']["shape"][0] - data[:] = np.vstack((np.hstack([time_stamp, predictions]), data))[:output_size,:] - return data - self.options['smm'].modify_variable("model_input", - insert_model_input) - self.options['smm'].modify_variable("model_output", - insert_model_output) - self.options['model_smm_writes'] += 1 - - message = f"{str(predictions)} {str(time_stamp)}\n" - if not self.tcp: - self.sock.sendto(bytes(message, 'utf-8'), (self.ip, self.port)) - else: - self.conn.sendall(str.encode(message)) + def visualize(self, + ip: str="127.0.0.1", + port: int=12346, + max_len: int = 50, + legend: bool = False): + """Plot a live visualization of the online regressor's predictions. Please note that the animation updates every 5 milliseconds, + so keep this in mind when choosing window size and increment. For example, a window increment that's too small may cause delay in the plotting + if the regressor is making predictions faster than the plot can be updated. + + Parameters + ---------- + ip: str (optional), default="localhost" + The ip to monitor for regressor outputs. + port: int (optional), default=12346 + The port to monitor for regressor outputs. + max_len: int (optional), default = 50 + Maximum number of predictions to plot at a time. Defaults to 50. + legend: bool (optional), default = False + True if a legend should be shown, otherwise False. Defaults to False. + """ - def visualize(self, max_len = 50, legend = False): - # TODO: Maybe add an extra option for 2 DOF problems where a single point is plotted on a 2D plane plt.style.use('ggplot') fig, ax = plt.subplots(layout='constrained') - fig.suptitle('Live Regressor Output', fontsize=20) + fig.suptitle('Live Regressor Output', fontsize=16) + ax.set_xlabel('Time (s)') + ax.set_ylabel('Prediction') - # Make local UDP socket whose purpose is to read from regressor output - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind((self.ip, self.port)) - - # Grab sample data to determine # of DOFs - data, _ = sock.recvfrom(1024) - data = str(data.decode('utf-8')) - predictions = self.parse_output(data)[0] + controller = RegressorController(ip=ip, port=port) + + # Wait for controller to start receiving data + predictions = None + while predictions is None: + predictions = controller.get_data('predictions') cmap = cm.get_cmap('turbo', len(predictions)) + plots = [ax.plot([], [], '.', color=cmap.colors[dof_idx], alpha=0.8)[0] for dof_idx in range(len(predictions))] + if legend: handles = [mpatches.Patch(color=cmap.colors[dof_idx], label=f"DOF {dof_idx}") for dof_idx in range(len(predictions))] - decision_horizon_predictions = [] - timestamps = [] start_time = time.time() - while True: - data, _ = sock.recvfrom(1024) - data = str(data.decode('utf-8')) - predictions, timestamp = self.parse_output(data) + + def update(frame, decision_horizon_predictions, timestamps): + data = controller.get_data(['predictions', 'timestamp']) + if data is None: + return + predictions, timestamp = data + timestamps.append(timestamp - start_time) decision_horizon_predictions.append(predictions) timestamps = timestamps[-max_len:] decision_horizon_predictions = decision_horizon_predictions[-max_len:] - if plt.fignum_exists(fig.number): - ax.clear() - ax.set_xlabel('Time (s)') - ax.set_ylabel('Prediction') - for dof_idx in range(len(predictions)): - ax.scatter(timestamps, np.array(decision_horizon_predictions)[:, dof_idx], color=cmap.colors[dof_idx], s=4, alpha=0.8) + for dof_idx in range(len(predictions)): + plots[dof_idx].set_xdata(timestamps) + plots[dof_idx].set_ydata(np.array(decision_horizon_predictions)[:, dof_idx]) - if legend: - ax.legend(handles=handles, loc='upper right') - plt.draw() - plt.pause(0.01) - else: - # Figure was closed - return + if legend: + ax.legend(handles=handles, loc='upper right') - @staticmethod - def parse_output(message): - outputs = re.findall(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?", message) - outputs = list(map(float, outputs)) - predictions = outputs[:-1] - timestamp = outputs[-1] - return predictions, timestamp + ax.relim() + ax.autoscale_view() + return plots + + _ = FuncAnimation(fig, partial(update, decision_horizon_predictions=[], timestamps=[]), interval=5, blit=False) # must return value or animation won't work + plt.show() diff --git a/libemg/environments/__init__.py b/libemg/environments/__init__.py new file mode 100644 index 00000000..8fb7cbbc --- /dev/null +++ b/libemg/environments/__init__.py @@ -0,0 +1 @@ +from libemg.environments import _base, controllers, emg_hero, fitts,curricular_fitts \ No newline at end of file diff --git a/libemg/environments/_base.py b/libemg/environments/_base.py new file mode 100644 index 00000000..1aba9ac8 --- /dev/null +++ b/libemg/environments/_base.py @@ -0,0 +1,88 @@ +import json +from abc import ABC, abstractmethod +from multiprocessing import Process +from pathlib import Path +import pickle +import os + +os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" # hide pygame welcome message +import pygame + +from libemg.environments.controllers import Controller + +class Environment(ABC): + def __init__(self, controller: Controller, fps: int, log_dictionary: dict, save_file: str | None = None): + """Abstract environment interface for pygame environments. + + Parameters + ---------- + controller : Controller + Controller instance that defines how control actions are parsed. + fps : int + Frames per second (Hz). + log_dictionary : dict + Dictionary containing metrics to log. + save_file : str | None, optional + Name of save file (e.g., log.pkl). Supports .json and .pkl file formats. If None, no results are saved. Defaults to None. + """ + # Assumes this is a pygame environment + self.controller = controller + self.done = False # flag to determine when loop should be exited + self.clock = pygame.time.Clock() + self.fps = fps + self.log_dictionary = log_dictionary + self.save_file = save_file + self.process = Process(target=self.run, daemon=True) + + @abstractmethod + def game_setup(self): + # setup things like font in here. + ... + + def run(self): + """Run environment in main loop. Blocks all further execution. Results are saved after task is completed.""" + pygame.init() + pygame.font.init() + pygame.mixer.init() + + self.game_setup() + while not self.done: + self._run_loop() + pygame.display.update() + self.clock.tick(self.fps) + + self.save_results() + + pygame.display.quit() + pygame.mixer.quit() + pygame.font.quit() + pygame.quit() + + @abstractmethod + def _run_loop(self): + ... + + def run_helper(self, block=True): + if block: + self.process.start() + self.process.join() + else: + self.process.start() + + + def save_results(self): + if self.save_file is None: + # Don't log anything + return + + file = Path(self.save_file).absolute() + file.parent.mkdir(parents=True, exist_ok=True) # create parent directories if they don't exist + + if file.suffix == '.pkl': + with open(self.save_file, 'wb') as f: + pickle.dump(self.log_dictionary, f) + elif file.suffix == '.json': + with open(self.save_file, 'w') as f: + json.dump(self.log_dictionary, f) + else: + raise ValueError(f"Unexpected file format '{file.suffix}'. Choose from '.pkl' or '.json'.") diff --git a/libemg/environments/controllers.py b/libemg/environments/controllers.py new file mode 100644 index 00000000..8b02af0c --- /dev/null +++ b/libemg/environments/controllers.py @@ -0,0 +1,283 @@ +from abc import ABC, abstractmethod +from typing import overload +import socket +import re +import time + +import numpy as np +import pygame + + +class Controller(ABC): + def __init__(self): + """Abstract controller interface for controlling environments. Runs as a Process in a separate thread and collects control signals continuously. Call start() to start collecting control signals.""" + self.info_function_map = { + 'predictions': self._parse_predictions, + 'pc': self._parse_proportional_control, + 'timestamp': self._parse_timestamp + } + # TODO: Maybe add a flag for continuous vs. not continuous... not sure if that's needed though + + @overload + def get_data(self, info: list[str]) -> tuple | None: + ... + + @overload + def get_data(self, info: str) -> list[float] | None: + ... + + def get_data(self, info: list[str] | str) -> tuple | list[float] | None: + """Get data from current action. This method should be used to access data to ensure that all parsing happens on the same action. Velocity control must be enabled when using proportional control. + + Parameters + ---------- + info: list[str] or str + Type of data requested. Must be a string in info_function_map. + """ + if isinstance(info, str): + # Cast to list + info = [info] + + action = self._get_action() + if action is None: + # Action didn't occur + return None + + + data = [] + for info_type in info: + try: + parse_function = self.info_function_map[info_type] + result = parse_function(action) + except KeyError as e: + raise ValueError(f"Unexpected value for info type. Accepted parameters are: {list(self.info_function_map.keys())}. Got: {info_type}.") from e + + data.append(result) + + data = tuple(data) # convert to tuple so unpacking can be used if desired + if len(data) == 1: + data = data[0] + return data + + @abstractmethod + def _parse_predictions(self, action: str) -> list[float]: + """Parse the latest prediction from a message. + + Parameters + ---------- + action: str + Message to parse. + + Returns + ---------- + list[float] + List of predictions. + """ + ... + + @abstractmethod + def _parse_proportional_control(self, action: str) -> list[float]: + """Parse the latest proportional control info from a message. + + Parameters + ---------- + action: str + Message to parse. + + Returns + ---------- + list[float] + List of proportional control values. + """ + ... + + + @abstractmethod + def _parse_timestamp(self, action: str) -> float: + """Parse the latest timestamp from a message. + + Parameters + ---------- + action : str + Message to parse. + + Returns + ------- + float + Timestamp. + """ + ... + + @abstractmethod + def _get_action(self) -> str | None: + """Grab the latest action. + + Returns + ---------- + str or None + Latest action or None if no action has occurred. + """ + ... + + +class SocketController(Controller): + def __init__(self, ip: str = '127.0.0.1', port: int = 12346) -> None: + """Controller interface for controlling environments using a UDP socket. + Runs as a Process in a separate thread and collects control signals continuously. Call start() to start collecting control signals. + + Parameters + ---------- + ip: str + IP address for UDP socket used to read messages. + port: int + Port for UDP socket used to read messages. + """ + super().__init__() + self.ip = ip + self.port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.bind((self.ip, self.port)) + self.sock.setblocking(False) + + @abstractmethod + def _parse_predictions(self, action: str) -> list[float]: + ... + + @abstractmethod + def _parse_proportional_control(self, action: str) -> list[float]: + ... + + @abstractmethod + def _parse_timestamp(self, action: str) -> float: + ... + + def _get_action(self): + try: + data, _ = self.sock.recvfrom(1024) + action = str(data.decode('utf-8')) + except BlockingIOError: + action = None + return action + + +class ClassifierController(SocketController): + def __init__(self, output_format: str, num_classes: int, ip: str = '127.0.0.1', port: int = 12346) -> None: + """Controller interface for controlling environments using a classifier. + Runs as a Process in a separate thread and collects control signals continuously. Call start() to start collecting control signals. + + Parameters + ---------- + output_format: str + Output format of classifier. Accepted values are 'probabliities' and 'predictions. + num_classes: int + Number of classes in classification problem. + ip: str + IP address for UDP socket used to read messages. + port: int + Port for UDP socket used to read messages. + """ + super().__init__(ip, port) + self.info_function_map['probabilities'] = self._parse_probabilities # add option for classifier to parse probabilities + self.output_format = output_format + self.num_classes = num_classes # could remove this parameter if we always sent a velocity value (e.g., set it to -1 if velocity control is not enabled) + self.error_message = f"Unexpected value for output_format. Accepted values are 'predictions' or 'probabilities'. Got: {output_format}." + if output_format not in ['predictions', 'probabilities']: + raise ValueError(self.error_message) + + def _parse_predictions(self, action: str) -> list[float]: + if self.output_format == 'predictions': + return [float(action.split(' ')[0])] + elif self.output_format == 'probabilities': + probabilities = self._parse_probabilities(action) + return [float(np.argmax(probabilities))] + + raise ValueError(self.error_message) + + def _parse_timestamp(self, action: str) -> float: + if self.output_format == 'predictions': + raise ValueError("Output format is set to 'predictions', so timestamp cannot be parsed because timestamp is not sent when output_format='predictions'.") + return float(action.split(' ')[-1]) + + def _parse_proportional_control(self, action: str) -> list[float]: + components = action.split(' ') + if self.output_format == 'predictions': + try: + return [float(components[1])] + except IndexError as e: + raise IndexError('Attempted to parse proportional control, but no velocity value was found. Please enable velocity control in the EMGClassifier.') from e + elif self.output_format == 'probabilities': + # Assume that user has enabled velocity control and take the value before the timestamp + if len(components) < (self.num_classes + 2): + raise ValueError('Did not find velocity value in message. Please enable velocity control in the EMGClassifier.') + return [float(components[-2])] + + raise ValueError(self.error_message) + + def _parse_probabilities(self, action: str) -> list[float]: + if self.output_format == 'predictions': + raise ValueError("Output format is set to 'predictions', so probabilities cannot be parsed. Set output_format='probabilities' if this functionality is needed.") + + return [float(prob) for prob in action.split(' ')[:self.num_classes]] + + +class RegressorController(SocketController): + """Controller interface for controlling environments using a regressor. + Runs as a Process in a separate thread and collects control signals continuously. Call start() to start collecting control signals. + + Parameters + ---------- + ip: str + IP address for UDP socket used to read messages. + port: int + Port for UDP socket used to read messages. + """ + def _parse_predictions(self, action: str) -> list[float]: + outputs = re.findall(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?", action) + outputs = list(map(float, outputs)) + return outputs[:-1] + + def _parse_timestamp(self, action: str) -> float: + outputs = re.findall(r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?", action) + outputs = list(map(float, outputs)) + return outputs[-1] + + def _parse_proportional_control(self, action: str) -> list[float]: + predictions = self._parse_predictions(action) + return [1. for _ in predictions] # proportional control is built into prediction, so return 1 for each DOF + + +class KeyboardController(Controller): + def __init__(self) -> None: + """Controller interface for controlling environments using a keyboard. start() method is not required because this doesn't run in a separate thread.""" + # No run method b/c using pygame events in another thread doesn't work (pygame.init is required but isn't thread-safe) + super().__init__() + self.keys = [ + pygame.K_LEFT, + pygame.K_RIGHT, + pygame.K_UP, + pygame.K_DOWN, + pygame.K_1, + pygame.K_2, + pygame.K_3, + pygame.K_4 + ] + + def _parse_predictions(self, action: str) -> list[float]: + return [float(action)] + + def _parse_proportional_control(self, action: str) -> list[float]: + predictions = self._parse_predictions(action) + return [1. for _ in predictions] # proportional control is built into prediction, so return 1 for each key pressed + + def _parse_timestamp(self, action: str) -> float: + return time.time() + + def _get_action(self): + keys = pygame.key.get_pressed() + keys_pressed = [key for key in self.keys if keys[key]] + if len(keys_pressed) == 0: + # No data received + keys_pressed = [-1] + + key_pressed = keys_pressed[0] # take the first value, maybe change later to support combined keys + return str(key_pressed) diff --git a/libemg/environments/curricular_fitts.py b/libemg/environments/curricular_fitts.py new file mode 100644 index 00000000..1d6e2891 --- /dev/null +++ b/libemg/environments/curricular_fitts.py @@ -0,0 +1,561 @@ +from libemg.output_writer import OutputWriter +from libemg.environments.controllers import Controller +from libemg.environments._base import Environment +from libemg.adaptation._base import produce_tciil_feedback +from dataclasses import dataclass +from multiprocessing import Process +from typing import Sequence +import time +import pickle +import pygame +import random +import numpy as np + +@dataclass +class CurricularFittsConfig: + """Configuration for the Curricular Fitts environment. + + Parameters + ---------- + num_trials : int + Number of fitts law target to spawn before the game is done. + width : int + Width of the main panel in pixels. + height : int + Height of the main panel in pixels. + fps : int + Frames per second (Hz). + block_height : int + Height of the info-block in pixels. + block_width : int + Width of the info-block in pixels. + default_target_radius : int + Default radius of the target in pixels. + default_timeout : float + Default timeout for the target in seconds. + default_speed : float + Default speed of the target in pixels per second. + cursor_radius : int + Radius of the cursor in pixels. + brownian_motion : bool + Whether the target should move (randomly). + cursor_speed_multiplier : int + The number multiplied by the controller output to determine the cursor speed. + color_bg : tuple[int, int, int] + Background color of the main panel in RGB format. + color_block_border : tuple[int, int, int] + Border color of the info-block in RGB format. + color_block_fill : tuple[int, int, int] + Fill color of the info-block in RGB format. + color_cursor : tuple[int, int, int] + Color of the cursor in RGB format. + color_target : tuple[int, int, int] + Color of the target in RGB format. + """ + # game parameters + num_trials: int = 100 + + # main panel parameters + width: int = 1000 + height: int = 1080 + fps: int = 60 + + # info-block + block_height: int = 80 + block_width: int = 1000 + + # cursor / target defaults + default_target_radius: int = 25 + default_timeout: float = 10.0 + default_speed: float = 1.0 + cursor_radius: int = 5 + brownian_motion: bool = False + cursor_speed_multiplier: int = 20 + target_countdown: float = 0.5 # seconds + + # color scheme - darkula by default + color_bg: tuple[int, int, int] = (0,0,0) + color_block_border: tuple[int, int, int] = (10, 0, 71) + color_block_fill: tuple[int, int, int] = (0, 70, 135) + color_cursor: tuple[int, int, int] = (0, 255, 210) + color_target: tuple[int, int, int] = (255, 68, 153) + color_target_good: tuple[int, int, int] = (255, 0, 0) # Color for successful target acquisition + + # controller related parameters + controller_fields : tuple[str, str] = ('predictions', 'timestamp') # for regression; add 'pc' for classification + controller_map : tuple[int, int] = (1,1) + + # feedback configuration + feedback_handle: callable = produce_tciil_feedback + +class Target: + def __init__(self, + screen, + config: CurricularFittsConfig, + radius : int = 10, + speed: float = 1): + self.screen = screen + self.config = config + self.color = [config.color_target, config.color_target_good] + self.contact = 0 # 0: not in contact, 1: in contact + self.radius = radius + self.randomize_location() + self.speed = speed + self.direction = [random.uniform(-1, 1), random.uniform(-1, 1)] + + def randomize_location(self): + x = random.randint(self.radius, self.config.width - self.radius) + y = random.randint(self.config.block_height + self.radius, self.config.height - self.radius) # Spawn below the information block + self.position = [x, y] + + def update(self): + if self.config.brownian_motion: + # Update target position with Brownian motion + self.position[0] += self.direction[0] * self.speed + self.position[1] += self.direction[1] * self.speed + + # Randomly change direction occasionally + if random.random() < 0.01: # Adjust frequency of direction change + self.direction = [random.uniform(-1, 1), random.uniform(-1, 1)] + + # Keep the target within the bounds of the play area + if self.position[0] < self.radius or self.position[0] > self.config.width - self.radius: + self.direction[0] *= -1 + if self.position[1] < self.config.block_height + self.radius or self.position[1] > self.config.height - self.radius: + self.direction[1] *= -1 + + def draw(self): + if self.contact: + pygame.draw.circle(self.screen, self.color[1], (int(self.position[0]), int(self.position[1])), self.radius) + else: + pygame.draw.circle(self.screen, self.color[0], (int(self.position[0]), int(self.position[1])), self.radius) + + def reset(self): + self.randomize_location() # Spawn in a new location + +class BaseTargetGenerator: + def __init__(self, + N, + F, + P): + self.N = N # Number of trials or reversals + self.F = F # Factor for increase on miss + self.P = P # Factor for decrease on hit + self.yields = [] # Store yields for logging + + def save_yields(self, filename="target_yields.pkl"): + with open(filename, 'wb') as f: + pickle.dump(self.yields, f) + +class ConstantTargetGenerator(BaseTargetGenerator): + def __init__(self, + config: CurricularFittsConfig): + super().__init__(N=config.num_trials, + F=0, # nothing changes + P=0 # nothing changes + ) + self.config = config + self.current_radius = config.default_target_radius + self.current_timeout = config.default_timeout + self.counter = 0 + + def generate(self, + result: bool): + # result = 1 : Passed + # result = 0 : Failed (via timeout or miss) + if self.counter < self.N: + self.counter += 1 + self.yields.append((self.current_radius, 0, self.current_timeout)) + return self.current_radius, 0, self.current_timeout + +class RadiusTargetGenerator(BaseTargetGenerator): + def __init__(self, + config: CurricularFittsConfig, + F, + P): + super().__init__(N=config.num_trials, + F=F, + P=P) + self.config = config + self.current_radius = config.default_target_radius + self.last_result = -1 + self.counter = 0 + self.reversal_counter = 0 + + def generate(self, result): + # a success + if result == 1: + factor = 1 - (self.P / 100) + if int(self.current_radius * factor) == self.current_radius and self.P != 0: + # if its just marginally smaller, reduce by a whole pixel. + self.current_radius = self.current_radius - 1 + else: + self.current_radius *= factor + # a fail + elif result == 0: + factor = 1 + (self.F / 100) + if int(self.current_radius * factor) == self.current_radius and self.F != 0: + self.current_radius = self.current_radius + 1 + else: + self.current_radius *= factor + # first spawn + else: + pass + self.current_radius = int(max([self.config.cursor_radius, self.current_radius]))# don't allow it smaller than cursor radius + + # check reversals + if self.last_result != -1: + if result != self.last_result: + self.reversal_counter += 1 + + self.counter += 1 + self.last_result = result + + self.yields.append((self.current_radius, 0, self.config.default_timeout)) # Assume constant radius and speed of zero + return self.current_radius, 0, self.config.default_timeout + +class SpeedTargetGenerator(BaseTargetGenerator): + def __init__(self, + config : CurricularFittsConfig, + F, + P): + super().__init__(N=config.num_trials, F=F, P=P) + self.config = config + self.current_speed = config.default_speed + self.last_result = -1 + self.counter = 0 + self.reversal_counter = 0 + + def generate(self, result): + # a success + if result == 1: + self.current_speed *= (1 + self.P / 100) + # a fail + elif result == 0: + self.current_speed *= (1 - self.F / 100) + # first spawn + else: + pass + + # check reversals + if self.last_result != -1: + if result != self.last_result: + self.reversal_counter += 1 + + self.counter += 1 + self.last_result = result + + self.yields.append((self.config.default_target_radius, self.current_speed, self.config.default_timeout)) # Assume constant radius and speed + return self.config.default_target_radius, self.current_speed, self.config.default_timeout + +class TimeoutTargetGenerator(BaseTargetGenerator): + def __init__(self, + config : CurricularFittsConfig, + F, + P): + super().__init__(N=config.num_trials, + F=F, + P=P) + self.config = config + self.current_timeout = config.default_timeout + self.last_result = -1 + self.counter = 0 + self.reversal_counter = 0 + + def generate(self, result): + # a success + if result == 1: + self.current_timeout *= (1 - self.P / 100) + # a fail + elif result == 0: + self.current_timeout *= (1 + self.F / 100) + # first spawn + else: + pass + + # check reversals + if self.last_result != -1: + if result != self.last_result: + self.reversal_counter += 1 + + self.counter += 1 + self.last_result = result + + self.yields.append((self.config.default_target_radius, 0, self.current_timeout)) # Assume constant radius and speed of 0 + return self.config.default_target_radius, 0, self.current_timeout + +class Log: + def __init__(self): + self.entries = { + "trial_number": [], + "target_position": [], + "cursor_position": [], + "target_size": [], + "feedback": [], + "timestamp": [] + } + + def record(self, trial_number, target_position: list, cursor_position: list, target_size, feedback:list, timestamp): + self.entries['trial_number'].append(trial_number) + self.entries['target_position'].append(target_position.copy()) + self.entries['cursor_position'].append(cursor_position.copy()) + self.entries['target_size'].append(target_size) + self.entries['feedback'].append(feedback.copy()) + self.entries['timestamp'].append(timestamp) + + def save(self, dir, trial_number, result): + filename = f'trial_log_{trial_number}_{result}.pkl' + with open(dir + "/" + filename, 'wb') as f: + pickle.dump(self, f) + + def __add__(self, obj): + log = Log() + log.entries['trial_number'].extend(self.entries['trial_number']) + log.entries['trial_number'].extend(obj.entries['trial_number']) + log.entries['target_position'].extend(self.entries['target_position']) + log.entries['target_position'].extend(obj.entries['target_position']) + log.entries['cursor_position'].extend(self.entries['cursor_position']) + log.entries['cursor_position'].extend(obj.entries['cursor_position']) + log.entries['target_size'].extend(self.entries['target_size']) + log.entries['target_size'].extend(obj.entries['target_size']) + log.entries['feedback'].extend(self.entries['feedback']) + log.entries['feedback'].extend(obj.entries['feedback']) + log.entries['timestamp'].extend(self.entries['timestamp']) + log.entries['timestamp'].extend(obj.entries['timestamp']) + return log + +class InformationBlock: + def __init__(self, + screen, + config: CurricularFittsConfig, + font_size: int = 50, + header_font_size: int = 30, + countdown_seconds: int = 60): + self.screen = screen + self.config = config + self.rect = pygame.Rect(0, 0, config.block_width, config.block_height) + self.font = pygame.font.Font(None, font_size) + self.header_font = pygame.font.Font(None, header_font_size) + self.update_countdown_seconds(countdown_seconds) + self.update_trial_number(0) + self.update_metadata("") + self.color = config.color_cursor # Default text color + + def update_trial_number(self, trial_number): + self.trial_number = trial_number + + def update_metadata(self, metadata): + self.metadata = metadata + + def update_countdown_seconds(self, countdown_seconds): + self.countdown_seconds = countdown_seconds + self.start_time = time.time() + + def get_remaining_time(self): + elapsed_time = time.time() - self.start_time + remaining_time = self.countdown_seconds - elapsed_time + return max(remaining_time, 0) + + def draw(self): + pygame.draw.rect(self.screen, self.config.color_block_fill, self.rect) + pygame.draw.rect(self.screen, self.config.color_block_border, self.rect, 2) # Draw border + + headers = ["Trial:", "Time:", "Metadata:"] + values = [f"{self.trial_number}", f"{self.get_remaining_time():.1f}s", self.metadata] + + header_surfaces = [self.header_font.render(header, True, self.color) for header in headers] + value_surfaces = [self.font.render(value, True, self.color) for value in values] + + column_width = self.rect.width // 3 + + for i, header_surface in enumerate(header_surfaces): + header_x = self.rect.x + (i * column_width) + (column_width - header_surface.get_width()) // 2 + value_x = self.rect.x + (i * column_width) + (column_width - value_surfaces[i].get_width()) // 2 + + header_y = self.rect.y + 5 + value_y = header_y + header_surface.get_height() + 5 + + self.screen.blit(header_surface, (header_x, header_y)) + self.screen.blit(value_surfaces[i], (value_x, value_y)) + + +class Cursor: + def __init__(self, + screen, + config: CurricularFittsConfig): + self.screen = screen + self.config = config + self.radius = config.cursor_radius + self.color = config.color_cursor # Cursor color + self.position = [0, 0] # Initial position + + def update(self, update_direction): + if update_direction is not None: + self.position[0] += update_direction[0] * self.config.cursor_speed_multiplier * self.config.controller_map[0] + self.position[1] += update_direction[1] * self.config.cursor_speed_multiplier * self.config.controller_map[1] + + # Ensure cursor stays in the bounds of the screen + self.position[0] = max(0, self.position[0]) + self.position[0] = min(self.config.width - self.radius, self.position[0]) + self.position[1] = max(0, self.position[1]) + self.position[1] = min(self.config.height - self.radius, self.position[1]) + + def draw(self): + pygame.draw.circle(self.screen, self.color, (int(self.position[0]), int(self.position[1])), self.radius) + + +class CurricularFitts(Environment): + """ + """ + + def __init__(self, + controller: Controller, + config: CurricularFittsConfig, + target_generator: BaseTargetGenerator = None, + environment_ow: list[OutputWriter] = [], + save_file: str | None = None): + + super().__init__(controller, + fps=config.fps, + log_dictionary=None, + save_file=save_file) + + self.config = config + + # ConstantTargetGenerator takes the config, and reads the radius and + # the timeout off it itself. Passing them as keywords raised a + # TypeError, which meant a CurricularFitts could not be built at all + # without supplying a target generator explicitly. + self.target_generator = target_generator or ConstantTargetGenerator(self.config) + + self.environment_ow = environment_ow + + self.predictions = None + self.timestamp = None + + self.game_feedback = None + self.model_feedback = None + + def game_setup(self): + self.screen = pygame.display.set_mode((self.config.width, self.config.height)) + pygame.display.set_caption("LibEMG -> Curricular Fitts Law") + + self.cursor = Cursor(self.screen, self.config) + self.target = Target(self.screen, self.config, radius=self.config.default_target_radius, speed=self.config.default_speed) + self.log = Log() + + self.trial_number = 1 + self._start_new_trial(initial=True) + + def _start_new_trial(self, + initial: bool = False, + result: bool = True): + """ + Get new target parameters (size, timeout, speed) from the generator, and use this to setup the next target. + """ + # if we've finished a trial, save the log, increment the trial counter + if not initial: + self.log.save(self.save_file, self.trial_number, result) + self.log = Log() + self.trial_number += 1 + + self.cursor_timer = time.time() + + # get the radius, speed, and timeout from the generator + radius, speed, timeout = self.target_generator.generate(result) + + self.timeout = timeout + self.target.radius = radius + self.target.speed = speed + self.target.randomize_location() + + # update the information block + self.info_block = InformationBlock( + self.screen, + self.config, + countdown_seconds = timeout + ) + self.info_block.update_trial_number(self.trial_number) + + self.trial_distance = np.linalg.norm([x - y for x,y in zip(self.cursor.position, self.target.position)]) + + def _run_loop(self): + self.input() + self.update() + self.draw() + if self.trial_number > self.config.num_trials: + self.done = True + + def input(self): + self.pygame_inputs() + self.controller_inputs() + self.check_collisions() + + def pygame_inputs(self): + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.done = True + + def controller_inputs(self): + # get the controller message + + # get the feedback ready for this message + data = self.controller.get_data(self.config.controller_fields) + if data is not None: + self.predictions = data[0] + self.direction = [i*j for i,j in zip(self.predictions, self.config.controller_map)] + self.timestamp = data[1] + # game feedback is in the game space, i.e, down is positive y, right is positive x. + self.game_feedback = self.config.feedback_handle(self.cursor.position, self.direction, self.target.position, self.target.radius, self.trial_distance) + # model feedback is in the classifier space, so we need to transform it BACK via multiplying by controller_map again + self.model_feedback = [i*j for i,j in zip(self.game_feedback, self.config.controller_map)] + self.info = {'timestamp': self.timestamp, + 'environment_feedback': self.model_feedback, + 'game_feedback': self.game_feedback, + 'trial': self.trial_number, + 'prediction': self.predictions, + 'direction': self.direction} + self.log.record(self.trial_number, self.target.position, self.cursor.position, self.target.radius, self.game_feedback, self.timestamp) + + # Truthiness, not "is not None". environment_ow defaults to an + # empty list, which is not None, so the index below raised + # IndexError on the first frame that produced a control signal + # whenever the task was run without adaptation writers attached. + if self.environment_ow: + self.environment_ow[0].write(self.info) + # make self._info, + # save last timestamp, last controller output, etc. + + + def check_collisions(self): + target_rect = pygame.Rect(self.target.position[0] - self.target.radius, + self.target.position[1] - self.target.radius, + self.target.radius * 2, self.target.radius * 2) + + # TODO: Make a countdown for this to acquire the target + if target_rect.collidepoint(self.cursor.position): + self.target.contact = 1 + if time.time() - self.cursor_timer > self.config.target_countdown: + self.info_block.update_metadata("Hit!") + self._start_new_trial(initial=False, result=1) + else: + self.cursor_timer = time.time() + self.target.contact = 0 + + def update(self): + self.target.update() + self.cursor.update(self.predictions) + + if self.info_block.get_remaining_time() <= 0: + self.info_block.update_metadata("Timeout!") + self._start_new_trial(initial=0, result=0) + + def draw(self): + self.screen.fill(self.config.color_bg) + self.info_block.draw() + self.target.draw() + self.cursor.draw() + pygame.display.flip() + + def save_results(self): + self.target_generator.save_yields() \ No newline at end of file diff --git a/libemg/environments/emg_hero.py b/libemg/environments/emg_hero.py new file mode 100644 index 00000000..c863b1c7 --- /dev/null +++ b/libemg/environments/emg_hero.py @@ -0,0 +1,185 @@ +import time +from typing import Sequence + +import pygame +import numpy as np + +from libemg.environments.controllers import Controller +from libemg.environments._base import Environment + + +class _Note: + def __init__(self, type): + self.type = type + assert self.type in [0,1,2,3] + y_poses = [75, 200, 325, 450] + colors = [(255, 0, 0),(0, 255, 0),(0, 0, 255),(255, 165, 0)] + # Based on the type, set up the note + self.x_pos = y_poses[self.type] + self.y_pos = 0 + self.color = colors[self.type] + self.length = 35 * (5 * np.random.random()) # Random integer between 1 and 5 + + def move_note(self, speed=5): + self.y_pos += speed + if self.y_pos > 1000: + return -1 + return 0 + + +class EMGHero(Environment): + def __init__(self, controller: Controller, prediction_map: dict | None = None, test_time: int = 120, min_speed: float = 2.5, max_speed: float = 7.5, min_time: float = 0.6, max_time: float = 2.2, + img_files: Sequence | None = None, save_file: str | None = None, fps: int = 60): + """Guitar Hero style game that tests user's ability to elicit contractions at specific times. Game speed progressively gets quicker over the course of the task. + Simultaneous contractions, such as with regression, are not currently supported. + + Parameters + ---------- + controller : Controller + Interface to parse predictions which determine the notes being played. + prediction_map : dict | None, optional + Maps received control commands to notes being played. If None, a standard map for classifiers is created where 0, 1, 2, 3, 4 are mapped to 0, 1, -1, 2, and 3, respectively. + For custom mappings, pass in a dictionary where keys represent received control signals (from the Controller) and values map to actions in the environment. + Accepted actions are: -1 (play nothing), 0 (first note), 1 (second note), 2 (third note), 3 (fourth note). All of these actions must be represented by a single key in the dictionary. + Defaults to None. + test_time : int, optional + Amount of time test will take (in seconds). Defaults to 120. + min_speed : float, optional + Minimum game speed. Defaults to 2.5. + max_speed : float, optional + Maximum game speed. Defaults to 7.5. + min_time : float, optional + Minimum time between notes (in seconds). + max_time : float, optional + Maximum time between notes (in seconds). + img_files : Sequence | None, optional + List of image filenames to put at the bottom of the display to show users which notes are represented by which gestures. If None, no images are shown. Defaults to None. + save_file : str | None, optional + Path to save file for logging metrics. If None, no results are logged. Defaults to None. + fps : int, optional + Frames per second (in Hz). Defaults to 60. + """ + if prediction_map is None: + prediction_map = { + 0: 0, + 1: 1, + 2: -1, + 3: 2, + 4: 3 + } + + assert set(np.unique(list(prediction_map.values()))) == set([0, 1, -1, 2, 3]), f"Did not find all commands (0, 1, 2, 3, -1) represented as values in prediction_map. Got: {prediction_map}." + + if img_files is None: + img_files = [] + + log_dictionary = { + "times": [], + "notes": [], + "button_pressed": [] + } + super().__init__(controller, fps=fps, log_dictionary=log_dictionary, save_file=save_file) + self.prediction_map = prediction_map + self.test_time = test_time + self.min_speed = min_speed + self.max_speed = max_speed + self.min_time = min_time + self.max_time = max_time + + self.imgs = [] + if len(img_files) > 0: + assert len(img_files) == 4, f"Expected 4 image files, but got {len(img_files)}." + for i in img_files: + self.imgs.append(pygame.transform.smoothscale(pygame.image.load(i), (100,100))) + + + + + def game_setup(self): + pygame.display.set_caption('Testing Environment') + self.font = pygame.font.SysFont('Comic Sans MS', 30) + self.screen = pygame.display.set_mode([525, 700]) + + + self.last_note = time.time() + self.start_time = time.time() + self.test_time + + self.notes = [] + self.key_pressed = -1 + + + def _run_loop(self): + # Run until the user asks to quit + gen_time = ((self.start_time - time.time())/self.test_time) * (self.max_time - self.min_time) + self.min_time + if time.time() - self.last_note > gen_time: # Generation + new_note = np.random.randint(0,4) + self.notes.append(_Note(new_note)) + self.last_note = time.time() + + if self.start_time - time.time() <= 0: + # Time's up + self.done = True + + # Fill the background with white + self.screen.fill((255, 255, 255)) + + # Update time remaining + text = self.font.render('{0:.1f}'.format(self.start_time - time.time()), True, (0,0,0), (255,255,255)) + textRect = text.get_rect() + textRect.center = (470, 25) + self.screen.blit(text, textRect) + + # Did the user click the window close button? + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.done = True + + predictions = self.controller.get_data('predictions') + + if predictions is not None: + # Received data + assert len(predictions) == 1, f"Expected a single prediction, but got {len(predictions)}. Controllers that produce multiple predictions, like RegressionController, are not currently supported." + self.key_pressed = self.prediction_map[predictions[0]] + + # Draw notes on bottom of screen + pygame.draw.circle(self.screen, (255, 0, 0), (75, 500), 35, width=8 - (self.key_pressed==0) * 8) + pygame.draw.circle(self.screen, (0, 255, 0), (200, 500), 35, width=8 - (self.key_pressed==1) * 8) + pygame.draw.circle(self.screen, (0, 0, 255), (325, 500), 35, width=8 - (self.key_pressed==2) * 8) + pygame.draw.circle(self.screen, (255, 165, 0), (450, 500), 35, width=8 - (self.key_pressed==3) * 8) + + # Move and deal with notes coming down + # speed only depends on the frame time, so compute it once per frame rather than per note + speed = (1 - (self.start_time - time.time())/self.test_time) * (self.max_speed - self.min_speed) + self.min_speed + # Rebuild the list instead of calling self.notes.remove(n) while iterating over self.notes: + # removing during iteration skips the following element, so off-screen notes leaked and the + # per-frame log below grew over that ever-growing list. Order of the surviving notes is preserved. + remaining_notes = [] + for n in self.notes: + if n.move_note(speed=speed) == -1: + # Note has travelled past the bottom of the window (y > 1000 on a 700px tall screen), + # so it is discarded and no longer drawn. + continue + remaining_notes.append(n) + # Check to see if the shape is over top of the note + w = 0 + if n.type == self.key_pressed and n.y_pos >= 500 and n.y_pos - 60 - n.length <= 500: + w = 5 + pygame.draw.circle(self.screen, n.color, (n.x_pos, n.y_pos), 35, width=w) + pygame.draw.rect(self.screen, n.color, (n.x_pos - 20, n.y_pos - 30 - n.length, 40, n.length), width=w) + pygame.draw.circle(self.screen, n.color, (n.x_pos, n.y_pos - 60 - n.length), 35, width=w) + self.notes = remaining_notes + + pygame.draw.rect(self.screen, (255,255,255), (0, 550, 1000, 300)) + + # Draw images on screen + if len(self.imgs) > 0: + self.screen.blit(self.imgs[0], (25,550)) + self.screen.blit(self.imgs[1], (150,550)) + self.screen.blit(self.imgs[2], (275,550)) + self.screen.blit(self.imgs[3], (400,550)) + + # Log everything + self.log_dictionary['times'].append(time.time()) + self.log_dictionary['notes'].append([[n.type, n.y_pos, n.length] for n in self.notes]) + self.log_dictionary['button_pressed'].append(self.key_pressed) + \ No newline at end of file diff --git a/libemg/environments/fitts.py b/libemg/environments/fitts.py new file mode 100644 index 00000000..a6b9fcf7 --- /dev/null +++ b/libemg/environments/fitts.py @@ -0,0 +1,450 @@ +import math +import time +from dataclasses import dataclass + +import pygame +import numpy as np + +from libemg.environments.controllers import Controller +from libemg.environments._base import Environment + + +OUTSIDE_TARGET = pygame.USEREVENT + 1 +INSIDE_TARGET = pygame.USEREVENT + 2 + + +@dataclass(frozen=True) +class FittsConfig: + """Dataclass to customize parameters (e.g., target size and color) for a real-time Fitts' Law style task. + + Parameters + ---------- + num_trials : int + Number of trials user must complete. + dwell_time : float, optional + Time (in seconds) user must dwell in target to complete trial. Defaults to 1.0. + timeout : float | None, optional + Time limit (in seconds) that signifies a failed trial. If None, no timeout is used. Defaults to None. + velocity : float, optional + Velocity scalar that controls the max speed of the cursor. Defaults to 25. + save_file : str | None, optional + Name of save file (e.g., log.pkl). Supports .json and .pkl file formats. If None, no results are saved. Defaults to None. + width : int, optional + Width of display (in pixels). Defaults to 1250. + height : int, optional + Height of display (in pixels). Defaults to 750. + fps : int, optional + Frames per second (in Hz). Defaults to 60. + proportional_control : bool, optional + True if proportional control should be used, otherwise False. This value is ignored for Controllers that have proportional control built in, like regressors. Defaults to False. + target_radius : int, optional + Radius (in pixels) of each individual target. Defaults to 40. + cursor_radius : int, optional + Radius (in pixels) of cursor. Defaults to 14. + game_time : float, optional + Time (in seconds) that the task should run. If None, no time limit is set and the task ends when the number of targets are acquired. + If a value is passed, the task is stopped when either the time limit has been reached or the number of trials has been acquired. Defaults to None. + mapping : str, optional + Space to map predictions to. Setting this to 'cartesian' uses the standard Fitts' style input space, where predictions map to the x and y position of the cursor. + Setting this mapping to polar will instead map horizontal and vertical predictions to the radius and angle of a semi-circle, respectively (similar to spinning a wheel). + Pass in 'polar+' or 'polar-' to map up or down to counter-clockwise changes in angle, respectively. Defaults to 'cartesian'. + cursor_color : tuple, optional + Color of cursor. Pass in a tuple of the format (red, green, blue), where each color value is in the range 0-255. Defaults to orange (255, 95, 31). + cursor_in_target_color : tuple, optional + Color of cursor when in target. Pass in a tuple of the format (red, green, blue), where each color value is in the range 0-255. Defaults to blue (0, 102, 204). + target_color : tuple, optional + Color of targets. Pass in a tuple of the format (red, green, blue), where each color value is in the range 0-255. Defaults to white (255, 255, 255). + background_color : tuple, optional + Color of background. Pass in a tuple of the format (red, green, blue), where each color value is in the range 0-255. Defaults to black (0, 0, 0). + timer_color : tuple, optional + Color of dwell timer. Pass in a tuple of the format (red, green, blue), where each color value is in the range 0-255. Defaults to blue (0, 102, 204). + """ + num_trials: int + dwell_time: float = 1.0 + timeout: float | None = None + velocity: float = 25.0 + save_file: str | None = None + width: int = 1250 + height: int = 750 + fps: int = 60 + proportional_control: bool = True + target_radius: int = 40 + cursor_radius: int = 7 + game_time: float | None = None + mapping: str = 'cartesian' + cursor_color: tuple[int, int, int] = (255, 95, 31) + cursor_in_target_color: tuple[int, int, int] = (0, 102, 204) + target_color: tuple[int, int, int] = (255, 255, 255) + background_color: tuple[int, int, int] = (0, 0, 0) + timer_color: tuple[int, int, int] = (0, 102, 204) + + +class Fitts(Environment): + def __init__(self, controller: Controller, config: FittsConfig, prediction_map: dict | None = None): + """Fitts style task. Targets are generated at random and the user is asked to acquire targets as quickly as possible. + + Parameters + ---------- + controller : Controller + Interface to parse predictions which determine the direction of the cursor. + config : FittsConfig + Configuration class that determines environment parameters (e.g., target size). + prediction_map : dict | None, optional + Maps received control commands to cursor movement - only used if a non-continuous controller is used (e.g., classifier). If a continuous controller is used (e.g., regressor), + then 2 DoFs are expected when parsing predictions and this parameter is not used. If None, a standard map for classifiers is created where 0, 1, 2, 3, 4 are mapped to + down, up, no motion, right, and left, respectively. For custom mappings, pass in a dictionary where keys represent received control signals (from the Controller) and + values map to actions in the environment. Accepted actions are: 'S' (down), 'N' (up), 'NM' (no motion), 'E' (right), and 'W' (left). All of these actions must be + represented by a single key in the dictionary. Defaults to None. + """ + # logging information + log_dictionary = { + 'time_stamp': [], + 'trial_number': [], + 'goal_target' : [], + 'global_clock' : [], + 'cursor_position': [], + 'class_label': [], + 'current_direction': [] + } + default_prediction_map = { + 0: 'S', + 1: 'N', + 2: 'NM', + 3: 'E', + 4: 'W' + } + self.config = config + super().__init__(controller, fps=self.config.fps, log_dictionary=log_dictionary, save_file=self.config.save_file) + + if self.config.mapping == 'cartesian': + self.render_as_polar = False + elif self.config.mapping in ['polar+', 'polar-']: + self.render_as_polar = True + else: + raise ValueError(f"Unexpected value for mapping. Got: {self.config.mapping}.") + + if prediction_map is None: + prediction_map = default_prediction_map + assert set(np.unique(list(prediction_map.values()))) == set(list(default_prediction_map.values())), f"Did not find all commands {list(default_prediction_map.values())} represented as values in prediction_map. Got: {prediction_map}." + + self.prediction_map = prediction_map + self.current_direction = [0., 0.] + + def game_setup(self): + self.font = pygame.font.SysFont('helvetica', 40) + self.screen = pygame.display.set_mode([self.config.width, self.config.height]) + + # gameplay parameters + self.trial = -1 + + if '+' in self.config.mapping: + theta_bounds = (math.pi, 0) + else: + theta_bounds = (0, math.pi) + + self.theta_bounds = theta_bounds + self.polar_origin = (self.config.width // 2, self.config.height // 2) + self.polar_origin = (self.config.width // 2, self.config.height) + self.polygon_angles = np.linspace(0, 2 * math.pi, num=100) # could change how many points are calculated based on desired FPS (having 1000 caused frame rate issues) + + # interface objects + self.cursor = pygame.Rect(self.config.width // 2 - self.config.cursor_radius, self.config.height // 2 - self.config.cursor_radius, + self.config.cursor_radius * 2, self.config.cursor_radius * 2) + self._get_new_goal_target() + self.current_direction = [0,0] + + self.timeout_timer = None + self.trial_duration = 0 + self._info = ['predictions', 'timestamp'] + if self.config.proportional_control: + self._info.append('pc') + self.start_time = time.time() + self.dwell_timer = None + self.duration = 0 + self.Event_Flag = False # _check_events now reads this, so make sure it always exists + + def _draw(self): + self.screen.fill(self.config.background_color) + self._draw_targets() + self._draw_cursor() + self._draw_timer() + + def _draw_targets(self): + self._draw_circle(self.goal_target, self.config.target_color) + + def _draw_cursor(self): + color = self.config.cursor_in_target_color if self.dwell_timer is not None else self.config.cursor_color + self._draw_circle(self.cursor, color) + + def _draw_timer(self): + if self.dwell_timer is not None: + toc = time.perf_counter() + duration = round((toc-self.dwell_timer),2) + time_str = str(duration) + draw_text = self.font.render(time_str, 1, self.config.timer_color) + self.screen.blit(draw_text, (10, 10)) + + def _update_game(self): + self._draw() + self._run_game_process() + self._move() + + def _run_game_process(self): + self._check_collisions() + self._check_events() + + def _check_collisions(self): + # Collision state is communicated to _check_events through self.Event_Flag. It used to also be + # posted as an INSIDE_TARGET/OUTSIDE_TARGET pygame event, but nothing consumes those events + # (only _check_events read them, and it now uses the flag), so the per-frame posts are dropped. + if math.sqrt((self.goal_target.centerx - self.cursor.centerx)**2 + (self.goal_target.centery - self.cursor.centery)**2) < (self.goal_target[2]/2 + self.cursor[2]/2): + self.Event_Flag = True + else: + self.Event_Flag = False + + def _check_events(self): + # closing window + for event in pygame.event.get(): + if event.type == pygame.QUIT: + self.done = True + return + + if self.config.game_time is not None and (time.time() - self.start_time) >= self.config.game_time: + self.done = True + return + + data = self.controller.get_data(self._info) + + #self.current_direction = [0., 0.] + if data is not None: + # Move cursor + predictions = data[0] + timestamp = data[1] + if len(data) == 3: + pc = data[2] + else: + pc = [1. for _ in predictions] + + if len(predictions) == 1 and len(pc) == 1: + # Output is a class/action, not a set of DOFs + prediction = predictions[0] + pc = pc[0] + direction = self.prediction_map[prediction] + + if direction == 'N': + predictions = [0, 1] + elif direction == 'E': + predictions = [1, 0] + elif direction == 'S': + predictions = [0, -1] + elif direction == 'W': + predictions = [-1, 0] + elif direction == 'NM': + predictions = [0, 0] + else: + raise ValueError(f"Expected prediction map to have keys 'N', 'E', 'S', 'W', and 'NM', but found key: {direction}.") + + pc = [pc, pc] + + self.current_direction[0] = self.config.velocity * float(predictions[0]) * pc[0] + self.current_direction[1] = -1* self.config.velocity * float(predictions[1]) * pc[1] # -ve b/c pygame origin pixel is at top left of screen + + self._log(str(predictions), timestamp) + + if len(self.log_dictionary['time_stamp']) == 0: + # No data has been received, so don't start counting + print('Waiting for Fitts to receive data...') + return + + ## CHECKING FOR COLLISION BETWEEN CURSOR AND RECTANGLES + # This used to read `event`, the leftover loop variable from the pygame.event.get() loop above, + # which raises NameError whenever the event queue is empty. self.Event_Flag, set by + # _check_collisions immediately before this call, is the explicit collision state. + if not self.Event_Flag: + self.dwell_timer = None + self.duration = 0 + else: + if self.dwell_timer is None: + self.dwell_timer = time.perf_counter() + else: + toc = time.perf_counter() + self.duration = round((toc - self.dwell_timer), 2) + if self.duration >= self.config.dwell_time: + self._get_new_goal_target() + + if self.timeout_timer is None: + self.timeout_timer = time.perf_counter() + else: + toc = time.perf_counter() + self.trial_duration = round((toc - self.timeout_timer), 2) + + if self.config.timeout is not None and self.trial_duration >= self.config.timeout: + # Timeout + self._get_new_goal_target() + + def _move(self): + self.cursor.left += self.current_direction[0] + self.cursor.top += self.current_direction[1] + + # Ensure cursor stays in the bounds of the screen + self.cursor.left = max(0, self.cursor.left) + self.cursor.left = min(self.config.width - self.cursor.width, self.cursor.left) + self.cursor.top = max(0, self.cursor.top) + self.cursor.top = min(self.config.height - self.cursor.height, self.cursor.top) + + def _get_new_goal_target(self): + self.dwell_timer = None + self.timeout_timer = None + self.trial_duration = 0 + + max_radius = int(min(self.config.width, self.config.height) * 0.5) # only create targets in a centered circle (based on size of screen) + while True: + target_radius = np.random.randint(self.cursor[2], self.config.target_radius) + target_position_radius = np.random.randint(0, max_radius - target_radius) + target_angle = np.random.uniform(0, 2 * math.pi) + # Convert to cartesian (relative to pygame origin, not center of screen) + x = self.config.width // 2 + target_position_radius * math.cos(target_angle) + y = self.config.height // 2 - target_position_radius * math.sin(target_angle) # subtract b/c y is inverted in pygame + # Continue until we create a target that isn't on the cursor + if math.dist((x, y), self.cursor.center) > (target_radius + self.cursor[2] // 2): + break + + left = x - target_radius + top = y - target_radius + self.goal_target = pygame.Rect(left, top, target_radius * 2, target_radius * 2) + + self.trial += 1 + if self.trial == self.config.num_trials: + self.done = True + + def _log(self, label, timestamp): + self.log_dictionary['time_stamp'].append(timestamp) + self.log_dictionary['trial_number'].append(self.trial) + self.log_dictionary['goal_target'].append((self.goal_target.centerx, self.goal_target.centery, self.goal_target[2])) + self.log_dictionary['global_clock'].append(time.perf_counter()) + self.log_dictionary['cursor_position'].append((self.cursor.centerx, self.cursor.centery, self.cursor[2])) + self.log_dictionary['class_label'].append(label) + self.log_dictionary['current_direction'].append(self.current_direction) + + def _map_to_polar_space(self, x, y): + radius = np.interp(x, (0, self.config.width), (0, min(self.config.width // 2, self.config.height))) # limit radius based on screen dimensions so circles stay on screen + theta = np.interp(y, (0, self.config.height), self.theta_bounds) + + # theta is the angle from the right side of the screen and goes counter-clockwise (same as unit circle) + polar_x = radius * np.cos(theta) + self.polar_origin[0] + polar_y = self.polar_origin[1] - radius * np.sin(theta) # subtract b/c y is inverted in pygame + + return polar_x, polar_y + + def _draw_circle(self, rect, color, fill = True, draw_radius = False): + # Keep the underlying circle (e.g., target or cursor) coordinates the same, but render as polar to keep downstream calculations the same + polygon_width = 0 if fill else 2 + target_radius = rect.width // 2 + + if not self.render_as_polar: + pygame.draw.circle(self.screen, color, rect.center, target_radius, width=polygon_width) + return + + points = [] + for circle_theta in self.polygon_angles: + # Create points to make a circle in Cartesian space + x = rect.centerx + target_radius * np.cos(circle_theta) + y = rect.centery + target_radius * np.sin(circle_theta) + + # Remap to polar equivalents + polar_x, polar_y = self._map_to_polar_space(x, y) + points.append((polar_x, polar_y)) + + pygame.draw.polygon(self.screen, color, points, width=polygon_width) + + if draw_radius: + # NOTE: This option is there, but I'm not sure that it should be used. You don't have runways in real life (or other Fitts tasks), so it might not be fair to add. + # If we are going to add it, we'd need to have them for the angle (not just the radius) + # Also the calculation of the radius should probably be a field because recalculating and rounding every time will cause instability when just changing the angle. + semi_circle_x, semi_circle_y = self._map_to_polar_space(rect.centerx, rect.centery) + semi_circle_radius = int(np.linalg.norm(np.array([semi_circle_x - self.polar_origin[0], semi_circle_y - self.polar_origin[1]]))) + pygame.draw.circle(self.screen, color, self.polar_origin, semi_circle_radius, width=2, draw_top_right=True, draw_top_left=True) + pygame.draw.line(self.screen, color, (self.polar_origin[0] - semi_circle_radius, self.polar_origin[1]), (self.polar_origin[0] + semi_circle_radius, self.polar_origin[1])) + + def _run_loop(self): + # updated frequently for graphics & gameplay + self._update_game() + pygame.display.set_caption(str(self.clock.get_fps())) + + +class ISOFitts(Fitts): + def __init__(self, controller: Controller, config: FittsConfig, prediction_map: dict | None = None, num_targets: int = 8, target_distance_radius: int = 275): + """ISO Fitts style task. Targets are generated in a circle and the user is asked to acquire targets as quickly as possible. + + Parameters + ---------- + controller : Controller + Interface to parse predictions which determine the direction of the cursor. + config : FittsConfig + Configuration class that determines environment parameters (e.g., target size). + prediction_map : dict | None, optional + Maps received control commands to cursor movement - only used if a non-continuous controller is used (e.g., classifier). If a continuous controller is used (e.g., regressor), + then 2 DoFs are expected when parsing predictions and this parameter is not used. If None, a standard map for classifiers is created where 0, 1, 2, 3, 4 are mapped to + down, up, no motion, right, and left, respectively. For custom mappings, pass in a dictionary where keys represent received control signals (from the Controller) and + values map to actions in the environment. Accepted actions are: 'S' (down), 'N' (up), 'NM' (no motion), 'E' (right), and 'W' (left). All of these actions must be + represented by a single key in the dictionary. Defaults to None. + num_targets : int, optional + Number of targets in task. Defaults to 8. + target_distance_radius : int, optional + Radius (in pixels) of target of targets in Iso Fitts' environment. Defaults to 275. + """ + width_is_too_small = target_distance_radius > config.width // 2 + height_is_too_small = target_distance_radius > config.height // 2 + if width_is_too_small and height_is_too_small: + error_info = f"width and height" + elif width_is_too_small: + error_info = f"width" + elif height_is_too_small: + error_info = f"height" + else: + error_info = None + + if error_info is not None: + raise ValueError(f"Radius between ISO Fitts targets is larger than screen size will allow. " + f"Target distance radius must be less than half the screen dimensions. " + f"Please increase screen {error_info} or reduce target distance radius.") + assert target_distance_radius < config.width // 2 and target_distance_radius < config.height // 2, f"Radius between ISO Fitts targets is larger than screen size will allow. Please increase screen size or reduce target distance radius." + self.goal_target_idx = -1 + self.num_of_targets = num_targets + self.big_rad = target_distance_radius + + # interface objects + self.targets = [] + angle = 0 + angle_increment = 360 // self.num_of_targets + while angle < 360: + self.targets.append(pygame.Rect( + (config.width // 2 - config.target_radius) + math.cos(math.radians(angle)) * self.big_rad, + (config.height // 2 - config.target_radius) + math.sin(math.radians(angle)) * self.big_rad, + config.target_radius * 2, config.target_radius * 2 + )) + angle += angle_increment + + super().__init__(controller, config, prediction_map=prediction_map) + + def _draw_targets(self): + for target in self.targets: + self._draw_circle(target, self.config.target_color, fill=False) # draw target outlines + + self._draw_circle(self.goal_target, self.config.target_color, fill=True) # fill in goal target + + def _get_new_goal_target(self): + super()._get_new_goal_target() + if self.goal_target_idx == -1: + self.goal_target_idx = 0 + self.next_target_in = self.num_of_targets//2 + self.target_jump = 0 + else: + self.goal_target_idx = (self.goal_target_idx + self.next_target_in )% self.num_of_targets + if self.target_jump == 0: + self.next_target_in = self.num_of_targets//2 + 1 + self.target_jump = 1 + else: + self.next_target_in = self.num_of_targets // 2 + self.target_jump = 0 + self.goal_target = self.targets[self.goal_target_idx] diff --git a/libemg/event_log.py b/libemg/event_log.py new file mode 100644 index 00000000..fe204855 --- /dev/null +++ b/libemg/event_log.py @@ -0,0 +1,314 @@ +"""Cross-process event log for the reactive pipeline. + +Every propagation decision the reactive layer makes is recorded here: which +shared-memory item changed, which observer was told about it, what criterion +that observer applied, whether it declared the item dirty, and what ran as a +result. When a pipeline does not fire -- or fires more often than expected -- +this log is the record of why, and because the events carry the process that +emitted them it reads correctly when hooks are spread across processes. + +The log is deliberately cheap to leave switched on: an event is a small tuple +pushed onto a ``multiprocessing.Queue`` by the process that observed it, and a +single drain thread in the owning process does the formatting and file I/O. No +worker ever formats a string or touches a file. +""" + +import os +import queue +import threading +import time +from dataclasses import dataclass, field, asdict +from multiprocessing import Queue + + +# Event kinds. Kept as plain strings so a log file stays readable and so a +# consumer can filter without importing this module. +COMMIT = "commit" # a writer changed a stateful item +NOTIFY = "notify" # a subscriber was woken for an item +EVALUATE = "evaluate" # an observer applied its criterion +DIRTY = "dirty" # the criterion said dirty +CLEAN = "clean" # the criterion said clean +INVOKE = "invoke" # a hook's step() was entered +COMPLETE = "complete" # a hook's step() returned +DROP = "drop" # work was skipped, with a reason +ERROR = "error" # a hook raised +LIFECYCLE = "lifecycle" # start/stop of an executor or graph + + +@dataclass +class Event: + """One thing that happened in the reactive pipeline. + + Attributes + ---------- + kind: str + One of the module-level event kinds. + timestamp: float + ``time.time()`` when the event was observed, so events from different + processes share a clock. + monotonic: float + ``time.perf_counter()`` in the observing process. Use this for + durations within a process; it is not comparable across processes. + origin: str + What changed, or what is acting. For a commit this is the shared-memory + tag that was written. + observer: str + The hook or executor the event concerns. Empty for a bare commit. + criterion: str + A description of the criterion applied, e.g. ``"OnSamples(40)"``. + detail: dict + Kind-specific numbers: generations, sample counts, durations. + pid: int + The process that observed the event. + """ + + kind: str + origin: str = "" + observer: str = "" + criterion: str = "" + detail: dict = field(default_factory=dict) + timestamp: float = field(default_factory=time.time) + monotonic: float = field(default_factory=time.perf_counter) + pid: int = field(default_factory=os.getpid) + + def format(self): + """Render as one tab-separated line, stable enough to grep and parse.""" + parts = [ + f"{self.timestamp:.6f}", + f"pid={self.pid}", + f"{self.kind:<9}", + f"origin={self.origin or '-'}", + f"observer={self.observer or '-'}", + f"criterion={self.criterion or '-'}", + ] + if self.detail: + rendered = " ".join(f"{k}={self._render(v)}" for k, v in sorted(self.detail.items())) + parts.append(rendered) + return "\t".join(parts) + + @staticmethod + def _render(value): + if isinstance(value, float): + return f"{value:.6g}" + return str(value) + + +class _NullLog: + """The no-logging case, shaped like an EventLog so callers never branch. + + The reactive layer calls into the log on every commit and every criterion + evaluation, so the disabled path has to cost as close to nothing as + possible. These methods are empty and ``enabled`` is False so hot paths can + skip building a detail dict at all. + """ + + enabled = False + + def emit(self, *args, **kwargs): + pass + + def record(self, event): + pass + + def start(self): + pass + + def stop(self): + pass + + def close(self): + pass + + def __getstate__(self): + return {} + + def __setstate__(self, state): + pass + + +NULL_LOG = _NullLog() + + +class EventLog: + """Collects reactive events from every process and writes them in order. + + Create one in the process that owns the pipeline and hand it to the graph. + It survives being pickled into a child process: the child keeps the queue + and drops the drain thread, so a child emits but never writes. + + Parameters + ---------- + path: str or None (optional), default=None + File to append events to. If None, events are kept in memory only and + can be read with :meth:`events`. + to_stdout: bool (optional), default=False + Also print each event as it is drained. + keep: int (optional), default=10000 + How many recent events to retain in memory for :meth:`events`. Set to + 0 to keep none, which is what you want for a long recording that is + only being written to a file. + kinds: sequence of str or None (optional), default=None + Only record these event kinds. None records everything. Filtering here + happens in the emitting process, so excluded events cost nothing beyond + the check. + + Examples + --------- + >>> log = EventLog(path='reactive.log') + >>> graph = ReactiveGraph(shared_memory_items, log=log) + >>> # ... run the pipeline ... + >>> log.stop() + >>> for event in log.events()[:5]: + ... print(event.format()) + """ + + enabled = True + + def __init__(self, path=None, to_stdout=False, keep=10000, kinds=None): + self.path = path + self.to_stdout = to_stdout + self.keep = keep + self.kinds = set(kinds) if kinds is not None else None + self._queue = Queue() + self._records = [] + self._thread = None + self._stop = threading.Event() + self._handle = None + self._owner_pid = os.getpid() + + # ------------------------------------------------------------------ + # emitting (runs in any process) + # ------------------------------------------------------------------ + def emit(self, kind, origin="", observer="", criterion="", **detail): + """Record an event. Safe to call from any process.""" + if self.kinds is not None and kind not in self.kinds: + return + self.record(Event(kind=kind, origin=origin, observer=observer, + criterion=criterion, detail=detail)) + + def record(self, event): + """Queue an already-built event.""" + if self.kinds is not None and event.kind not in self.kinds: + return + try: + self._queue.put_nowait(event) + except Exception: + # A full or closed queue must never take the pipeline down with + # it; losing a debug event is always better than losing a sample. + pass + + # ------------------------------------------------------------------ + # draining (runs only in the owning process) + # ------------------------------------------------------------------ + def start(self): + """Begin draining events. Called for you by ReactiveGraph.start().""" + if self._thread is not None or os.getpid() != self._owner_pid: + return + self._stop.clear() + if self.path is not None: + self._handle = open(self.path, "a", buffering=1) + self._handle.write(f"# libemg reactive event log, opened {time.time():.6f}\n") + self._thread = threading.Thread(target=self._drain, daemon=True, + name="libemg-eventlog") + self._thread.start() + + def _drain(self): + while not self._stop.is_set(): + self._drain_available(block=True) + # A stop request has to be followed by a final sweep, or the events + # that describe the shutdown are the ones you lose. + self._drain_available(block=False) + + def _drain_available(self, block): + try: + event = self._queue.get(timeout=0.1) if block else self._queue.get_nowait() + except (queue.Empty, OSError, ValueError): + return + while True: + self._write(event) + try: + event = self._queue.get_nowait() + except (queue.Empty, OSError, ValueError): + return + + def _write(self, event): + if self.keep: + self._records.append(event) + if len(self._records) > self.keep: + del self._records[:-self.keep] + line = event.format() + if self._handle is not None: + self._handle.write(line + "\n") + if self.to_stdout: + print(line) + + def stop(self): + """Stop draining and flush what is still queued.""" + if self._thread is None: + return + self._stop.set() + self._thread.join(timeout=3) + self._thread = None + if self._handle is not None: + self._handle.flush() + self._handle.close() + self._handle = None + + close = stop + + # ------------------------------------------------------------------ + # reading back + # ------------------------------------------------------------------ + def events(self, kind=None, origin=None, observer=None): + """Return the retained events, optionally filtered. + + Returns + ---------- + list + Matching :class:`Event` objects, oldest first. + """ + out = self._records + if kind is not None: + out = [e for e in out if e.kind == kind] + if origin is not None: + out = [e for e in out if e.origin == origin] + if observer is not None: + out = [e for e in out if e.observer == observer] + return list(out) + + def summary(self): + """Counts per event kind, and per hook for invocations. + + Returns + ---------- + dict + ``kinds`` maps event kind to count. ``invocations`` maps hook name + to how many times it ran. ``dirty`` and ``clean`` map hook name to + how often its criterion fired or held, which is the pair that tells + you whether a criterion is set sensibly. + """ + kinds, invocations, dirty, clean = {}, {}, {}, {} + for event in self._records: + kinds[event.kind] = kinds.get(event.kind, 0) + 1 + if event.kind == INVOKE: + invocations[event.observer] = invocations.get(event.observer, 0) + 1 + elif event.kind == DIRTY: + dirty[event.observer] = dirty.get(event.observer, 0) + 1 + elif event.kind == CLEAN: + clean[event.observer] = clean.get(event.observer, 0) + 1 + return {"kinds": kinds, "invocations": invocations, + "dirty": dirty, "clean": clean} + + def __getstate__(self): + # The drain thread and the open file belong to the owning process. A + # child keeps only what it needs to emit. + state = self.__dict__.copy() + state["_thread"] = None + state["_handle"] = None + state["_records"] = [] + state["_stop"] = None + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self._stop = threading.Event() diff --git a/libemg/feature_extractor.py b/libemg/feature_extractor.py index 1e513d8e..271e2d94 100644 --- a/libemg/feature_extractor.py +++ b/libemg/feature_extractor.py @@ -1,6 +1,7 @@ +import contextlib +import functools import math import numpy as np -import numpy.matlib as matlib import matplotlib.pyplot as plt from sklearn.decomposition import PCA, KernelPCA, FastICA from sklearn.manifold import TSNE, Isomap @@ -8,6 +9,93 @@ from scipy.stats import skew, kurtosis from librosa import lpc from pywt import wavedec, upcoef +from sklearn.preprocessing import StandardScaler + + +_MISSING = object() + + +def _next_power_of_two(window_size): + """Smallest power of two at least as large as ``window_size``. + + Every frequency-domain feature needs this to pick its FFT length. It used + to be re-declared as an inner closure inside each of them. + """ + return 1 if window_size == 0 else 2 ** math.ceil(math.log2(window_size)) + + +class _PrerequisiteCache: + """Memoizes prerequisite results for the span of a single extraction. + + Several features are built from the same expensive intermediate: the five + frequency-domain features all want one FFT of the same windows, the four + wavelet features all want one ``sym8`` decomposition, and the six temporal + moment features all want the same ``m0``/``m2``/``m4`` and the same + log-energy array. Without somewhere to put those, each feature recomputes + them from scratch. + + Entries are keyed partly on ``id()`` of the array a prerequisite was called + with, so the cache also holds a reference to that array. That reference is + what makes the key sound: while the entry is alive the array cannot be + collected, so its id cannot be handed to a different object. + """ + + def __init__(self): + self.store = {} + self._pinned = [] + + def lookup(self, key): + return self.store.get(key, _MISSING) + + def insert(self, key, array, value): + self.store[key] = value + self._pinned.append(array) + + +def prerequisite(fn): + """Declare a method as a shared prerequisite of one or more features. + + A prerequisite is a computation that features depend on rather than a + feature itself -- an FFT, a wavelet decomposition, a set of temporal + moments. Decorating it means that within one :meth:`extract_features` call + (or one :meth:`shared_prerequisites` block) the work happens once, no + matter how many features ask for it, and the later askers get the stored + result. + + The decorated method takes the array it operates on as its first argument + after ``self``. Its remaining arguments participate in the cache key, so + the same prerequisite computed at two different FFT lengths or two + different wavelet orders is stored separately. + + Outside an extraction there is nothing to share with, so the call falls + straight through and nothing is retained. That keeps a lone + ``getMDFfeat(windows)`` behaving exactly as it did, and it is why the cache + can be keyed on array identity at all: it never outlives the call that + owns the array. + + A prerequisite must be a pure function of its arguments, and callers must + not mutate what it returns -- the value handed back is the stored one, not + a copy. + """ + name = fn.__name__ + + @functools.wraps(fn) + def wrapper(self, array, *args, **kwargs): + cache = getattr(self, "_prerequisite_cache", None) + if cache is None: + return fn(self, array, *args, **kwargs) + key = (name, id(array), array.shape, array.dtype.str, + args, tuple(sorted(kwargs.items()))) + cached = cache.lookup(key) + if cached is not _MISSING: + return cached + value = fn(self, array, *args, **kwargs) + cache.insert(key, array, value) + return value + + wrapper.is_prerequisite = True + return wrapper + class FeatureExtractor: """ @@ -133,7 +221,7 @@ def extract_feature_group(self, feature_group, windows, feature_dic={}, array=Fa return self._format_data(feats) return feats - def extract_features(self, feature_list, windows, feature_dic={}, array=False): + def extract_features(self, feature_list, windows, feature_dic={}, array=False, normalize=False, normalizer=None, fix_feature_errors=False): """Extracts a list of features. Parameters @@ -147,25 +235,236 @@ def extract_features(self, feature_list, windows, feature_dic={}, array=False): A dictionary containing the parameters you'd like passed to each feature. ex. {"MDF_sf":1000} array: bool (optional), default=False If True, the dictionary will get converted to a list. + normalize: bool (optional), default=False + If True, the features will be normalized between using sklearn StandardScaler. The returned object will be a list. + normalizer: StandardScaler, default=None + This should be set to the output from feature extraction on the training data. Do not normalize testing features without this as this could be considered information leakage. + fix_feature_errors: bool (optional), default=False + If true, fixes all feature errors (NaN=0, INF=0, -INF=0). Returns ---------- dictionary or list A dictionary where each key is a specific feature and its value is a list of the computed features for each window. + StandardScaler + If normalize is true it will return the normalizer object. This should be passed into the feature extractor for test data. """ features = {} - for feature in feature_list: - if feature in self.get_feature_list(): + scaler = None + available = set(self.get_feature_list()) + unknown = [f for f in feature_list if f not in available] + if unknown: + # Silently skipping an unrecognised name used to return a narrower + # feature matrix than the caller asked for. Because a model is fit + # at a particular width, a typo here surfaced much later as a + # confusing dimension mismatch, so refuse it at the source. + raise ValueError( + f"Unknown feature(s) requested: {unknown}. " + "Run FeatureExtractor().get_feature_list() for the available features." + ) + # Features are extracted under one prerequisite cache, so shared + # intermediates (FFTs, wavelet decompositions, temporal moments) are + # computed once for this set of windows rather than once per feature. + with self.shared_prerequisites(): + for feature in feature_list: method_to_call = getattr(self, 'get' + feature + 'feat') valid_keys = [i for i in list(feature_dic.keys()) if feature+"_" in i] smaller_dictionary = dict((k, feature_dic[k]) for k in valid_keys if k in feature_dic) - features[feature] = method_to_call(windows, **smaller_dictionary) + feats = method_to_call(windows, **smaller_dictionary) + if fix_feature_errors: + if self.check_features(feats, False): + feats = np.nan_to_num(feats, neginf=0, nan=0, posinf=0) + features[feature] = feats if array: - return self._format_data(features) - return features + features = self._format_data(features) + if normalize: + if isinstance(features, dict): + features = self._format_data(features) + if not normalizer: + scaler = StandardScaler() + features = scaler.fit_transform(features) + else: + features = normalizer.transform(features) + return features, scaler + return features + + @contextlib.contextmanager + def shared_prerequisites(self): + """Share prerequisite computations across everything called inside the block. + + :meth:`extract_features` already does this for you. Use it directly + when calling feature methods one at a time and you want them to share + their intermediates anyway:: + + with fe.shared_prerequisites(): + m0 = fe.getM0feat(windows) # computes the temporal moments + m2 = fe.getM2feat(windows) # reuses them + + Outside such a block each call recomputes its own intermediates, which + is the safe default: results are cached against the identity of the + array passed in, so the cache must not outlive the caller's ownership + of that array. Nothing is retained once the block exits. + + Nesting is allowed; the innermost block shares the outermost cache. The + cache lives on the instance, so a single FeatureExtractor must not be + driven from several threads at once -- give each thread its own. + + See :func:`prerequisite` for how a computation is declared shareable, + and :meth:`get_prerequisite_list` for the ones that are. + """ + previous = getattr(self, "_prerequisite_cache", None) + if previous is None: + self._prerequisite_cache = _PrerequisiteCache() + try: + yield self._prerequisite_cache + finally: + self._prerequisite_cache = previous + + def get_prerequisite_list(self): + """Names of the shared prerequisite computations features are built on. + + Returns + ---------- + list + The methods decorated with :func:`prerequisite`. These are not + features; they are the intermediates that features share when + extracted together. + """ + names = [] + for name in dir(type(self)): + attribute = getattr(type(self), name, None) + if getattr(attribute, "is_prerequisite", False): + names.append(name) + return sorted(names) + + # ------------------------------------------------------------------ + # Shared prerequisites + # + # Each of these is depended on by more than one feature. They are declared + # with @prerequisite so that when those features are extracted together the + # computation happens once. See the decorator's docstring for the contract. + # ------------------------------------------------------------------ + + @prerequisite + def _fft_spectrum(self, windows, nextpow2): + """One-sided normalized spectrum. Shared by MDF, MNF, MNP, SM and DFTR. + + ``rfft`` is used rather than ``fft`` because the input is real, so the + negative-frequency half that ``fft`` computes was being discarded by + every caller anyway. ``rfft`` returns bins 0..n/2 inclusive; slicing to + ``n//2`` reproduces exactly the bins the callers kept. + """ + spec = np.fft.rfft(windows, n=nextpow2, axis=2) / windows.shape[2] + return spec[:, :, :nextpow2 // 2] + + @prerequisite + def _fft_power(self, windows, nextpow2): + """Power spectrum. Shared by MDF, MNF, MNP and SM.""" + spec = self._fft_spectrum(windows, nextpow2) + return np.real(spec * np.conj(spec)) + + @prerequisite + def _fft_magnitude(self, windows, nextpow2): + """Magnitude spectrum. Used by DFTR.""" + return np.abs(self._fft_spectrum(windows, nextpow2)) + + def _fft_frequencies(self, nextpow2, sampling_frequency): + """Frequency of each retained bin, shaped to broadcast over windows. + + Returned with two leading singleton axes so it broadcasts against a + (windows, channels, bins) power spectrum. The callers used to expand it + to the full spectrum shape with two np.repeat calls, which allocated an + array as large as the spectrum itself to hold one distinct value per + bin. + """ + frequencies = np.fft.rfftfreq(nextpow2) * sampling_frequency + return frequencies[:nextpow2 // 2][np.newaxis, np.newaxis, :] + + @prerequisite + def _wavelet_decompose(self, windows, wavelet, level): + """``wavedec`` coefficients. Shared by WENG, WV, WWL and WENT. + + The order Khushaba et al. prescribe is higher than the window length + strictly supports, so pywt warns about boundary effects. That warning is + expected here and is suppressed, as the original WENG implementation + already did -- suppressing it in one place keeps it from depending on + which of the four features happens to run first. + """ + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return wavedec(windows, wavelet=wavelet, level=level, axis=2) + + @prerequisite + def _wavelet_energy(self, windows, wavelet, level): + """Squared ``wavedec`` coefficients. Shared by WENG, WV, WWL and WENT.""" + return [coefficients ** 2 + for coefficients in self._wavelet_decompose(windows, wavelet, level)] + + @prerequisite + def _tdpsd_log_energy(self, windows): + """The log-energy branch input, shared by all six temporal moment features. + + Each of M0, M2, M4, SPARSI, IRF and WLF is the combination of a + statistic taken over the windows themselves and the same statistic + taken over this array, so all six used to build it independently. + """ + return np.log(windows ** 2 + np.spacing(1)) + + @prerequisite + def _tdpsd_d1(self, windows): + """First difference along the sample axis. Shared by the moment features.""" + return np.diff(windows, n=1, axis=2) + + @prerequisite + def _tdpsd_d2(self, windows): + """Second difference along the sample axis. Shared by the moment features.""" + return np.diff(self._tdpsd_d1(windows), n=1, axis=2) + + @prerequisite + def _tdpsd_m0(self, windows): + """Zeroth temporal moment, normalized as the TDPSD set defines it. + + Note that the sample count divides outside the square root here and + inside it for m2 and m4. That asymmetry is what the original + implementation does and it is preserved deliberately -- changing it + would change every feature in the set. + """ + m0 = np.sqrt(np.sum(windows ** 2, axis=2)) / (windows.shape[2] - 1) + return m0 ** 0.1 / 0.1 + + @prerequisite + def _tdpsd_m2(self, windows): + """Second temporal moment. Shared by M2, SPARSI and IRF.""" + d1 = self._tdpsd_d1(windows) + m2 = np.sqrt(np.sum(d1 ** 2, axis=2) / (windows.shape[2] - 1)) + return m2 ** 0.1 / 0.1 + + @prerequisite + def _tdpsd_m4(self, windows): + """Fourth temporal moment. Shared by M4, SPARSI and IRF.""" + d2 = self._tdpsd_d2(windows) + m4 = np.sqrt(np.sum(d2 ** 2, axis=2) / (windows.shape[2] - 1)) + return m4 ** 0.1 / 0.1 + + @prerequisite + def _tdpsd_abs_differences(self, windows): + """Summed absolute first and second differences. Used by WLF.""" + d1 = self._tdpsd_d1(windows) + d2 = self._tdpsd_d2(windows) + return np.sum(np.abs(d1), axis=2), np.sum(np.abs(d2), axis=2) + + def _tdpsd_combine(self, ebp, efp): + """Combine a statistic's two branches the way the TDPSD set defines it. + + Identical arithmetic in all six features, so it lives in one place. + """ + num = -2 * np.multiply(efp, ebp) + den = np.multiply(efp, efp) + np.multiply(ebp, ebp) + return num / den def check_features(self, features, silent=False): - """Assesses a features object for np.nan, np.inf, and -np.inf. Can be used to check for clean data. + """Assesses a features object for np.nan, np.inf, and -np.inf. Can be used to check for clean data. Parameters ---------- @@ -204,15 +503,20 @@ def _check_dict_features(self, features, silent=False): # sanity check that no errors were found in feature computation violations = 0 for fk in feature_list: - if (features[fk] == np.nan).any(): + # NaN has to be found with np.isnan: it compares unequal to + # everything including itself, so the equality test that used to be + # here could never fire and a NaN-bearing feature was reported + # clean. That in turn meant extract_features(fix_feature_errors=True) + # skipped np.nan_to_num and handed the NaN back. + if np.isnan(features[fk]).any(): violations += 1 if not silent: print(f"nan in feature {fk}.") - if (features[fk] == np.inf).any(): + if np.isposinf(features[fk]).any(): violations += 1 if not silent: print(f"inf in feature {fk}.") - if (features[fk] == -1*np.inf).any(): + if np.isneginf(features[fk]).any(): violations += 1 if not silent: print(f"-inf in feature {fk}.") @@ -233,15 +537,16 @@ def _check_ndarray_features(self, features, silent=False): does not indicate the feature the violation arose from. """ violations = 0 - if (features == np.nan).any(): + # See _check_dict_features: NaN needs np.isnan, not an equality test. + if np.isnan(features).any(): violations += 1 if not silent: print(f"nan in features.") - if (features == np.inf).any(): + if np.isposinf(features).any(): violations += 1 if not silent: print(f"inf in features.") - if (features == -1*np.inf).any(): + if np.isneginf(features).any(): violations += 1 if not silent: print(f"-inf in features.") @@ -407,7 +712,7 @@ def __LegendreShiftPoly(self, n): pk = np.zeros((n+1,1)) for e in range(n-k+1,n+1,1): pk[e-1] = (4*k-2)*pkm1[e]+ (1-2*k)*pkm1[e-1] + (1-k) * pkm2[e-1] - pk[n,0] = (1-2*k)*pkm1[n] + (1-k)*pkm2[n] + pk[n,0] = (1-2*k)*pkm1[n].item() + (1-k)*pkm2[n].item() pk = pk/k if k < n: @@ -545,17 +850,9 @@ def getM0feat(self, windows): """ def closure(w): - m0 = np.sqrt(np.sum(w**2,axis=2))/(w.shape[2]-1) - m0 = m0 ** 0.1 / 0.1 - return np.log(np.abs(m0)) - m0_ebp=closure(windows) - m0_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(m0_efp,m0_ebp) - den=np.multiply(m0_efp, m0_efp) + np.multiply(m0_ebp, m0_ebp) - - #Feature extraction goes here - return num/den + return np.log(np.abs(self._tdpsd_m0(w))) + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getM2feat(self, windows): """Extract Second Temporal Moment (M2) feature. @@ -571,19 +868,9 @@ def getM2feat(self, windows): The computed features associated with each window. """ def closure(w): - m0 = np.sqrt(np.sum(w**2,axis=2))/(w.shape[2]-1) - m0 = m0 ** 0.1 / 0.1 - d1 = np.diff(w, n=1, axis=2) - m2 = np.sqrt(np.sum(d1 **2, axis=2)/ (w.shape[2]-1)) - m2 = m2 ** 0.1 / 0.1 - return np.log(np.abs(m0-m2)) - m2_ebp=closure(windows) - m2_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(m2_efp,m2_ebp) - den=np.multiply(m2_efp, m2_efp) + np.multiply(m2_ebp, m2_ebp) - - return num/den + return np.log(np.abs(self._tdpsd_m0(w) - self._tdpsd_m2(w))) + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getM4feat(self, windows): """Extract Fourth Temporal Moment (M4) feature. @@ -599,20 +886,9 @@ def getM4feat(self, windows): The computed features associated with each window. """ def closure(w): - m0 = np.sqrt(np.sum(w**2,axis=2))/(w.shape[2]-1) - m0 = m0 ** 0.1 / 0.1 - d1 = np.diff(w, n=1, axis=2) - d2 = np.diff(d1, n=1, axis=2) - m4 = np.sqrt(np.sum(d2 **2, axis=2)/ (w.shape[2]-1)) - m4 = m4 ** 0.1 / 0.1 - return np.log(np.abs(m0-m4)) - m4_ebp=closure(windows) - m4_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(m4_efp,m4_ebp) - den=np.multiply(m4_efp, m4_efp) + np.multiply(m4_ebp, m4_ebp) - - return num/den + return np.log(np.abs(self._tdpsd_m0(w) - self._tdpsd_m4(w))) + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getSPARSIfeat(self, windows): """Extract Sparsness (SPARSI) feature. @@ -628,23 +904,13 @@ def getSPARSIfeat(self, windows): The computed features associated with each window. """ def closure(w): - m0 = np.sqrt(np.sum(w**2,axis=2))/(w.shape[2]-1) - m0 = m0 ** 0.1 / 0.1 - d1 = np.diff(w, n=1, axis=2) - m2 = np.sqrt(np.sum(d1 **2, axis=2)/ (w.shape[2]-1)) - m2 = m2 ** 0.1 / 0.1 - d2 = np.diff(d1, n=1, axis=2) - m4 = np.sqrt(np.sum(d2 **2, axis=2)/ (w.shape[2]-1)) - m4 = m4 ** 0.1 / 0.1 + m0 = self._tdpsd_m0(w) + m2 = self._tdpsd_m2(w) + m4 = self._tdpsd_m4(w) sparsi = np.sqrt(np.abs((m0-m2)*(m0-m4)))/m0 return np.log(np.abs(sparsi)) - sparsi_ebp=closure(windows) - sparsi_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(sparsi_efp,sparsi_ebp) - den=np.multiply(sparsi_efp, sparsi_efp) + np.multiply(sparsi_ebp, sparsi_ebp) - - return num/den + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getIRFfeat(self, windows): """Extract Irregularity Factor (IRF) feature. @@ -660,23 +926,10 @@ def getIRFfeat(self, windows): The computed features associated with each window. """ def closure(w): - m0 = np.sqrt(np.sum(w**2,axis=2))/(w.shape[2]-1) - m0 = m0 ** 0.1 / 0.1 - d1 = np.diff(w, n=1, axis=2) - m2 = np.sqrt(np.sum(d1 **2, axis=2)/ (w.shape[2]-1)) - m2 = m2 ** 0.1 / 0.1 - d2 = np.diff(d1, n=1, axis=2) - m4 = np.sqrt(np.sum(d2 **2, axis=2)/ (w.shape[2]-1)) - m4 = m4 ** 0.1 / 0.1 - irf = m2/np.sqrt(m0*m4) + irf = self._tdpsd_m2(w)/np.sqrt(self._tdpsd_m0(w)*self._tdpsd_m4(w)) return np.log(np.abs(irf)) - irf_ebp=closure(windows) - irf_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(irf_efp,irf_ebp) - den=np.multiply(irf_efp, irf_efp) + np.multiply(irf_ebp, irf_ebp) - - return num/den + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getWLFfeat(self, windows): """Waveform Length Factor (WLF) feature. @@ -692,19 +945,11 @@ def getWLFfeat(self, windows): The computed features associated with each window. """ def closure(w): - d1 = np.diff(w, n=1, axis=2) - - d2 = np.diff(d1, n=1, axis=2) - - wlf = np.sqrt(np.sum(np.abs(d1),axis=2)/np.sum(np.abs(d2),axis=2)) + absd1, absd2 = self._tdpsd_abs_differences(w) + wlf = np.sqrt(absd1/absd2) return np.log(np.abs(wlf)) - wlf_ebp=closure(windows) - wlf_efp=closure(np.log(windows**2+np.spacing(1))) - - num=-2*np.multiply(wlf_efp,wlf_ebp) - den=np.multiply(wlf_efp, wlf_efp) + np.multiply(wlf_ebp, wlf_ebp) - - return num/den + return self._tdpsd_combine(closure(windows), + closure(self._tdpsd_log_energy(windows))) def getARfeat(self, windows, AR_order=4): """Extract Autoregressive Coefficients (AR) feature. @@ -822,20 +1067,22 @@ def getMDFfeat(self, windows,MDF_fs=1000): list The computed features associated with each window. """ - assert type(MDF_fs) == int or type(MDF_fs) == float - def closure(winsize): - return 1 if winsize==0 else 2**math.ceil(math.log2(winsize)) - nextpow2 = closure(windows.shape[2]) - spec = np.fft.fft(windows,nextpow2, axis=2)/windows.shape[2] - spec = spec[:,:,0:int(nextpow2/2)] - POW = np.real(spec * np.conj(spec)) + assert type(MDF_fs) == int or type(MDF_fs) == float + nextpow2 = _next_power_of_two(windows.shape[2]) + POW = self._fft_power(windows, nextpow2) totalPOW = np.sum(POW, axis=2) cumPOW = np.cumsum(POW, axis=2) - medfreq = np.zeros((windows.shape[0], windows.shape[1])) - for i in range(0, windows.shape[0]): - for j in range(0, windows.shape[1]): - medfreq[i,j] = (MDF_fs/2)*np.argwhere(cumPOW[i,j,:] > totalPOW[i,j] /2)[0]/(nextpow2/2) - return medfreq + # The first bin whose cumulative power passes half the total. argmax on + # a boolean array returns the first True, which is what the per-element + # np.argwhere in the old nested loop was reading -- but argwhere built a + # full index array of every match just to take element zero, once per + # window per channel. + # Behaviour note: for a window with no power at all the condition is + # never satisfied; argmax then yields bin 0 (a median frequency of 0) + # where the old code raised IndexError. Cumulative power reaches the + # total by construction, so only an all-zero window can reach this. + first_bin = np.argmax(cumPOW > totalPOW[:, :, np.newaxis] / 2, axis=2) + return (MDF_fs/2) * first_bin / (nextpow2/2) def getMNFfeat(self, windows, MNF_fs=1000): """Extract Mean Frequency (MNF) feature. @@ -851,18 +1098,11 @@ def getMNFfeat(self, windows, MNF_fs=1000): list The computed features associated with each window. """ - assert type(MNF_fs) == int or type(MNF_fs) == float - def closure(winsize): - return 1 if winsize==0 else 2**math.ceil(math.log2(winsize)) - nextpow2 = closure(windows.shape[2]) - spec = np.fft.fft(windows, n=nextpow2,axis=2)/windows.shape[2] - f = np.fft.fftfreq(nextpow2)*MNF_fs - spec = spec[:,:,0:int(round(spec.shape[2]/2))] - f = f[0:int(round(nextpow2/2))] - f = np.repeat(f[np.newaxis, :], spec.shape[0], axis=0) - f = np.repeat(f[:, np.newaxis,:], spec.shape[1], axis=1) - POW = spec * np.conj(spec) - return np.real(np.sum(POW*f,axis=2)/np.sum(POW,axis=2)) + assert type(MNF_fs) == int or type(MNF_fs) == float + nextpow2 = _next_power_of_two(windows.shape[2]) + POW = self._fft_power(windows, nextpow2) + f = self._fft_frequencies(nextpow2, MNF_fs) + return np.sum(POW*f, axis=2)/np.sum(POW, axis=2) def getMNPfeat(self, windows): """Extract Mean Power (MNP) feature. @@ -877,12 +1117,8 @@ def getMNPfeat(self, windows): list The computed features associated with each window. """ - def closure(winsize): - return 1 if winsize==0 else 2**math.ceil(math.log2(winsize)) - nextpow2 = closure(windows.shape[2]) - spec = np.fft.fft(windows,n=nextpow2,axis=2)/windows.shape[2] - spec = spec[:,:,0:int(round(nextpow2/2))] - POW = np.real(spec[:,:,:int(nextpow2)]*np.conj(spec[:,:,:int(nextpow2)])) + nextpow2 = _next_power_of_two(windows.shape[2]) + POW = self._fft_power(windows, nextpow2) return np.sum(POW, axis=2)/POW.shape[2] def getMPKfeat(self, windows): @@ -1077,16 +1313,10 @@ def getSMfeat(self, windows, SM_order=2, SM_fs=1000): """ assert type(SM_order)==int assert type(SM_fs)==int or type(SM_fs) == float - def closure(winsize): - return 1 if winsize==0 else 2**math.ceil(math.log2(winsize)) - nextpow2 = closure(windows.shape[2]) - spec = np.fft.fft(windows,n=nextpow2,axis=2)/windows.shape[2] - pow = np.real(spec[:,:,0:int(round(nextpow2/2))] * np.conj(spec[:,:,0:int(round(nextpow2/2))])) - f = np.fft.fftfreq(nextpow2)*SM_fs - f = f[0:int(round(nextpow2/2))] - f = np.repeat(f[np.newaxis, :], spec.shape[0], axis=0) - f = np.repeat(f[:, np.newaxis,:], spec.shape[1], axis=1) - return np.sum( pow*(f**SM_order),axis=2) + nextpow2 = _next_power_of_two(windows.shape[2]) + pow = self._fft_power(windows, nextpow2) + f = self._fft_frequencies(nextpow2, SM_fs) + return np.sum(pow*(f**SM_order), axis=2) def getSAMPENfeat(self, windows, SAMPEN_dim=2, SAMPEN_tolerance=0.3): """Extract Sample Entropy (SAMPEN) feature. SAMPEN_dim should be specified and is the number of samaples that @@ -1139,9 +1369,9 @@ def getSAMPENfeat(self, windows, SAMPEN_dim=2, SAMPEN_tolerance=0.3): for k in range(N-m): # compute the distance between each pattern and other patterns if m == 1: - tmp = np.abs(patterns - matlib.repmat(patterns[:,k],1,N-m+1)) + tmp = np.abs(patterns - np.tile(patterns[:,k], (1,N-m+1))) else: - tmp = np.max(np.abs(patterns - matlib.repmat(patterns[:,k,np.newaxis],1,N-m+1)),axis=0) + tmp = np.max(np.abs(patterns - np.tile(patterns[:,k,np.newaxis], (1,N-m+1))),axis=0) mask = (tmp <= SAMPEN_tolerance) count[k] = (np.sum(mask)-1) # we remove 1 to avoid self comparison, in theory this means we can eventually do log of 0 (error) # that is why we need the eps / np.spacing(1) @@ -1209,9 +1439,9 @@ def getFUZZYENfeat(self, windows, FUZZYEN_dim=2, FUZZYEN_tolerance=0.3, FUZZYEN_ for k in range(N-m): # compute the distance between each pattern and other patterns if m == 1: - tmp = np.abs(dataMat - matlib.repmat(dataMat[:,k],1,N-m+1)) + tmp = np.abs(dataMat - np.tile(dataMat[:,k], (1,N-m+1))) else: - tmp = np.max(np.abs(dataMat - matlib.repmat(dataMat[:,k,np.newaxis],1,N-m+1)),axis=0) + tmp = np.max(np.abs(dataMat - np.tile(dataMat[:,k,np.newaxis], (1,N-m+1))),axis=0) # now get the similarity simi = np.exp(((-1)*((tmp)**FUZZYEN_win))/FUZZYEN_tolerance[w,ch]) phi[k]=(np.sum(simi)-1) / (windows.shape[2]-m-1) @@ -1240,19 +1470,15 @@ def getDFTRfeat(self, windows, DFTR_fs=1000): The computed features associated with each window. """ assert type(DFTR_fs)==int or type(DFTR_fs) == float - def closure(winsize): - return 1 if winsize==0 else 2**math.ceil(math.log2(winsize)) init_freq = 20 upper_freqs = [92, 163, 235, 305, 378, 450] nyquist = DFTR_fs/2 num_bins = sum([i init_freq) , (f < upper_freq)) @@ -1360,9 +1586,20 @@ def getMOBfeat(self, windows): The computed features associated with each window. """ m0 = self.getACTfeat(windows) - m2 = np.sum(np.diff(windows,axis=2)**2,axis=2)/windows.shape[2] + m2 = self._hjorth_m2(windows) return np.sqrt(m2/m0) + @prerequisite + def _hjorth_m2(self, windows): + """Second Hjorth moment. Shared by MOB and COMP.""" + return np.sum(np.diff(windows, axis=2) ** 2, axis=2) / windows.shape[2] + + @prerequisite + def _hjorth_m4(self, windows): + """Fourth Hjorth moment. Used by COMP.""" + d2 = np.diff(np.diff(windows, axis=2), axis=2) + return np.sum(d2 ** 2, axis=2) / windows.shape[2] + def getCOMPfeat(self, windows): """Extract Complexity (COMP) feature. This feature is sqrt(m4/m2), where m2 and m4 are the second and fourth order moments found via Parseval's theorem. It is a measure of the the similarity of the shape of a signal compared to a pure sine waveform. Because the Gabor frequency @@ -1378,8 +1615,13 @@ def getCOMPfeat(self, windows): list The computed features associated with each window. """ - m2 = np.sum(np.diff(windows,axis=2)**2,axis=2)/windows.shape[2] - m4 = np.sum(np.diff(np.diff(windows, axis=2),axis=2)**2)/windows.shape[2] + # m4 previously omitted axis=2, so np.sum reduced the whole batch to a + # single scalar and every window/channel was divided by the same global + # numerator. One window's data therefore changed another window's + # feature, and the value depended on what else happened to be in the + # batch. Both moments now reduce along the sample axis only. + m2 = self._hjorth_m2(windows) + m4 = self._hjorth_m4(windows) return np.sqrt(m4/m2) def getWENGfeat(self, windows, WENG_fs = 1000): @@ -1399,12 +1641,11 @@ def getWENGfeat(self, windows, WENG_fs = 1000): """ # get the highest power of 2 the nyquist rate is divisible by order = math.floor(np.log(WENG_fs/2)/np.log(2) - 1) - # Khushaba et al suggests using sym8 - # note, this will often throw a WARNING saying the user specified order is too high -- but this is what the - # original paper suggests using as the order. - wavelets = wavedec(windows, wavelet='sym8', level=order,axis=2) + # Khushaba et al suggests using sym8. The decomposition is shared with + # WV, WWL and WENT, which all ask for the same one. + window_energy = self._wavelet_energy(windows, 'sym8', order) # for every order, compute the energy (sum of DWT) - total of the squared signal - features = np.hstack([np.log(np.sum(i**2, axis=2)+1e-10) for i in wavelets]) + features = np.hstack([np.log(np.sum(i, axis=2)+1e-10) for i in window_energy]) return features @@ -1424,12 +1665,10 @@ def getWVfeat(self, windows, WV_fs=1000): """ # get the highest power of 2 the nyquist rate is divisible by order = math.floor(np.log(WV_fs/2)/np.log(2) - 1) - # Khushaba et al suggests using sym8 - # note, this will often throw a WARNING saying the user specified order is too high -- but this is what the - # original paper suggests using as the order. - wavelets = wavedec(windows, wavelet='sym8', level=order,axis=2) + # Khushaba et al suggests using sym8. Shared with WENG, WWL and WENT. + window_energy = self._wavelet_energy(windows, 'sym8', order) # for every order, compute the variance (squared sum of DWT) - this is variance of the energy, so we keep the square - features = np.hstack([np.log(np.var(i**2, axis=2)+1e-10) for i in wavelets]) + features = np.hstack([np.log(np.var(i, axis=2)+1e-10) for i in window_energy]) return features def getWWLfeat(self, windows, WWL_fs=1000): @@ -1448,12 +1687,10 @@ def getWWLfeat(self, windows, WWL_fs=1000): """ # get the highest power of 2 the nyquist rate is divisible by order = math.floor(np.log(WWL_fs/2)/np.log(2) - 1) - # Khushaba et al suggests using sym8 - # note, this will often throw a WARNING saying the user specified order is too high -- but this is what the - # original paper suggests using as the order. - wavelets = wavedec(windows, wavelet='sym8', level=order,axis=2) + # Khushaba et al suggests using sym8. Shared with WENG, WV and WENT. + window_energy = self._wavelet_energy(windows, 'sym8', order) # for every order, compute the waveform length (sum of absolute differences) -- this is WL of the energy, so we keep the square - features = np.hstack([np.log(np.sum(np.abs(np.diff(i**2, axis=2)),axis=2)+1e-10) for i in wavelets]) + features = np.hstack([np.log(np.sum(np.abs(np.diff(i, axis=2)),axis=2)+1e-10) for i in window_energy]) return features def getWENTfeat(self, windows, WENT_fs=1000): @@ -1471,13 +1708,9 @@ def getWENTfeat(self, windows, WENT_fs=1000): The computed features associated with each window. """# get the highest power of 2 the nyquist rate is divisible by order = math.floor(np.log(WENT_fs/2)/np.log(2) - 1) - # Khushaba et al suggests using sym8 - # note, this will often throw a WARNING saying the user specified order is too high -- but this is what the - # original paper suggests using as the order. - wavelets = wavedec(windows, wavelet='sym8', level=order,axis=2) - longs = np.expand_dims(np.array([i.shape[2] for i in wavelets]),(1,2)) + # Khushaba et al suggests using sym8. Shared with WENG, WV and WWL. # for every order, compute the energy (squared sum of DWT) - window_energy = [i **2 for i in wavelets] + window_energy = self._wavelet_energy(windows, 'sym8', order) # within each window: # 1. find the percentage of total energy a sample has (normalize by channel/wavelet amplitude) # 2. once you have this "probability" convert it to an entropy on a per sample basis with: @@ -1800,6 +2033,9 @@ def __project_data(self, projection, projection_engine, feature_matrix, classes, return train_data, test_data def _format_data(self, feature_dictionary): + if not isinstance(feature_dictionary, dict): + return feature_dictionary + arr = None for feat in feature_dictionary: if arr is None: diff --git a/libemg/filtering.py b/libemg/filtering.py index 5e3989ab..8db93e82 100644 --- a/libemg/filtering.py +++ b/libemg/filtering.py @@ -187,7 +187,9 @@ def _get_standardization_params(self, odh): def visualize_filters(self): '''Visualizes the bode plot of the installed filters. ''' - fig, ax = plt.subplots(len(self.filters), 2, figsize=(10, 5*len(self.filters))) + # squeeze=False keeps ax 2-D even for a single filter, otherwise the ax[fl,0] indexing + # below raises when only one filter is installed (matches visualize_effect). + fig, ax = plt.subplots(len(self.filters), 2, figsize=(10, 5*len(self.filters)), squeeze=False) for fl in range(len(self.filters)): if self.filters[fl]["name"] == "standardize": continue# no visualization of standardize filter diff --git a/libemg/gui.py b/libemg/gui.py index 7f43ae72..4a33924a 100644 --- a/libemg/gui.py +++ b/libemg/gui.py @@ -1,6 +1,8 @@ import dearpygui.dearpygui as dpg from libemg._gui._data_collection_panel import DataCollectionPanel from libemg._gui._data_import_panel import DataImportPanel +from libemg._gui._visualization_panel import VisualizationPanel +import gc import inspect import time import os @@ -22,7 +24,12 @@ class GUI: Online data handler used for acquiring raw EMG data. args: dic, default={'media_folder': 'images/', 'data_folder':'data/', 'num_reps': 3, 'rep_time': 5, 'rest_time': 3, 'auto_advance': True} The dictionary that defines the SGT window. Keys are: 'media_folder', - 'data_folder', 'num_reps', 'rep_time', 'rest_time', and 'auto_advance'. + 'data_folder', 'num_reps', 'rep_time', 'rest_time', 'auto_advance', and 'timestamps'. All media (i.e., images and videos) in 'media_folder' will be played in alphabetical order. + For video files, a matching labels file of the same name will be searched for and added to the 'data_folder' if found. + 'rep_time' is only used for images since the duration of videos is automatically calculated based on + the number of frames (assumed to be 24 FPS). 'timestamps' (default False) prepends a timestamp column to + every logged sample. + Visualize > Live Signal reads 'num_samples', 'refresh_rate' and 'plot_height' from this same dictionary. width: int, default=1920 The width of the SGT window. height: int, default=1080 @@ -35,7 +42,7 @@ class GUI: If true, this will cleanup (and kill) the streamer reference. """ def __init__(self, - online_data_handler, + online_data_handler=None, args={'media_folder': 'images/', 'data_folder':'data/', 'num_reps': 3, 'rep_time': 5, 'rest_time': 3, 'auto_advance': True}, width=1920, height=1080, @@ -52,12 +59,24 @@ def __init__(self, self.video_player_width = gesture_width self.video_player_height = gesture_height self.clean_up_on_kill = clean_up_on_kill + # Panels that need a live handler are disabled without one. The + # pipeline editor creates its own sources, so it must be reachable with + # no handler at all. + self.panels = [] self._install_global_fields() def start_gui(self): """ Launches the Screen Guided Training UI. """ + # Everything past this point runs alongside worker threads that must not + # be stalled -- the file logger above all. Anything left unfinalized by + # the script that got us here is collected now, on the main thread, so a + # worker is not the one to trigger it later. A matplotlib window closed + # before this call is the usual culprit: its Tk widgets each block a + # non-main thread for a full second on finalization (see + # libemg.utils._release_interactive_plot). + gc.collect() self._window_init(self.width, self.height, self.debug) def _install_global_fields(self): @@ -78,43 +97,157 @@ def _window_init(self, width, height, debug=False): dpg.show_viewport() dpg.set_exit_callback(self._on_window_close) + # A manual render loop on every path, not just in debug. Anything that + # has to be refreshed while the window is open -- a probe drawing live + # data, a progress bar -- needs a per-frame callback, and + # start_dearpygui() blocks with nowhere to put one. The loop below is + # what the debug path already ran. if debug: dpg.configure_app(manual_callback_management=True) - while dpg.is_dearpygui_running(): - jobs = dpg.get_callback_queue() - dpg.run_callbacks(jobs) - dpg.render_dearpygui_frame() - else: - dpg.start_dearpygui() + while dpg.is_dearpygui_running(): + if debug: + dpg.run_callbacks(dpg.get_callback_queue()) + self._poll_panels() + dpg.render_dearpygui_frame() dpg.destroy_context() + def _poll_panels(self): + """Give every open panel its per-frame slice, on the render thread. + + This is the only thread that may touch DearPyGui items, so it is the + only place a panel may draw what a running pipeline has produced. + """ + for panel in list(self.panels): + poll = getattr(panel, "poll", None) + if poll is None: + continue + try: + poll() + except Exception: + # A panel that fails mid-frame must not take the window down + # with it, or a transient read error closes the whole GUI. + self.panels.remove(panel) + def _file_menu_init(self): with dpg.viewport_menu_bar(): with dpg.menu(label="File"): dpg.add_menu_item(label="Exit") + with dpg.menu(label="Device"): + dpg.add_menu_item(label="Streamer", callback=self._streamer_callback) + with dpg.menu(label="Data"): dpg.add_menu_item(label="Collect Data", callback=self._data_collection_callback) #dpg.add_menu_item(label="Import Data", callback=self._import_data_callback ) #dpg.add_menu_item(label="Export Data", callback=self._export_data_callback) #dpg.add_menu_item(label="Inspect Data", callback=self._inspect_data_callback) - # with dpg.menu(label="Visualize"): - # dpg.add_menu_item(label="Live Signal", callback=self._visualize_livesignal_callback) - + with dpg.menu(label="Visualize"): + dpg.add_menu_item(label="Live Signal", callback=self._visualize_livesignal_callback) + + with dpg.menu(label="Pipeline"): + dpg.add_menu_item(label="Pipeline Editor", callback=self._pipeline_editor_callback) + + with dpg.menu(label="Environments"): + dpg.add_menu_item(label="Launch Environment", + callback=self._environments_callback) + # with dpg.menu(label="Model"): # dpg.add_menu_item(label="Train Classifier", callback=self._train_classifier_callback) # with dpg.menu(label="HCI"): # dpg.add_menu_item(label="Fitts Law", callback=self._fitts_law_callback) + def _open_panel(self, attribute, build): + """Show a panel, reusing the one already open. + + Clicking a menu item twice used to build a second panel, and the second + one's setup deletes the window tag the first one owns. What was left + was an invisible first panel still in the poll list, writing into + widgets the second panel now owns, and still holding whatever it had + started -- a streaming device or a running pipeline -- with no window + able to stop it. Reusing the panel that is already there is what a menu + click means anyway: bring it to the front. + + Parameters + ---------- + attribute: str + Where the panel is remembered on this object. + build: callable + Makes a fresh panel, called only when there is not one open. + + Returns + ---------- + object + The panel now on screen. + """ + panel = getattr(self, attribute, None) + tag = getattr(panel, "window_tag", None) + if panel is not None and tag is not None and dpg.does_alias_exist(tag): + dpg.focus_item(tag) + return panel + # The window is gone, so whatever this panel held goes with it rather + # than outliving the only thing that could stop it. + if panel is not None: + try: + panel.cleanup() + except Exception: + pass + if panel in self.panels: + self.panels.remove(panel) + panel = build() + panel.spawn_window() + setattr(self, attribute, panel) + if panel not in self.panels: + self.panels.append(panel) + return panel + def _data_collection_callback(self): panel_arguments = list(inspect.signature(DataCollectionPanel.__init__).parameters) passed_arguments = {i: self.args[i] for i in self.args.keys() if i in panel_arguments} - self.dcp = DataCollectionPanel(**passed_arguments, gui=self, video_player_width=self.video_player_width, video_player_height=self.video_player_height) + self.dcp = DataCollectionPanel(self.online_data_handler, **passed_arguments, video_player_width=self.video_player_width, video_player_height=self.video_player_height) self.dcp.spawn_configuration_window() + def _visualize_livesignal_callback(self): + panel_arguments = list(inspect.signature(VisualizationPanel.__init__).parameters) + passed_arguments = {i: self.args[i] for i in self.args.keys() if i in panel_arguments} + self.vp = VisualizationPanel(self.online_data_handler, **passed_arguments) + self.vp.spawn_window() + + def _pipeline_editor_callback(self): + from libemg._gui._pipeline.editor_panel import PipelineEditorPanel + self._open_panel("pep", lambda: PipelineEditorPanel( + width=self.width, height=self.height)) + + def _streamer_callback(self): + from libemg._gui._streamer_panel import StreamerPanel + self._open_panel("sp", lambda: StreamerPanel( + on_started=self._streamer_started, + on_stopped=self._streamer_stopped)) + + def _streamer_started(self, online_data_handler, shared_memory_items): + """Make a device started here available to every other panel. + + The panels that need live data read this attribute when they are + opened, so publishing it is what lets somebody start a device and then + collect training data or watch the signal without leaving the window. + """ + self.online_data_handler = online_data_handler + self.args["shared_memory_items"] = shared_memory_items + + def _streamer_stopped(self): + self.online_data_handler = None + + def _environments_callback(self): + from libemg._gui._environments.panel import EnvironmentsPanel + panel = self._open_panel("env_panel", lambda: EnvironmentsPanel( + online_data_handler=self.online_data_handler, + width=min(self.width, 1280), height=min(self.height, 820))) + # A device may have been started since this panel was first opened, and + # an environment launched afterwards should be driven by it. + panel.online_data_handler = self.online_data_handler + def _import_data_callback(self): panel_arguments = list(inspect.signature(DataImportPanel.__init__).parameters) passed_arguments = {i: self.args[i] for i in self.args.keys() if i in panel_arguments} diff --git a/libemg/offline_metrics.py b/libemg/offline_metrics.py index e7675136..fda605bd 100644 --- a/libemg/offline_metrics.py +++ b/libemg/offline_metrics.py @@ -3,8 +3,17 @@ import matplotlib.pyplot as plt class OfflineMetrics: - """Offline Metrics class is used for extracting offline performance metrics. """ + Offline Metrics class is used for extracting offline performance metrics. + """ + + def _ignore_rejected(self, y_predictions, y_true): + # ignore rejections + valid_samples = y_predictions != -1 + y_predictions = y_predictions[valid_samples] + y_true = y_true[valid_samples] + return y_predictions, y_true + def get_common_metrics(self): """Gets a list of the common metrics used for assessing EMG performance. @@ -50,9 +59,9 @@ def extract_common_metrics(self, y_true, y_predictions, null_label=None): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of the true labels associated with each prediction. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted outputs from a classifier. null_label: int (optional) A null label used for the AER metric - this should correspond to the label associated @@ -74,9 +83,9 @@ def extract_offline_metrics(self, metrics, y_true, y_predictions, null_label=Non metrics: list A list of the metrics to extract. A list of metrics can be found running the get_available_metrics function. - y_true: list + y_true: numpy.ndarray A list of the true labels associated with each prediction. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted outputs from a classifier. null_label: int (optional) A null label used for the AER metric - this should correspond to the label associated @@ -126,9 +135,9 @@ def get_CA(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -136,10 +145,7 @@ def get_CA(self, y_true, y_predictions): float Returns the classification accuracy. """ - # ignore rejections - valid_samples = y_predictions != -1 - y_predictions = y_predictions[valid_samples] - y_true = y_true[valid_samples] + y_predictions, y_true = self._ignore_rejected(y_predictions, y_true) if len(y_true) == 0: print("No test samples - check the rejection rate.") return 1.0 @@ -148,13 +154,13 @@ def get_CA(self, y_true, y_predictions): def get_AER(self, y_true, y_predictions, null_class): """Active Error. - Classification accuracy on active classes (i.e., all classes but no movement/rest). + Classification accuracy on active classes (i.e., all classes but no movement/rest). Rejected samples are ignored. Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. null_class: int The null class that shouldn't be considered. @@ -164,6 +170,7 @@ def get_AER(self, y_true, y_predictions, null_class): float Returns the active error. """ + y_predictions, y_true = self._ignore_rejected(y_predictions, y_true) nm_predictions = [i for i, x in enumerate(y_predictions) if x == null_class] return 1 - self.get_CA(np.delete(y_true, nm_predictions), np.delete(y_predictions, nm_predictions)) @@ -174,9 +181,9 @@ def get_INS(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -196,7 +203,7 @@ def get_REJ_RATE(self, y_predictions): Parameters ---------- - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. -1 in the list correspond to rejected predictions. Returns @@ -214,9 +221,9 @@ def get_CONF_MAT(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -240,9 +247,9 @@ def get_RECALL(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -250,6 +257,7 @@ def get_RECALL(self, y_true, y_predictions): list Returns a list consisting of the recall for each class. """ + y_predictions, y_true = self._ignore_rejected(y_predictions, y_true) recall, weights = self._get_RECALL_helper(y_true, y_predictions) return np.average(recall, weights=weights) @@ -273,9 +281,9 @@ def get_PREC(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -283,6 +291,7 @@ def get_PREC(self, y_true, y_predictions): list Returns a list consisting of the precision for each class. """ + y_predictions, y_true = self._ignore_rejected(y_predictions, y_true) precision, weights = self._get_PREC_helper(y_true, y_predictions) return np.average(precision, weights=weights) @@ -307,9 +316,9 @@ def get_F1(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -317,6 +326,7 @@ def get_F1(self, y_true, y_predictions): list Returns a list consisting of the f1 score for each class. """ + y_predictions, y_true = self._ignore_rejected(y_predictions, y_true) prec, weights = self._get_PREC_helper(y_true, y_predictions) recall, _ = self._get_RECALL_helper(y_true, y_predictions) f1 = 2 * (prec * recall) / (prec + recall) @@ -329,9 +339,9 @@ def get_R2(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -351,9 +361,9 @@ def get_MSE(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -372,9 +382,9 @@ def get_MAPE(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -393,9 +403,9 @@ def get_RMSE(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -415,9 +425,9 @@ def get_NRMSE(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns @@ -437,9 +447,9 @@ def get_MAE(self, y_true, y_predictions): Parameters ---------- - y_true: list + y_true: numpy.ndarray A list of ground truth labels. - y_predictions: list + y_predictions: numpy.ndarray A list of predicted labels. Returns diff --git a/libemg/output_writer.py b/libemg/output_writer.py new file mode 100644 index 00000000..ffb73f0a --- /dev/null +++ b/libemg/output_writer.py @@ -0,0 +1,161 @@ +from abc import ABC, abstractmethod +import socket +from libemg.shared_memory_manager import SharedMemoryManager +import numpy as np +import types + +class OutputWriter(ABC): + @abstractmethod + def write(self, info: dict) -> None: + """ + Write the output information. + + Parameters + ---------- + info : dict + A dictionary containing output information such as timestamp, + prediction, probability, velocity, etc. + """ + pass +class ConsoleOutputWriter(OutputWriter): + def __init__(self, tag): + self.tag = tag + + def write(self, info: dict) -> None: + print(str(info['timestamp'])," ", info[self.tag]) + +class FileOutputWriter(OutputWriter): + def __init__(self, tag, file_path: str, file_name: str): + self.file_path = file_path + self.file_name = file_name + self.handle = open(self.file_path + self.file_name, "a", newline="") + + def write(self, info: dict) -> None: + # Format the info as a line. + line = f"{info.get('timestamp', '')} {info.get('prediction', '')} {info.get('probability', '')} {info.get('velocity', '')}\n" + self.handle.write(line) + self.handle.flush() + +class SocketOutputWriter(OutputWriter): + def __init__(self, tag, ip: str = '127.0.0.1', port: int = 12346, protocol: str = "UDP"): + self.ip = ip + self.port = port + self.protocol = protocol.upper() + self.sock = None + self.tag = tag + self._create_socket() + + def _create_socket(self): + if self.protocol == "UDP": + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + elif self.protocol == "TCP": + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.ip, self.port)) + else: + raise ValueError("Protocol must be UDP or TCP.") + + def write(self, info: dict) -> None: + message = str(info[self.tag]) + " " + str(info['timestamp']) + if self.sock is None: + self._create_socket() + if self.protocol == "UDP": + self.sock.sendto(message.encode('utf-8'), (self.ip, self.port)) + else: + self.sock.sendall(message.encode('utf-8')) + + def __getstate__(self): + # Remove the socket from the state so it's not pickled. + state = self.__dict__.copy() + if "sock" in state: + state['sock'].close() + del state["sock"] + return state + + def __setstate__(self, state): + self.__dict__.update(state) + # Reinitialize the socket in the child process. + self.sock = None + self._create_socket() + +class SharedMemoryOutputWriter(OutputWriter): + def __init__(self, tag: str, shape, dtype, lock, mod_fn=None, mod_fn_count=None): + """ + Parameters: + tag (str): + The shared memory variable tag. + shape (tuple): + The shape of the shared memory variable. + dtype: + The data type of the shared memory variable. + lock (Lock): + A multiprocessing lock for synchronization. + mod_fn (callable, optional): + A function that takes (current_data, message) and returns new data. + If not provided, defaults to a function that simply returns the message. + """ + self.tag = tag + self.shape = shape + self.dtype = dtype + self.lock = lock + self.mod_fn = types.MethodType(mod_fn, self) if mod_fn is not None else self.default_mod_fn + self.mod_fn_count = types.MethodType(mod_fn_count, self) if mod_fn_count is not None else self.default_mod_fn_count + # Imported here rather than at module scope to keep the import graph + # acyclic: reactive builds on shared memory, which this module also + # builds on. + from libemg.reactive import default_notifier_pool + # The pool is what lets a write here wake an observer in another + # process. Without it the write still advances the state block, so a + # hook would see the change on its next check rather than immediately. + self.smm = SharedMemoryManager(notifier_pool=default_notifier_pool()) + self.smm.create_variable(tag, shape, dtype, lock) + # Share the buffer's lock so (data, count) can be snapshotted atomically. + self.smm.create_variable(tag+"_count", (1,1), np.int32, lock) + + def write(self, info: dict) -> None: + if self.smm is None: + raise RuntimeError("SharedMemoryOutputWriter not attached to a manager.") + # apply() runs both transforms under one acquisition of the variable's + # lock, where the old pair of modify_variable calls took it twice and + # let a reader see a count that ran ahead of the data. It also advances + # the variable's state block and wakes subscribers, which is what makes + # a write here observable: an adaptation flag or a slice of environment + # feedback becomes something a hook can be triggered by rather than + # something another process has to poll for. + self.smm.apply(self.tag, + lambda data: self.mod_fn(data, info), + lambda data: self.mod_fn_count(data, info)) + + def reset(self) -> None: + if self.smm is None: + raise RuntimeError("SharedMemoryOutputWriter not attached to a manager.") + self.smm.modify_variable(self.tag, lambda data: np.zeros(self.shape, dtype=self.dtype)) + self.smm.modify_variable(self.tag + "_count", lambda data: 0) + + def default_mod_fn(self, data, info): + input_size = self.smm.variables[self.tag]["shape"][0] + data[:] = np.vstack((info[self.tag], data))[:input_size, :] + return data + + def default_mod_fn_count(self, data, info): + data[:] = data[:] + info[self.tag].shape[0] + return data + + def __getstate__(self): + self._smm_item = self.smm.get_shared_memory_items() + state = self.__dict__.copy() + # Remove the non-serializable shared memory manager. + if "smm_manager" in state: + del state["smm_manager"] + return state + + def __setstate__(self, state): + self.__dict__.update(state) + # Reconstruct the shared memory manager using the stored _smm_item. + if self._smm_item is not None: + new_mgr = SharedMemoryManager() + tag, shape, dtype, lock = self._smm_item + new_mgr.create_variable(tag, shape, dtype, lock) + self.smm_manager = new_mgr + else: + self.smm_manager = None + diff --git a/libemg/pipeline.py b/libemg/pipeline.py new file mode 100644 index 00000000..20a3b3cc --- /dev/null +++ b/libemg/pipeline.py @@ -0,0 +1,247 @@ +"""Build and run LibEMG pipelines, with or without the editor. + +The visual editor is a view onto a pipeline, not the pipeline itself. This +module is the public way in: it exposes the same document, registry and +compiler the editor drives, and it can run a saved pipeline from a command +line with no display attached. + +That matters for more than convenience. A pipeline that only exists inside a +GUI cannot be scripted, cannot be checked into a repository usefully, and +cannot be run by continuous integration. Here a pipeline is a file, and running +one is a command. + +Examples +--------- +Build one in Python and run it:: + + from libemg.pipeline import PipelineDocument, compile_pipeline + + doc = PipelineDocument() + source = doc.add_node('source.synthetic_streamer', params={'pattern': 'bursts'}) + window = doc.add_node('window.enframe', params={'window_size': 200, + 'window_increment': 50}) + features = doc.add_node('features.extract', params={'features': ['MAV', 'RMS']}) + doc.connect(source, 'emg', window, 'input') + doc.connect(window, 'output', features, 'input') + doc.add_probe(features, 'output') + doc.save('pipeline.json') + +Or run a saved one from a terminal:: + + python -m libemg.pipeline train pipeline.json + python -m libemg.pipeline run pipeline.json --seconds 30 + python -m libemg.pipeline check pipeline.json + python -m libemg.pipeline blocks + +``train`` fits the model a stored-data pipeline names, on the recording that +pipeline reads, and writes it where the model block already points. So the same +file describes the fit and the run, and continuous integration can do both. +""" + +import argparse +import json +import sys +import time + +from libemg._gui._pipeline.compile import (CompileError, OfflinePipeline, + OnlinePipeline, compile_pipeline, + tag_for) +from libemg._gui._pipeline.document import (Link, Node, PipelineDocument, Probe, + SCHEMA_VERSION, ValidationError) +from libemg._gui._pipeline.registry import (NodeSpec, ParamSpec, PortSpec, + PortType, build_registry, + default_registry) +from libemg._gui._pipeline.synthetic import SyntheticStreamer, synthetic_streamer + +__all__ = ["PipelineDocument", "Node", "Link", "Probe", "SCHEMA_VERSION", + "ValidationError", "compile_pipeline", "CompileError", + "OnlinePipeline", "OfflinePipeline", "tag_for", + "NodeSpec", "ParamSpec", "PortSpec", "PortType", + "build_registry", "default_registry", + "synthetic_streamer", "SyntheticStreamer", "main"] + + +def _describe_blocks(registry): + lines = [] + grouped = {} + for spec in registry.values(): + grouped.setdefault(spec.category, []).append(spec) + for category in sorted(grouped): + lines.append(f"{category}:") + for spec in sorted(grouped[category], key=lambda s: s.id): + inputs = ", ".join(f"{p.name}:{p.type}" for p in spec.inputs) or "-" + outputs = ", ".join(f"{p.name}:{p.type}" for p in spec.outputs) or "-" + lines.append(f" {spec.id}") + lines.append(f" in {inputs}") + lines.append(f" out {outputs}") + if spec.params: + lines.append(" params " + ", ".join(p.name for p in spec.params)) + return "\n".join(lines) + + +def _check(path, registry): + document = PipelineDocument.load(path, registry=registry) + problems = document.validate() + print(f"{path}: {len(document.nodes)} blocks, {len(document.links)} links, " + f"{len(document.probes)} probes, mode '{document.mode()}'") + if problems: + print("not ready:") + for problem in problems: + print(f" - {problem}") + return 1 + print("ready to run.") + return 0 + + +def _train(path, registry, model_path, quiet): + """Fit the model a saved pipeline names, on the recording it reads.""" + document = PipelineDocument.load(path, registry=registry) + try: + pipeline = compile_pipeline(document) + except CompileError as error: + print(error, file=sys.stderr) + return 1 + if not isinstance(pipeline, OfflinePipeline): + print("Training reads stored data. This pipeline's source is a device.", + file=sys.stderr) + return 1 + + width = 40 + + def show(fraction): + filled = int(fraction * width) + print(f"\r[{'#' * filled}{'.' * (width - filled)}] {fraction * 100:5.1f}%", + end="", flush=True) + + try: + result = pipeline.train(on_progress=None if quiet else show, + model_path=model_path) + except CompileError as error: + if not quiet: + print() + print(error, file=sys.stderr) + return 1 + if not quiet: + print() + print(json.dumps({k: _jsonable(v) for k, v in result.items()}, indent=2)) + return 0 + + +def _run(path, registry, seconds, quiet): + from libemg.event_log import EventLog + + document = PipelineDocument.load(path, registry=registry) + log = None if quiet else EventLog(keep=0, to_stdout=False) + try: + pipeline = compile_pipeline(document, log=log) + except CompileError as error: + print(error, file=sys.stderr) + return 1 + + if isinstance(pipeline, OfflinePipeline): + # A run over a recording knows its own total, so it can report a real + # fraction rather than a spinner. + width = 40 + + def show(fraction): + filled = int(fraction * width) + print(f"\r[{'#' * filled}{'.' * (width - filled)}] {fraction * 100:5.1f}%", + end="", flush=True) + + results = pipeline.run(on_progress=None if quiet else show) + if not quiet: + print() + if results: + print(json.dumps({k: _jsonable(v) for k, v in results.items()}, indent=2)) + else: + print("The run finished. Add an Offline Metrics block to score it.") + return 0 + + if log is not None: + log.start() + pipeline.start() + print(f"running for {seconds:g}s. Ctrl-C to stop early.") + try: + deadline = time.time() + seconds + while time.time() < deadline: + time.sleep(0.5) + if not quiet: + status = pipeline.status() + parts = [f"{t.replace('pipe_', '')}={s.total_samples}" + for t, s in sorted(status.items()) if not t.startswith("probe_")] + print("\r" + " ".join(parts)[:150], end="", flush=True) + except KeyboardInterrupt: + pass + finally: + print() + pipeline.stop() + if log is not None: + log.stop() + return 0 + + +def _jsonable(value): + try: + import numpy as np + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, (np.floating, np.integer)): + return value.item() + except ImportError: + pass + return value + + +def main(argv=None): + """Command-line entry point. + + Parameters + ---------- + argv: list or None (optional), default=None + Arguments to parse. Defaults to the process arguments. + + Returns + ---------- + int + A process exit status. + """ + parser = argparse.ArgumentParser( + prog="python -m libemg.pipeline", + description="Build and run LibEMG pipelines without the editor.") + commands = parser.add_subparsers(dest="command", required=True) + + run = commands.add_parser("run", help="Run a saved pipeline.") + run.add_argument("path", help="The pipeline file.") + run.add_argument("--seconds", type=float, default=30.0, + help="How long to run a live pipeline. Ignored offline.") + run.add_argument("--quiet", action="store_true", help="Suppress progress output.") + + check = commands.add_parser("check", help="Validate a saved pipeline.") + check.add_argument("path", help="The pipeline file.") + + train = commands.add_parser( + "train", help="Fit the model a stored-data pipeline names.") + train.add_argument("path", help="The pipeline file.") + train.add_argument("--model-path", default=None, + help="Where to save. Defaults to the model block's own " + "Fitted Model path.") + train.add_argument("--quiet", action="store_true", + help="Suppress progress output.") + + commands.add_parser("blocks", help="List the blocks that can be placed.") + + arguments = parser.parse_args(argv) + registry = default_registry() + if arguments.command == "blocks": + print(_describe_blocks(registry)) + return 0 + if arguments.command == "check": + return _check(arguments.path, registry) + if arguments.command == "train": + return _train(arguments.path, registry, arguments.model_path, + arguments.quiet) + return _run(arguments.path, registry, arguments.seconds, arguments.quiet) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libemg/reactive.py b/libemg/reactive.py new file mode 100644 index 00000000..7dc67cbc --- /dev/null +++ b/libemg/reactive.py @@ -0,0 +1,1392 @@ +"""Reactive hooks over stateful shared memory. + +The problem this replaces +------------------------- +Online processing in libemg used to be built out of pollers. Each consumer sat +in a loop asking "is there a window yet?", and asking cost a copy of an entire +shared-memory buffer plus, if a filter was installed, a filter pass over all of +it. Several consumers meant several pollers, each independently re-deriving the +same filtered signal and the same features, with no way to share the result. + +Here, a write to a shared-memory item announces itself. Anything hooked into +that item is told it changed and decides for itself whether the change matters. + +Why "dirty" is not a flag on the item +------------------------------------- +An item can have any number of observers, and a single boolean would be wrong +for all of them: whichever observer cleared the flag would starve the rest. +Worse, observers legitimately disagree about what "changed enough" means. A +filter wants to run on a single new sample. A windowing stage does not care +until a full window increment has accrued. A live plot only wants thirty +updates a second no matter how fast samples arrive. + +So the item publishes *facts* -- how many times it has been written, how many +samples have ever arrived, whether its writer has finished -- and each observer +pairs those facts with its own :class:`Criterion` and its own memory of what it +last consumed. Propagation is the notification; dirtiness is a per-observer +judgement made against shared facts. + +Because the facts live in the item's state block in shared memory, and the +notification is a synchronization primitive shared between processes, an +observer does not have to live in the process that did the writing. + +The cascade +----------- +A hook's output is itself a stateful item, so committing to it notifies that +item's observers in turn. Chaining ``emg`` to ``filtered_emg`` to ``features`` +to ``predictions`` needs no coordinating loop; each stage wakes the next. Any +stage may have several observers, and a model is free to hook whichever stage +it wants -- raw samples, filtered samples, or features -- because what a hook +observes is declared rather than hard-wired. + +Examples +--------- +>>> from libemg.reactive import ReactiveGraph, FilterHook, Input, OnCommit, OnSamples +>>> from libemg.event_log import EventLog +>>> log = EventLog(path='reactive.log') +>>> graph = ReactiveGraph(shared_memory_items, log=log) +>>> graph.add(FilterHook('filter', 'emg', 'filtered_emg', fi=my_filter, +... shape=(2000, 8))) +>>> graph.start() +""" + +import pickle +import threading +import time +import traceback +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from multiprocessing import Condition, Event, Process + +import numpy as np + +from libemg import event_log +from libemg.shared_memory_manager import SharedMemoryManager, MAX_SUBSCRIBERS + + +# ====================================================================== +# Notification +# ====================================================================== +class NotifierPool: + """A fixed set of wake-up slots that any process can signal. + + Each executor claims one slot and waits on it. A writer signals the slots + subscribed to the item it just wrote, which it discovers from that item's + state block -- so a writer that started before an observer existed still + reaches it. + + The slots are made up front and handed to child processes as process + arguments, because a synchronization primitive cannot be looked up by name + after the fact on every platform libemg supports. A fixed pool is what + makes the set of handles known before anything spawns. + + Parameters + ---------- + size: int (optional), default=16 + How many slots to create. One per executor is enough; the cap is + :data:`libemg.shared_memory_manager.MAX_SUBSCRIBERS`. + """ + + def __init__(self, size=16): + if size > MAX_SUBSCRIBERS: + raise ValueError(f"A notifier pool holds at most {MAX_SUBSCRIBERS} slots.") + self.size = size + self._conditions = tuple(Condition() for _ in range(size)) + self._next_slot = 0 + + def claim(self): + """Reserve a slot. Call in the parent, before anything spawns. + + Returns + ---------- + int + The claimed slot number. + """ + if self._next_slot >= self.size: + raise RuntimeError( + f"All {self.size} notifier slots are claimed. " + "Construct the pool with a larger size." + ) + slot = self._next_slot + self._next_slot += 1 + return slot + + def signal(self, slot): + """Wake whoever is waiting on ``slot``.""" + condition = self._conditions[slot] + with condition: + condition.notify_all() + + def __len__(self): + return self.size + + def wait(self, slot, predicate, timeout): + """Block on ``slot`` until ``predicate`` holds or ``timeout`` elapses. + + The predicate is re-checked under the slot's lock, so a change that + lands between the check and the wait cannot be missed. The timeout is + what keeps a dropped notification from becoming a hang: the worst case + degrades to polling at the timeout interval rather than stopping. + + Returns + ---------- + bool + Whether the predicate held. + """ + condition = self._conditions[slot] + with condition: + return condition.wait_for(predicate, timeout=timeout) + + +_DEFAULT_POOL = None + + +def default_notifier_pool(size=16): + """The notifier pool shared by everything built in this process. + + A writer can only wake an observer if the two hold the same pool, and a + pool cannot be looked up by name after the fact. Routing everything through + one process-local pool is what lets a streamer started early notify a + predictor built later, as long as both were set up in the same script -- + which is how libemg pipelines are normally assembled. + + A writer that never obtains the pool still works. Its commits update the + item's state block, and observers fall back to re-reading that block at + their fallback interval. The block is a handful of integers, so even that + fallback is orders of magnitude cheaper than the buffer copy the old + pollers paid; obtaining the pool turns a cheap poll into no poll at all. + + Parameters + ---------- + size: int (optional), default=16 + Slots to create, if the pool does not exist yet. + + Returns + ---------- + NotifierPool + The process-wide pool. + """ + global _DEFAULT_POOL + if _DEFAULT_POOL is None: + _DEFAULT_POOL = NotifierPool(size) + return _DEFAULT_POOL + + +def reset_default_notifier_pool(): + """Discard the process-wide pool. Intended for tests.""" + global _DEFAULT_POOL + _DEFAULT_POOL = None + + +# ====================================================================== +# Criteria -- how an observer decides an item is dirty +# ====================================================================== +@dataclass +class CriterionMemory: + """What one observer remembers about one item it observes. + + Attributes + ---------- + generation: int + The last commit generation this observer has accounted for. + samples: int + The sample total this observer has consumed up to. Distinct from + ``generation`` because a volume-based criterion has to survive commits + of differing sizes. + fired_at: float + ``time.perf_counter()`` when this observer last fired on the item. + epoch: int + The item's epoch when this memory was last valid. A mismatch means the + item's counters were reset and everything here is stale. + """ + + generation: int = 0 + samples: int = 0 + fired_at: float = 0.0 + epoch: int = 0 + + +class Criterion(ABC): + """An observer's own definition of "this item changed enough to act on". + + Subclass this to express a rule the built-ins do not cover. The contract is + three methods: + + - :meth:`is_dirty` looks at the item's published facts and the observer's + memory and returns a judgement. It must not mutate either. + - :meth:`consume` advances the memory to record that the observer acted. + Keeping it separate from ``is_dirty`` is what lets the runtime evaluate a + criterion for logging without committing to running the hook. + - :meth:`describe` names the criterion for the event log, so a recorded + decision can be read back and understood. + """ + + @abstractmethod + def is_dirty(self, snapshot, memory): + """Whether ``snapshot`` counts as a change this observer cares about.""" + + def consume(self, snapshot, memory): + """Record that the observer has acted on ``snapshot``.""" + memory.generation = snapshot.generation + memory.samples = snapshot.total_samples + memory.fired_at = time.perf_counter() + memory.epoch = snapshot.epoch + + def describe(self): + return type(self).__name__ + "()" + + def __repr__(self): + return self.describe() + + +class OnCommit(Criterion): + """Dirty on any write at all. + + What a filter or a logger wants: one new sample is a reason to run. + """ + + def is_dirty(self, snapshot, memory): + return snapshot.generation > memory.generation + + +class OnSamples(Criterion): + """Dirty once ``increment`` new samples have accrued. + + What a windowing stage wants. A commit of one sample leaves the item clean + by this criterion even though it is dirty by :class:`OnCommit`, which is + exactly the disagreement that makes dirtiness a per-observer judgement + rather than a property of the item. + + :meth:`consume` advances by exactly ``increment`` rather than jumping to + the current total, so a burst that delivers three increments at once + produces three firings instead of one. That keeps window boundaries evenly + spaced no matter how the device chunks its packets. + + Parameters + ---------- + increment: int + How many new samples constitute a change. + """ + + def __init__(self, increment): + if increment < 1: + raise ValueError("increment must be at least 1.") + self.increment = int(increment) + + def is_dirty(self, snapshot, memory): + return (snapshot.total_samples - memory.samples) >= self.increment + + def consume(self, snapshot, memory): + memory.samples += self.increment + memory.generation = snapshot.generation + memory.fired_at = time.perf_counter() + memory.epoch = snapshot.epoch + + def describe(self): + return f"OnSamples({self.increment})" + + +class AfterSamples(OnSamples): + """Dirty once ``increment`` samples accrue, then skip whatever piled up. + + Same threshold as :class:`OnSamples`, but :meth:`consume` jumps to the + current total instead of stepping. Use it where acting on the newest data + matters more than acting on all of it: a model that has fallen behind + should predict from the latest window rather than work through a backlog of + stale ones. + """ + + def consume(self, snapshot, memory): + memory.samples = snapshot.total_samples + memory.generation = snapshot.generation + memory.fired_at = time.perf_counter() + memory.epoch = snapshot.epoch + + def describe(self): + return f"AfterSamples({self.increment})" + + +class Periodic(Criterion): + """Dirty at most ``hz`` times a second, and only if something changed. + + For observers whose cost is in the presentation rather than the + computation: a live plot updated faster than the display refreshes is + wasted work, and a plot of unchanged data is wasted work too. + + Parameters + ---------- + hz: float + Maximum firings per second. + """ + + def __init__(self, hz): + if hz <= 0: + raise ValueError("hz must be positive.") + self.hz = float(hz) + self.period = 1.0 / self.hz + + def is_dirty(self, snapshot, memory): + if snapshot.generation <= memory.generation: + return False + return (time.perf_counter() - memory.fired_at) >= self.period + + def describe(self): + return f"Periodic({self.hz:g})" + + +class Always(Criterion): + """Dirty whenever asked, changed or not. + + Only sensible paired with a rate limit or a hook that must run on a + heartbeat regardless of input. + """ + + def is_dirty(self, snapshot, memory): + return True + + +class WhenClosed(Criterion): + """Dirty once the item's writer declares itself finished. + + How a finite pipeline finishes: the stage that summarises a run hooks the + close rather than a data change. + """ + + def is_dirty(self, snapshot, memory): + return snapshot.closed and memory.generation < snapshot.generation + 1 + + def consume(self, snapshot, memory): + memory.generation = snapshot.generation + 1 + memory.samples = snapshot.total_samples + memory.fired_at = time.perf_counter() + memory.epoch = snapshot.epoch + + +class Custom(Criterion): + """Wrap a plain function as a criterion. + + Parameters + ---------- + predicate: callable + Called as ``predicate(snapshot, memory)``, returning a bool. It is + given the item's :class:`~libemg.shared_memory_manager.Snapshot` and + the observer's :class:`CriterionMemory`. + name: str (optional), default='Custom' + What the event log should call this criterion. + + Examples + --------- + >>> # act only once a second's worth of samples has arrived + >>> Custom(lambda s, m: s.total_samples - m.samples >= 1000, 'OneSecond') + """ + + def __init__(self, predicate, name="Custom"): + self.predicate = predicate + self.name = name + + def is_dirty(self, snapshot, memory): + return bool(self.predicate(snapshot, memory)) + + def describe(self): + return f"{self.name}()" + + +class AllOf(Criterion): + """Dirty only when every wrapped criterion is.""" + + def __init__(self, *criteria): + self.criteria = criteria + + def is_dirty(self, snapshot, memory): + return all(c.is_dirty(snapshot, memory) for c in self.criteria) + + def consume(self, snapshot, memory): + for c in self.criteria: + c.consume(snapshot, memory) + + def describe(self): + return "AllOf(" + ", ".join(c.describe() for c in self.criteria) + ")" + + +class AnyOf(Criterion): + """Dirty when any wrapped criterion is.""" + + def __init__(self, *criteria): + self.criteria = criteria + + def is_dirty(self, snapshot, memory): + return any(c.is_dirty(snapshot, memory) for c in self.criteria) + + def consume(self, snapshot, memory): + for c in self.criteria: + c.consume(snapshot, memory) + + def describe(self): + return "AnyOf(" + ", ".join(c.describe() for c in self.criteria) + ")" + + +# ====================================================================== +# Hook declarations +# ====================================================================== +# How a hook wants its input delivered. +WINDOW = "window" # the newest `size` rows, oldest first +DELTA = "delta" # everything committed since this hook last consumed +LATEST = "latest" # the newest single row +FULL = "full" # the whole buffer, newest-first, as get_data returns it +STATE = "state" # no data at all, just the snapshot + + +@dataclass +class Input: + """One item a hook observes, and the terms on which it observes it. + + Parameters + ---------- + tag: str + The shared-memory item to observe. + criterion: Criterion + This hook's rule for when a change to that item matters. Defaults to + :class:`OnCommit`, i.e. any write. + mode: str + How to deliver the data: :data:`WINDOW`, :data:`DELTA`, :data:`LATEST`, + :data:`FULL`, or :data:`STATE` for no data at all. + size: int or None + Rows to deliver in :data:`WINDOW` mode. + """ + + tag: str + criterion: Criterion = field(default_factory=OnCommit) + mode: str = WINDOW + size: int = None + + def __post_init__(self): + if self.mode == WINDOW and not self.size: + raise ValueError(f"Input('{self.tag}', mode=WINDOW) needs a size.") + + +@dataclass +class Output: + """An item a hook writes, created by the graph if it does not exist. + + Parameters + ---------- + tag: str + The shared-memory item to create and commit to. + shape: tuple + Buffer shape, as ``(rows, columns)``. + dtype: type + Buffer dtype. + """ + + tag: str + shape: tuple + dtype: type = np.double + + +class Hook(ABC): + """Something that runs when an item it observes is dirty by its criteria. + + A hook declares what it watches and what it writes; the runtime does the + attaching, the locking and the committing. A hook never takes a lock and + never commits, which is what makes one testable in isolation: build the + input dict by hand, call :meth:`step`, and check what comes back. + + Parameters + ---------- + name: str + Identifies the hook in the event log. Must be unique in a graph. + inputs: sequence of Input + What it observes. + outputs: sequence of Output (optional) + What it writes. Committing to these is what continues the cascade. + trigger: str (optional), default='any' + With several inputs, whether the hook runs when ``'any'`` of them is + dirty or only when ``'all'`` are. ``'all'`` is what a hook fusing two + modalities wants, so it does not run on half its data. + attach: sequence of str (optional) + Further items the hook needs access to, which the runtime should make + available but neither trigger on nor write for it. This is for a value + the hook sets itself rather than appends to, such as a flag announcing + that something is ready; the hook writes it with + :meth:`~libemg.shared_memory_manager.SharedMemoryManager.apply` from + inside :meth:`step`. Without declaring it here the item is simply not + attached in the executor's process and the write goes nowhere. + """ + + def __init__(self, name, inputs, outputs=(), trigger="any", attach=()): + if trigger not in ("any", "all"): + raise ValueError("trigger must be 'any' or 'all'.") + self.name = name + self.inputs = list(inputs) + self.outputs = list(outputs) + self.trigger = trigger + self.attach = list(attach) + + def setup(self, context): + """Build whatever cannot be pickled: models, filter state, sockets. + + Runs once, inside the executor's process, before the first step. A + hook must hold configuration rather than live objects so it can reach + that process at all; this is where the live objects get made. + + Parameters + ---------- + context: HookContext + Access to the executor's shared memory manager and event log. + """ + + @abstractmethod + def step(self, data, snapshots): + """Do the work. + + Parameters + ---------- + data: dict + Tag to array, delivered per each input's mode. Absent for inputs + declared :data:`STATE`. + snapshots: dict + Tag to :class:`~libemg.shared_memory_manager.Snapshot`, the state + each input was read at. + + Returns + ---------- + dict or None + Tag to array for each output to commit, or None to commit nothing. + Returning None is the normal way for a hook to decline: a model + that has not been fitted yet, say. + """ + + def teardown(self): + """Release anything :meth:`setup` made.""" + + +@dataclass +class HookContext: + """What a hook is given at setup time.""" + + smm: SharedMemoryManager + log: object + executor: str + + +# ====================================================================== +# Built-in hooks +# ====================================================================== +class CallbackHook(Hook): + """Run a plain function when an item goes dirty. + + The least ceremony available: useful for logging, for driving something + outside libemg, and for tests. + + Parameters + ---------- + name: str + Hook name. + inputs: sequence of Input + What to observe. + fn: callable + Called as ``fn(data, snapshots)``; whatever it returns is committed to + the declared outputs, so returning None is fine for a pure sink. + outputs: sequence of Output (optional) + What it writes. + trigger: str (optional), default='any' + As :class:`Hook`. + + Examples + --------- + >>> CallbackHook('watch', [Input('emg', OnCommit(), mode=LATEST)], + ... fn=lambda data, snaps: print(data['emg'])) + """ + + def __init__(self, name, inputs, fn, outputs=(), trigger="any"): + super().__init__(name, inputs, outputs, trigger) + self.fn = fn + + def step(self, data, snapshots): + return self.fn(data, snapshots) + + +class FilterHook(Hook): + """Filter one item into another. + + Sits between a raw item and everything that wants it conditioned, so the + filtering happens once for all consumers instead of once per consumer. + + Parameters + ---------- + name: str + Hook name. + source: str + Item to read. + target: str + Item to write. + fi: libemg.filtering.Filter + The filter to apply. + shape: tuple + Buffer shape for ``target``. + window: int (optional), default=None + How many rows to filter per run. Defaults to the target's row count. + Filtering a margin larger than the increment and keeping the newest + rows is what stops each run's edge effects from reaching the output. + increment: int (optional), default=1 + Rows of new input that constitute a change. The default of one sample + is the point of a filter stage: it is dirty as soon as anything + arrives. + dtype: type (optional), default=numpy.double + Buffer dtype for ``target``. + + Notes + ----- + The filter is applied to a fresh window on every run rather than carried as + streaming state, so its output matches what an offline filter would produce + for the same rows. That is deliberate: libemg's filters are zero-phase, + which cannot be realised causally, so the honest choice is to keep the + offline semantics and pay for a margin. + """ + + def __init__(self, name, source, target, fi, shape, window=None, + increment=1, dtype=np.double): + window = window or shape[0] + super().__init__( + name, + inputs=[Input(source, OnSamples(increment), mode=WINDOW, size=window)], + outputs=[Output(target, shape, dtype)], + ) + self.source = source + self.target = target + self.fi = fi + self.window = window + self.increment = increment + + def step(self, data, snapshots): + samples = data[self.source] + if samples.shape[0] == 0: + return None + filtered = self.fi.filter(samples) if self.fi is not None else samples + # Only the newest `increment` rows are new; the rest were emitted by an + # earlier run and re-filtering them here would duplicate them. + return {self.target: filtered[-self.increment:]} + + +class FeatureHook(Hook): + """Extract features from a window of an item. + + Worth having as its own stage when more than one model wants the same + features: the extraction happens once and both models hook the result. + + Parameters + ---------- + name: str + Hook name. + source: str + Item to window. + target: str + Item to write features to. + feature_list: list + Features to extract. + window_size: int + Samples per window. + window_increment: int + New samples that constitute a change, i.e. the hop between windows. + num_features: int + Columns the target buffer needs. Declared rather than inferred because + the buffer has to exist before the first extraction runs. + rows: int (optional), default=100 + Rows to retain in the target buffer. + feature_dic: dict (optional), default=None + Feature parameters. + """ + + def __init__(self, name, source, target, feature_list, window_size, + window_increment, num_features, rows=100, feature_dic=None): + super().__init__( + name, + inputs=[Input(source, OnSamples(window_increment), + mode=WINDOW, size=window_size)], + outputs=[Output(target, (rows, num_features), np.double)], + ) + self.source = source + self.target = target + self.feature_list = feature_list + self.window_size = window_size + self.feature_dic = feature_dic or {} + self._fe = None + + def setup(self, context): + # Built here rather than in __init__ so the hook stays picklable for a + # spawned executor. + from libemg.feature_extractor import FeatureExtractor + self._fe = FeatureExtractor() + + def step(self, data, snapshots): + samples = data[self.source] + if samples.shape[0] < self.window_size: + return None + window = samples.transpose()[np.newaxis, :, :] + features = self._fe.extract_features( + self.feature_list, window, feature_dic=self.feature_dic, array=True) + return {self.target: features} + + +class ProbeHook(Hook): + """Observe an item without writing anything. + + Rate-limited by default and holds no outputs, so a probe cannot slow the + pipeline it is watching or change what it produces. + + Parameters + ---------- + name: str + Hook name. + source: str + Item to observe. + fn: callable + Called as ``fn(data, snapshots)``. + hz: float (optional), default=30.0 + Maximum observations per second. + mode: str (optional), default=LATEST + Delivery mode. + size: int (optional), default=None + Rows, in :data:`WINDOW` mode. + """ + + def __init__(self, name, source, fn, hz=30.0, mode=LATEST, size=None): + super().__init__(name, + inputs=[Input(source, Periodic(hz), mode=mode, size=size)]) + self.source = source + self.fn = fn + + def step(self, data, snapshots): + self.fn(data, snapshots) + return None + + +# ====================================================================== +# Execution +# ====================================================================== +class _ExecutorCore: + """The wait-and-service loop, independent of what it runs inside. + + An executor owns exactly one wake-up slot and one wait loop. It wakes when + any item its hooks observe is committed to, asks each hook's criteria + whether that change matters, and runs the ones that say yes. It never + polls: the wait is a blocking wait with a timeout, and the timeout exists + only so a lost notification degrades to a slow check instead of a hang. + + :class:`Executor` runs this in its own process and :class:`ThreadExecutor` + runs it in a thread of the process that built the graph. The loop is + identical either way, which is what makes the choice purely about where a + hook needs to live rather than about what it can do. + """ + + def __init__(self, name, hooks, shared_memory_items, notifier_pool, slot, + log, stop_event, poll_fallback=0.05, max_catchup=8): + self.executor_name = name + self.hooks = hooks + self.shared_memory_items = shared_memory_items + self.notifier_pool = notifier_pool + self.slot = slot + self.log = log + self.stop_event = stop_event + self.poll_fallback = poll_fallback + self.max_catchup = max_catchup + + # ------------------------------------------------------------------ + def run(self): + smm = SharedMemoryManager(notifier_pool=self.notifier_pool, log=self.log) + self.log.emit(event_log.LIFECYCLE, origin=self.executor_name, + observer=self.executor_name, phase="starting", slot=self.slot) + try: + self._attach(smm) + self._setup_hooks(smm) + self._loop(smm) + except Exception: + self.log.emit(event_log.ERROR, origin=self.executor_name, + observer=self.executor_name, + traceback=traceback.format_exc().replace("\n", " | ")) + raise + finally: + for hook in self.hooks: + try: + hook.teardown() + except Exception: + pass + self.log.emit(event_log.LIFECYCLE, origin=self.executor_name, + observer=self.executor_name, phase="stopped") + smm.cleanup(parent=False) + + def _attach(self, smm): + """Attach every item this executor's hooks read or write.""" + declared = {item[0]: item for item in self.shared_memory_items} + wanted = set() + for hook in self.hooks: + wanted.update(i.tag for i in hook.inputs) + wanted.update(o.tag for o in hook.outputs) + wanted.update(getattr(hook, "attach", ())) + # A legacy sample counter is kept in step by commit(), so it has to + # be attached wherever its buffer is. + for output in hook.outputs: + if output.tag + "_count" in declared: + wanted.add(output.tag + "_count") + for spec in hook.inputs: + if spec.tag + "_count" in declared: + wanted.add(spec.tag + "_count") + for tag in sorted(wanted): + if tag not in declared: + raise KeyError( + f"Executor '{self.executor_name}' needs shared-memory item " + f"'{tag}', which the graph did not declare." + ) + item = declared[tag] + deadline = time.time() + 10 + while not smm.find_variable(*item): + if time.time() > deadline: + raise TimeoutError( + f"Shared-memory item '{tag}' never appeared. Is its writer running?" + ) + time.sleep(0.01) + # Subscribing after attaching means the writer can already be running: + # subscriptions live in the item's state block, not in the writer. + self.watched = sorted({i.tag for hook in self.hooks for i in hook.inputs}) + for tag in self.watched: + smm.subscribe(tag, self.slot) + + def _setup_hooks(self, smm): + context = HookContext(smm=smm, log=self.log, executor=self.executor_name) + self.memory = {} + for hook in self.hooks: + hook.setup(context) + for spec in hook.inputs: + self.memory[(hook.name, spec.tag)] = CriterionMemory() + + # ------------------------------------------------------------------ + def _loop(self, smm): + seen = {tag: 0 for tag in self.watched} + + def anything_changed(): + for tag in self.watched: + block = smm._block(tag) + if int(block[0]) > seen[tag] or int(block[3]): + return True + return False + + while not self.stop_event.is_set(): + self.notifier_pool.wait(self.slot, anything_changed, self.poll_fallback) + if self.stop_event.is_set(): + break + snapshots = smm.snapshots(self.watched) + for tag in self.watched: + seen[tag] = snapshots[tag].generation + for hook in self.hooks: + self._service(hook, smm, snapshots) + # A pipeline over a finite source has to be able to finish. Once + # every watched item is closed and no criterion is still dirty, + # there is nothing left that could ever fire. + if all(snapshots[t].closed for t in self.watched) and not self._any_dirty(smm): + self.log.emit(event_log.LIFECYCLE, origin=self.executor_name, + observer=self.executor_name, phase="drained") + break + + def _any_dirty(self, smm): + for hook in self.hooks: + snaps = smm.snapshots([i.tag for i in hook.inputs]) + for spec in hook.inputs: + if spec.criterion.is_dirty(snaps[spec.tag], + self.memory[(hook.name, spec.tag)]): + return True + return False + + def _service(self, hook, smm, cached): + """Evaluate one hook's criteria and run it while it says dirty.""" + for _ in range(self.max_catchup): + snaps = {} + for spec in hook.inputs: + snaps[spec.tag] = cached.get(spec.tag) or smm.snapshot(spec.tag) + cached = {} # only the first pass may use the batch snapshot + + verdicts = {} + for spec in hook.inputs: + memory = self.memory[(hook.name, spec.tag)] + snapshot = snaps[spec.tag] + # A reset moves the counters backwards on purpose. Without + # noticing the epoch change an observer would read the reset as + # "nothing has happened" and stall forever. + if snapshot.epoch != memory.epoch: + memory.generation = 0 + memory.samples = 0 + memory.epoch = snapshot.epoch + dirty = spec.criterion.is_dirty(snapshot, memory) + verdicts[spec.tag] = dirty + if self.log.enabled: + self.log.emit(event_log.DIRTY if dirty else event_log.CLEAN, + origin=spec.tag, observer=hook.name, + criterion=spec.criterion.describe(), + generation=snapshot.generation, + total_samples=snapshot.total_samples, + seen_generation=memory.generation, + consumed_samples=memory.samples) + fire = all(verdicts.values()) if hook.trigger == "all" else any(verdicts.values()) + if not fire: + return + + data = {} + for spec in hook.inputs: + if spec.mode == STATE: + continue + memory = self.memory[(hook.name, spec.tag)] + value, read_at = self._read(smm, spec, memory, snaps[spec.tag]) + data[spec.tag] = value + # Advance against the state the read itself saw, not the state + # seen when the criterion was evaluated. A commit landing + # between the two would otherwise leave the memory behind what + # was actually handed to the hook, and the next pass would + # deliver those rows a second time. + if read_at is not None: + snaps[spec.tag] = read_at + + started = time.perf_counter() + if self.log.enabled: + self.log.emit(event_log.INVOKE, origin=",".join( + t for t, v in verdicts.items() if v), + observer=hook.name, executor=self.executor_name) + try: + produced = hook.step(data, snaps) + except Exception: + self.log.emit(event_log.ERROR, origin=hook.name, observer=hook.name, + traceback=traceback.format_exc().replace("\n", " | ")) + # One failing hook must not take the executor's other hooks + # down with it, so the criterion is consumed and the loop moves + # on rather than retrying the same bad input forever. + for spec in hook.inputs: + if verdicts[spec.tag]: + spec.criterion.consume(snaps[spec.tag], + self.memory[(hook.name, spec.tag)]) + return + + for spec in hook.inputs: + if verdicts[spec.tag]: + spec.criterion.consume(snaps[spec.tag], + self.memory[(hook.name, spec.tag)]) + + if produced: + for tag, value in produced.items(): + if value is None: + continue + smm.commit(tag, value) + + if self.log.enabled: + self.log.emit(event_log.COMPLETE, observer=hook.name, + origin=hook.name, + duration_ms=(time.perf_counter() - started) * 1e3, + committed=",".join(sorted(produced)) if produced else "-") + else: + # Still dirty after max_catchup runs: the hook cannot keep up. + self.log.emit(event_log.DROP, observer=hook.name, origin=hook.name, + reason="max_catchup", limit=self.max_catchup) + + def _read(self, smm, spec, memory, snapshot): + """Deliver an input, and report the state it was actually read at. + + Returns + ---------- + value: numpy.ndarray + The data, per the input's mode. + read_at: Snapshot or None + The item's state at the instant of the read, or None where the + mode does not read the state. The caller advances the observer's + memory against this rather than against an earlier snapshot. + """ + if spec.mode == WINDOW: + window, read_at = smm.read_window(spec.tag, spec.size) + return window, read_at + if spec.mode == LATEST: + window, read_at = smm.read_window(spec.tag, 1) + return window, read_at + if spec.mode == DELTA: + samples, read_at, lost = smm.read_since(spec.tag, memory.samples) + if lost and self.log.enabled: + self.log.emit(event_log.DROP, origin=spec.tag, observer=spec.tag, + reason="buffer_overwritten", lost=lost) + return samples, read_at + if spec.mode == FULL: + return smm.get_variable(spec.tag), None + raise ValueError(f"Unknown input mode '{spec.mode}'.") + + +class Executor(Process): + """Runs a set of hooks in a process of its own. + + The default. Hooks reach the process by being pickled, so a hook must hold + configuration rather than live objects and build the live ones in + :meth:`Hook.setup`. + + Constructed by :class:`ReactiveGraph`; not usually built directly. + """ + + def __init__(self, *args, **kwargs): + name = args[0] if args else kwargs["name"] + super().__init__(daemon=True, name=f"libemg-executor-{name}") + # Kept on the wrapper as well as the core so an executor stays + # introspectable from the process that made it, where the core does + # not exist yet. + self.executor_name = name + self.hooks = args[1] if len(args) > 1 else kwargs.get("hooks", []) + self._core_args = args + self._core_kwargs = kwargs + + def run(self): + _ExecutorCore(*self._core_args, **self._core_kwargs).run() + + +class ThreadExecutor(threading.Thread): + """Runs a set of hooks in a thread of the process that built the graph. + + For hooks that cannot or should not be moved to another process. Two cases + come up in practice: + + - The hook closes over something unpicklable, a lambda or a local + function. In a process executor that fails at spawn time. + - The hook has to touch something that only exists here, most often a + plotting window. A GUI toolkit will not accept calls from another + process, so a probe that draws has to run in the process that owns the + window. + + The cost is that the work shares this process's interpreter lock, so a hook + doing heavy numeric work belongs in a process executor instead. Numpy + releases the lock for the arithmetic itself, so a probe or a light + transform is usually fine here. + """ + + def __init__(self, *args, **kwargs): + name = args[0] if args else kwargs["name"] + super().__init__(daemon=True, name=f"libemg-thread-executor-{name}") + self.executor_name = name + self.hooks = args[1] if len(args) > 1 else kwargs.get("hooks", []) + self._core_args = args + self._core_kwargs = kwargs + + def run(self): + _ExecutorCore(*self._core_args, **self._core_kwargs).run() + + +class ReactiveGraph: + """Assembles hooks over stateful shared memory and runs them. + + Hooks are grouped into executors, one process each. Hooks in the same + executor share a wake-up and run in sequence, which is what you want for + stages that are individually cheap; a hook that is expensive, or that must + not be delayed by its neighbours, belongs in its own executor. + + Parameters + ---------- + shared_memory_items: list + The items the graph may touch, in the ``[tag, shape, dtype, lock]`` + form the streamers produce. Outputs declared by hooks are appended + automatically. + log: EventLog or None (optional), default=None + Records every commit, every criterion decision and every invocation. + Defaults to no logging. + notifier_pool: NotifierPool or None (optional), default=None + Created for you if omitted. + poll_fallback: float (optional), default=0.05 + Seconds an executor will wait before re-checking without having been + woken. This is a safety net, not the mechanism. + + Examples + --------- + >>> graph = ReactiveGraph(shared_memory_items, log=EventLog(to_stdout=True)) + >>> graph.add(FilterHook('filt', 'emg', 'filtered_emg', fi, shape=(2000, 8))) + >>> graph.add(my_model_hook, executor='model') + >>> graph.start() + >>> ... + >>> graph.stop() + """ + + def __init__(self, shared_memory_items, log=None, notifier_pool=None, + poll_fallback=0.05): + self.shared_memory_items = [list(item) for item in shared_memory_items] + self.log = log if log is not None else event_log.NULL_LOG + self.notifier_pool = notifier_pool or NotifierPool() + self.poll_fallback = poll_fallback + self._groups = {} + self._names = set() + self._in_process = {} + self._executors = [] + self._stop = Event() + self._smm = None + self._started = False + + # ------------------------------------------------------------------ + def add(self, hook, executor="main", in_process=False): + """Register a hook, optionally in a named executor. + + Parameters + ---------- + hook: Hook + The hook to run. + executor: str (optional), default='main' + Which executor to run it in. Hooks sharing a name share one. + in_process: bool (optional), default=False + Run this executor as a thread here instead of as a separate + process. Needed for a hook that closes over something unpicklable, + such as a lambda, and for one that has to touch a plotting window, + which a separate process cannot do. See :class:`ThreadExecutor`. + All hooks in a given executor must agree on this. + + Returns + ---------- + ReactiveGraph + Self, so registrations can be chained. + + Examples + --------- + >>> graph.add(FilterHook('filt', 'emg', 'filtered_emg', fi, (2000, 8))) + >>> graph.add(ProbeHook('scope', 'emg', lambda d, s: plot(d)), + ... executor='scope', in_process=True) + """ + if self._started: + raise RuntimeError("Cannot add hooks to a graph that is running.") + if hook.name in self._names: + raise ValueError(f"A hook named '{hook.name}' is already registered.") + previous = self._in_process.get(executor) + if previous is not None and previous != in_process: + raise ValueError( + f"Executor '{executor}' was registered with in_process={previous} " + f"and now with in_process={in_process}. An executor is one thread " + "or one process, not both." + ) + self._in_process[executor] = in_process + self._names.add(hook.name) + self._groups.setdefault(executor, []).append(hook) + return self + + def declare(self, tag, shape, dtype=np.double, lock=None): + """Add a shared-memory item the graph should own. + + Rarely needed: a hook's declared outputs are added for you. + """ + from multiprocessing import Lock as _Lock + if any(item[0] == tag for item in self.shared_memory_items): + return self + self.shared_memory_items.append([tag, shape, dtype, lock or _Lock()]) + return self + + # ------------------------------------------------------------------ + def _collect_outputs(self): + """Create shared-memory items for every hook output not already declared.""" + from multiprocessing import Lock as _Lock + known = {item[0] for item in self.shared_memory_items} + for hooks in self._groups.values(): + for hook in hooks: + for output in hook.outputs: + if output.tag in known: + continue + lock = _Lock() + self.shared_memory_items.append( + [output.tag, output.shape, output.dtype, lock]) + # A count companion keeps the legacy readers of + # "_count" working against a hook-produced item, so a + # derived item is usable anywhere a device item is. + self.shared_memory_items.append( + [output.tag + "_count", (1, 1), np.int32, lock]) + known.add(output.tag) + known.add(output.tag + "_count") + + def _validate(self): + """Reject a graph that cannot run, before anything spawns.""" + produced, consumed = {}, set() + for executor, hooks in self._groups.items(): + for hook in hooks: + for output in hook.outputs: + if output.tag in produced: + raise ValueError( + f"Both '{produced[output.tag]}' and '{hook.name}' write " + f"'{output.tag}'. Two writers to one item would interleave " + "their samples." + ) + produced[output.tag] = hook.name + consumed.update(i.tag for i in hook.inputs) + declared = {item[0] for item in self.shared_memory_items} + missing = sorted(consumed - declared) + if missing: + raise KeyError( + f"These items are observed but never declared or produced: {missing}." + ) + # A cycle would have two stages each waiting for the other. Feedback + # belongs on a separate control item, not in the data cascade. + edges = {} + for hooks in self._groups.values(): + for hook in hooks: + edges[hook.name] = [produced[i.tag] for i in hook.inputs + if i.tag in produced] + self._check_acyclic(edges) + self._check_picklable() + + def _check_picklable(self): + """Fail here, with an explanation, rather than at spawn time. + + A hook bound for a process executor is pickled to get there, and a + hook holding a lambda, a local function or a live handle cannot be. + Left to the platform, that surfaces as a bare PicklingError naming an + anonymous function, from inside multiprocessing, with nothing to say + which hook is at fault or what to do about it. Checking up front costs + one serialisation per hook. + """ + for executor, hooks in self._groups.items(): + if self._in_process.get(executor, False): + # A thread executor shares this interpreter, so nothing is + # serialised and an unpicklable hook is perfectly fine there. + continue + for hook in hooks: + try: + pickle.dumps(hook) + except Exception as error: + raise TypeError( + f"Hook '{hook.name}' in executor '{executor}' cannot be " + f"sent to another process: {error}\n" + "A process executor pickles its hooks to reach them. Either " + "build the unpicklable part in the hook's setup() instead of " + "storing it on the hook, replace a lambda or local function " + "with a module-level one, or register the hook with " + f"in_process=True to run it as a thread here: " + f"graph.add(hook, executor='{executor}', in_process=True)." + ) from error + + @staticmethod + def _check_acyclic(edges): + WHITE, GREY, BLACK = 0, 1, 2 + colour = {node: WHITE for node in edges} + + def visit(node, path): + colour[node] = GREY + for parent in edges.get(node, []): + if colour.get(parent, BLACK) == GREY: + cycle = " -> ".join(path + [parent]) + raise ValueError(f"The hook graph contains a cycle: {cycle}.") + if colour.get(parent, BLACK) == WHITE: + visit(parent, path + [parent]) + colour[node] = BLACK + + for node in list(edges): + if colour[node] == WHITE: + visit(node, [node]) + + # ------------------------------------------------------------------ + def start(self, wait=True): + """Create the graph's items and start every executor. + + Parameters + ---------- + wait: bool (optional), default=True + Block until each executor has attached and subscribed. Without + this, samples committed immediately after ``start()`` can land + before anything is listening. + """ + if self._started: + return self + self._collect_outputs() + self._validate() + self.log.start() + # Hook outputs have to exist before an executor tries to attach to + # them, and they have to be created by a process that outlives the + # executors, so the graph owner creates them. + self._smm = SharedMemoryManager(notifier_pool=self.notifier_pool, log=self.log) + produced = {o.tag for hooks in self._groups.values() + for hook in hooks for o in hook.outputs} + for item in self.shared_memory_items: + base = item[0][:-len("_count")] if item[0].endswith("_count") else item[0] + if base in produced: + self._smm.create_variable(*item) + self._stop.clear() + for name, hooks in self._groups.items(): + slot = self.notifier_pool.claim() + in_process = self._in_process.get(name, False) + factory = ThreadExecutor if in_process else Executor + executor = factory(name, hooks, self.shared_memory_items, + self.notifier_pool, slot, self.log, self._stop, + poll_fallback=self.poll_fallback) + executor.start() + self._executors.append(executor) + self.log.emit(event_log.LIFECYCLE, origin="graph", observer=name, + phase="spawned" if not in_process else "threaded", + slot=slot, hooks=len(hooks)) + self._started = True + if wait: + self._await_subscriptions() + return self + + def _await_subscriptions(self, timeout=10.0): + """Wait until every executor has registered for its inputs.""" + expected = {} + for name, hooks in self._groups.items(): + for hook in hooks: + for spec in hook.inputs: + expected.setdefault(spec.tag, set()).add(name) + if not expected: + return + watcher = SharedMemoryManager(log=self.log) + declared = {item[0]: item for item in self.shared_memory_items} + deadline = time.time() + timeout + for tag in expected: + while not watcher.find_variable(*declared[tag]): + if time.time() > deadline: + return + time.sleep(0.01) + while time.time() < deadline: + if all(len(watcher.subscribers(tag)) >= len(names) + for tag, names in expected.items()): + break + time.sleep(0.005) + watcher.cleanup(parent=False) + + def stop(self, timeout=5.0): + """Ask every executor to finish, then wait for it.""" + if not self._started: + return + self._stop.set() + # An executor blocked in wait_for would otherwise sit there until its + # fallback timeout expired, so nudge every slot awake. + for slot in range(self.notifier_pool.size): + try: + self.notifier_pool.signal(slot) + except Exception: + pass + for executor in self._executors: + executor.join(timeout=timeout) + # A thread cannot be terminated, only asked; it is a daemon, so a + # thread that ignores the request dies with the interpreter. + if executor.is_alive() and hasattr(executor, "terminate"): + executor.terminate() + self._executors = [] + self._started = False + self.log.emit(event_log.LIFECYCLE, origin="graph", phase="stopped") + self.log.stop() + + def __enter__(self): + return self.start() + + def __exit__(self, *exc): + self.stop() + return False + + def describe(self): + """A readable summary of what is hooked to what. + + Returns + ---------- + str + One line per hook, listing its executor, what it observes with + which criterion, and what it writes. + """ + lines = [] + for executor, hooks in self._groups.items(): + lines.append(f"executor '{executor}':") + for hook in hooks: + observes = ", ".join( + f"{i.tag} [{i.criterion.describe()}, {i.mode}" + + (f"({i.size})" if i.size else "") + "]" + for i in hook.inputs) + writes = ", ".join(o.tag for o in hook.outputs) or "-" + lines.append(f" {hook.name}: observes {observes} -> writes {writes}" + + (f" (trigger={hook.trigger})" if len(hook.inputs) > 1 else "")) + return "\n".join(lines) diff --git a/libemg/shared_memory_manager.py b/libemg/shared_memory_manager.py index 75b0cd6a..cf1c1110 100644 --- a/libemg/shared_memory_manager.py +++ b/libemg/shared_memory_manager.py @@ -1,67 +1,697 @@ +import time +from dataclasses import dataclass + import numpy as np +from multiprocessing import Lock from multiprocessing.shared_memory import SharedMemory +from libemg import event_log + + +# ---------------------------------------------------------------------- +# The state block +# +# Every shared-memory variable gets a companion block of int64 counters that +# says what has happened to it. This is what makes a variable "stateful": a +# reader can tell that it changed, and by how much, without copying it. +# +# The block lives in its own shared-memory segment named "__state", and +# it is guarded by the same lock as the variable itself, so a reader can take +# the data and the state as one consistent snapshot. +# +# Fields are addressed by these indices rather than by name because the block +# has to be readable from any process without shipping a schema. +# ---------------------------------------------------------------------- +STATE_FIELDS = 8 +_GENERATION = 0 # bumped once per commit; the thing observers compare against +_TOTAL_SAMPLES = 1 # monotonic count of rows ever committed +_EPOCH = 2 # bumped on reset, so a reader can tell a reset from a wrap +_CLOSED = 3 # the writer is finished; lets a finite source terminate a graph +_COMMITS = 4 # diagnostics: how many commits have landed +_DROPPED = 5 # diagnostics: rows overwritten before somebody read them +_T_LAST_NS = 6 # perf_counter_ns of the newest commit, for latency accounting +_SUBSCRIBERS = 7 # bitmask of notifier slots to wake on commit + +STATE_SUFFIX = "__state" +MAX_SUBSCRIBERS = 63 + + +@dataclass(frozen=True) +class Snapshot: + """What a stateful variable's state block said at one instant. + + Attributes + ---------- + tag: str + The variable this describes. + generation: int + Commits since the variable was created. An observer that has seen + generation N knows nothing has changed while this still reads N. + total_samples: int + Rows ever committed. Criteria that care about volume rather than + change (a window increment, say) compare against this. + epoch: int + Incremented by :meth:`SharedMemoryManager.reset_state`. A change here + means the counters went backwards deliberately and any observer state + derived from them is stale. + closed: bool + The writer has finished and will not commit again. + commits: int + Diagnostic commit count. + dropped: int + Diagnostic count of rows that were overwritten before being read. + t_last_ns: int + ``time.perf_counter_ns()`` at the newest commit, in the writer's + process. + """ + + tag: str + generation: int + total_samples: int + epoch: int + closed: bool + commits: int + dropped: int + t_last_ns: int + + +def assign_shared_memory_locks(shared_memory_items): + """Append a synchronization lock to each shared-memory item in place. + + A modality buffer ```` and its sample counter ``_count`` are + given the *same* lock object so that the (data, count) pair can be read as + a single atomic snapshot (see :meth:`SharedMemoryManager.get_variables`). + Without this, a reader can copy the buffer and its count in two separate + critical sections and observe a count that runs ahead of the data it just + copied, which splices dropped/duplicated samples into logged signals. + + Parameters + ---------- + shared_memory_items : list + A list of ``[tag, shape, dtype]`` items. A lock is appended to each, + shared between a tag and its matching ``_count`` entry. + + Returns + ------- + list + The same list, with a lock appended to every item. + """ + locks = {} + for item in shared_memory_items: + tag = item[0] + base = tag[:-len("_count")] if tag.endswith("_count") else tag + lock = locks.setdefault(base, Lock()) + item.append(lock) + return shared_memory_items + + class SharedMemoryManager: - def __init__(self): + """Attaches to shared-memory variables and tracks what has happened to them. + + Beyond holding the buffers, every variable carries a state block (see + :class:`Snapshot`) recording how many times it has been written and how + many samples have arrived. That state is what lets an observer in another + process tell that a variable changed without copying it, and it is the + foundation the reactive layer in :mod:`libemg.reactive` is built on. + + Parameters + ---------- + notifier_pool: NotifierPool or None (optional), default=None + Used by :meth:`commit` to wake observers in other processes. When + None, commits still update the state block, so an observer that polls + the block still sees the change; it just is not woken. See + :mod:`libemg.reactive`. + log: EventLog or None (optional), default=None + Records every commit and notification. Defaults to no logging. + """ + + def __init__(self, notifier_pool=None, log=None): self.variables = {} + self.state = {} + self.notifier_pool = notifier_pool + self.log = log if log is not None else event_log.NULL_LOG - def create_variable(self, tag, shape, type, lock): + def create_variable(self, tag, shape, type, lock, notifier_pool=None): + if notifier_pool is not None: + self.notifier_pool = notifier_pool if tag in self.variables.keys(): print(f"Already have access to this variable: {tag}") return True + + # if tag exists already + if self.find_variable(tag, shape, type, lock): + print(f'{tag} already exists in shared memory, found variable.') + return True + try: sm = SharedMemory(tag, create=False) sm.unlink() except: pass - - smh = SharedMemory(tag, create=True, size=int(type().itemsize * np.prod(shape))) + + try: + type_size = type().itemsize + except TypeError: + # Passed in non-callable dtype + type_size = type.itemsize + + smh = SharedMemory(tag, create=True, size=int(type_size * np.prod(shape))) data = np.ndarray((shape),dtype=type,buffer=smh.buf) data.fill(0) - self.variables[tag] = {} - self.variables[tag]["data"] = data - self.variables[tag]["shape"] = shape - self.variables[tag]["type"] = type - self.variables[tag]["smh"] = smh - self.variables[tag]["lock"] = lock + self.variables[tag] = { + "data" : data, + "shape": shape, + "type" : type, + "smh" : smh, + "lock" : lock + } + # The creator of a variable is the one that gets a fresh state block, + # so a new session starts at generation zero rather than inheriting + # counters from whatever ran last. + self._attach_state(tag, fresh=True) return True - def find_variable(self, tag, shape, type, lock): + def find_variable(self, tag, shape, type, lock, notifier_pool=None): + if notifier_pool is not None: + self.notifier_pool = notifier_pool try: - smh = SharedMemory(tag, size=int(type().itemsize * np.prod(shape))) + type_size = type().itemsize + except TypeError: + # Passed in non-callable dtype + type_size = type.itemsize + try: + smh = SharedMemory(tag, size=int(type_size * np.prod(shape))) # create a new numpy array that uses the shared memory data = np.ndarray((shape), dtype=type, buffer=smh.buf) - self.variables[tag] = {} - self.variables[tag]["data"] = data - self.variables[tag]["shape"] = shape - self.variables[tag]["type"] = type - self.variables[tag]["smh"] = smh - self.variables[tag]["lock"] = lock + self.variables[tag] = { + "data" : data, + "shape": shape, + "type" : type, + "smh" : smh, + "lock" : lock + } + self._attach_state(tag, fresh=False) return True except FileNotFoundError: return False - + + # ------------------------------------------------------------------ + # state blocks + # ------------------------------------------------------------------ + def _attach_state(self, tag, fresh): + """Attach (creating if needed) the state block belonging to ``tag``. + + Either side of a variable may attach first, and both may race, so this + tries to create and falls back to opening what somebody else created. + ``fresh=True`` additionally discards a block left behind by a previous + run, which is what the creator of the data variable wants. + """ + name = tag + STATE_SUFFIX + size = STATE_FIELDS * np.dtype(np.int64).itemsize + if fresh: + try: + stale = SharedMemory(name, create=False) + stale.close() + stale.unlink() + except Exception: + pass + try: + smh = SharedMemory(name, create=True, size=size) + created = True + except FileExistsError: + smh = SharedMemory(name, create=False) + created = False + block = np.ndarray((STATE_FIELDS,), dtype=np.int64, buffer=smh.buf) + if created: + block[:] = 0 + self.state[tag] = {"block": block, "smh": smh} + return True + + def _block(self, tag): + assert tag in self.state, f"No state block attached for {tag}." + return self.state[tag]["block"] + + def _read_snapshot(self, tag): + """Build a Snapshot. The caller must already hold the variable's lock.""" + block = self._block(tag) + return Snapshot(tag=tag, + generation=int(block[_GENERATION]), + total_samples=int(block[_TOTAL_SAMPLES]), + epoch=int(block[_EPOCH]), + closed=bool(block[_CLOSED]), + commits=int(block[_COMMITS]), + dropped=int(block[_DROPPED]), + t_last_ns=int(block[_T_LAST_NS])) + + def snapshot(self, tag): + """Read a variable's state without copying the variable. + + This is the cheap read the reactive layer uses to decide whether + anything needs doing: a handful of integers under the variable's lock, + rather than a copy of the whole buffer. + + Parameters + ---------- + tag: str + The variable to inspect. + + Returns + ---------- + Snapshot + The state block's contents at the instant it was read. + """ + with self.variables[tag]["lock"]: + return self._read_snapshot(tag) + + def snapshots(self, tags): + """Read several variables' states, each under its own lock. + + Returns + ---------- + dict + Mapping from tag to :class:`Snapshot`. + """ + return {tag: self.snapshot(tag) for tag in tags} + + def reset_state(self, tag): + """Zero a variable's counters and bump its epoch. + + The epoch is what tells an observer that the counters went backwards on + purpose, so it can discard state it derived from the old ones instead + of concluding that nothing has happened for a very long time. + """ + with self.variables[tag]["lock"]: + block = self._block(tag) + epoch = int(block[_EPOCH]) + 1 + subscribers = int(block[_SUBSCRIBERS]) + block[:] = 0 + block[_EPOCH] = epoch + # Subscriptions describe who is listening, not what has happened, + # so a reset must not silently unsubscribe everybody. + block[_SUBSCRIBERS] = subscribers + + def mark_closed(self, tag): + """Record that no further commits will be made to ``tag``. + + A finite source calls this when it runs out of data. Observers use it + to shut down once they have consumed everything, which is what lets an + offline run through a recording terminate rather than wait forever. + """ + with self.variables[tag]["lock"]: + self._block(tag)[_CLOSED] = 1 + self._wake(tag) + self.log.emit(event_log.LIFECYCLE, origin=tag, phase="closed") + + # ------------------------------------------------------------------ + # committing + # ------------------------------------------------------------------ + def commit(self, tag, samples, count_tag=None): + """Write new samples and record that the variable changed. + + This is the write path stateful variables are meant to use. It does + what the streamers' read-modify-write idiom did -- prepend the new rows + so index 0 stays the newest sample -- and additionally advances the + state block and wakes anything subscribed to the variable, all under a + single acquisition of the variable's lock. + + Taking the lock once matters: writing the buffer and its counter + separately lets a reader observe a count that runs ahead of the data, + which splices dropped or duplicated samples into whatever it computes. + + Parameters + ---------- + tag: str + The variable to write to. + samples: numpy.ndarray + The new rows, oldest first, as the device produced them. Pass a 1-D + array for a single sample. + count_tag: str or None (optional), default=None + The legacy sample counter to keep in step. Defaults to + ``tag + "_count"`` when such a variable is attached, so existing + readers of that counter keep working untouched. + + Returns + ---------- + Snapshot + The variable's state after the commit. + """ + samples = np.atleast_2d(samples) + if count_tag is None: + candidate = tag + "_count" + count_tag = candidate if candidate in self.variables else None + + with self.variables[tag]["lock"]: + data = self.variables[tag]["data"] + capacity = data.shape[0] + arriving = samples.shape[0] + # Newest-first is the layout every existing reader expects, so the + # newest arriving row has to end up at index 0. + incoming = np.flip(samples, 0) + if arriving >= capacity: + # More arrived than the buffer can hold: keep the newest. + data[:] = incoming[:capacity] + overwritten = arriving - capacity + else: + data[arriving:] = data[:capacity - arriving] + data[:arriving] = incoming + overwritten = 0 + + block = self._block(tag) + block[_GENERATION] += 1 + block[_TOTAL_SAMPLES] += arriving + block[_COMMITS] += 1 + block[_DROPPED] += overwritten + block[_T_LAST_NS] = time.perf_counter_ns() + if count_tag is not None and count_tag in self.variables: + self.variables[count_tag]["data"][:] += arriving + snapshot = self._read_snapshot(tag) + + # Notifying happens outside the variable's lock on purpose. A waiter + # holds its notifier's lock and then wants this one; if the writer held + # this one and then wanted the notifier's, the two orders would close a + # cycle and deadlock. + self._wake(tag) + if self.log.enabled: + self.log.emit(event_log.COMMIT, origin=tag, + generation=snapshot.generation, + samples=arriving, + total_samples=snapshot.total_samples, + dropped=overwritten) + return snapshot + + def apply(self, tag, fn, count_fn=None, count_tag=None): + """Transform a variable arbitrarily, and record that it changed. + + :meth:`commit` is the write path for arriving samples. This is the one + for everything else: a value that is set rather than appended, a + counter used as a signal, a buffer rewritten by a caller-supplied rule. + It is what :class:`~libemg.output_writer.SharedMemoryOutputWriter` + writes through, which is how an adaptation flag or a slice of + environment feedback becomes something a hook can observe. + + The variable and its counter are updated under a single acquisition of + the variable's lock, then the state block advances and subscribers are + woken. Advancing by the counter's own delta means the sample total + follows whatever rule ``count_fn`` implements, rather than this method + having to guess what a transform did. + + Parameters + ---------- + tag: str + The variable to transform. + fn: callable + Called with the current data; its return value is written back. + count_fn: callable or None (optional), default=None + Called with the current counter; its return value is written back. + When None the counter is left alone and the sample total advances + by one, which is what a flag wants. + count_tag: str or None (optional), default=None + The counter variable. Defaults to ``tag + "_count"`` when one is + attached. + + Returns + ---------- + Snapshot + The variable's state after the change. + """ + if count_tag is None: + candidate = tag + "_count" + count_tag = candidate if candidate in self.variables else None + + with self.variables[tag]["lock"]: + data = self.variables[tag]["data"] + data[:] = fn(data) + added = 1 + if count_fn is not None and count_tag is not None: + counter = self.variables[count_tag]["data"] + before = int(counter.flat[0]) + counter[:] = count_fn(counter) + added = max(0, int(counter.flat[0]) - before) + + block = self._block(tag) + block[_GENERATION] += 1 + block[_TOTAL_SAMPLES] += added + block[_COMMITS] += 1 + block[_T_LAST_NS] = time.perf_counter_ns() + snapshot = self._read_snapshot(tag) + + # Outside the lock, for the ordering reason given in commit(). + self._wake(tag) + if self.log.enabled: + self.log.emit(event_log.COMMIT, origin=tag, + generation=snapshot.generation, samples=added, + total_samples=snapshot.total_samples) + return snapshot + + # ------------------------------------------------------------------ + # subscription and notification + # ------------------------------------------------------------------ + def subscribe(self, tag, slot): + """Ask to be woken when ``tag`` is committed to. + + Parameters + ---------- + tag: str + The variable to watch. + slot: int + A notifier slot from :meth:`NotifierPool.claim`. Slots are recorded + in the variable's state block, so a writer that started before the + observer existed still finds it. + """ + assert 0 <= slot < MAX_SUBSCRIBERS, f"Notifier slot {slot} out of range." + with self.variables[tag]["lock"]: + self._block(tag)[_SUBSCRIBERS] |= np.int64(1) << np.int64(slot) + + def unsubscribe(self, tag, slot): + """Stop being woken when ``tag`` is committed to.""" + with self.variables[tag]["lock"]: + self._block(tag)[_SUBSCRIBERS] &= ~(np.int64(1) << np.int64(slot)) + + def subscribers(self, tag): + """Slots currently subscribed to ``tag``. + + Returns + ---------- + list + The subscribed notifier slot numbers. + """ + with self.variables[tag]["lock"]: + mask = int(self._block(tag)[_SUBSCRIBERS]) + return [s for s in range(MAX_SUBSCRIBERS) if mask & (1 << s)] + + def _wake(self, tag): + """Signal every slot subscribed to ``tag``.""" + if self.notifier_pool is None: + return + with self.variables[tag]["lock"]: + mask = int(self._block(tag)[_SUBSCRIBERS]) + if not mask: + return + for slot in range(MAX_SUBSCRIBERS): + if mask & (1 << slot): + self.notifier_pool.signal(slot) + if self.log.enabled: + self.log.emit(event_log.NOTIFY, origin=tag, + observer=f"slot{slot}") + + # ------------------------------------------------------------------ + # stateful reads + # ------------------------------------------------------------------ + def read_window(self, tag, num_samples, chronological=True): + """Copy the newest ``num_samples`` rows and the state that goes with them. + + This is the read a windowing or model hook wants. Unlike + :meth:`get_variable` the copy is bounded by the window rather than by + the buffer, so it holds the writer's lock for less time and allocates + less. + + Parameters + ---------- + tag: str + The variable to read. + num_samples: int + How many of the newest rows to take. Clamped to the buffer size. + chronological: bool (optional), default=True + Return oldest-first, which is the orientation feature extraction + and models expect. Pass False for the newest-first layout the + buffer itself uses. + + Returns + ---------- + samples: numpy.ndarray + The requested rows. + snapshot: Snapshot + The variable's state at the instant of the read. + """ + with self.variables[tag]["lock"]: + data = self.variables[tag]["data"] + n = int(min(num_samples, data.shape[0])) + window = data[:n].copy() + snapshot = self._read_snapshot(tag) + if chronological: + window = np.flip(window, 0) + return window, snapshot + + def read_since(self, tag, last_total, chronological=True): + """Copy the rows committed since ``last_total`` samples had arrived. + + Parameters + ---------- + tag: str + The variable to read. + last_total: int + The ``total_samples`` value this caller has already consumed to. + chronological: bool (optional), default=True + Return oldest-first. + + Returns + ---------- + samples: numpy.ndarray + The rows that arrived since ``last_total``. Empty if none did. + snapshot: Snapshot + The variable's state at the instant of the read. + lost: int + How many of those rows the buffer had already overwritten, and so + could not be returned. + """ + with self.variables[tag]["lock"]: + data = self.variables[tag]["data"] + snapshot = self._read_snapshot(tag) + new = snapshot.total_samples - int(last_total) + if new <= 0: + return data[:0].copy(), snapshot, 0 + lost = max(0, new - data.shape[0]) + samples = data[:new - lost].copy() + if chronological: + samples = np.flip(samples, 0) + return samples, snapshot, lost def get_variable(self, tag): assert tag in self.variables.keys() with self.variables[tag]["lock"]: return self.variables[tag]["data"].copy() + def get_variables(self, tags): + """Atomically copy several variables as one consistent snapshot. + + Every distinct lock guarding the requested tags is held for the whole + copy, so the returned values reflect the same instant in time. When the + tags share a single lock (the convention established by + :func:`assign_shared_memory_locks` for a ````/``_count`` + pair), that lock is acquired exactly once. + + Parameters + ---------- + tags : list + The shared-memory tags to copy together. + + Returns + ------- + dict + A mapping from each requested tag to a copy of its current data. + """ + for tag in tags: + assert tag in self.variables.keys() + # Collapse duplicate lock objects, then acquire each distinct lock once + # in a deterministic (id-sorted) order to avoid deadlock if a caller + # ever groups tags that don't share a lock. + distinct_locks = {} + for tag in tags: + lock = self.variables[tag]["lock"] + distinct_locks[id(lock)] = lock + ordered_locks = [distinct_locks[key] for key in sorted(distinct_locks.keys())] + acquired = [] + try: + for lock in ordered_locks: + lock.acquire() + acquired.append(lock) + return {tag: self.variables[tag]["data"].copy() for tag in tags} + finally: + for lock in reversed(acquired): + lock.release() + + def get_samples_since(self, tag, last_count, count_tag=None): + """Copy only the samples a writer has added since ``last_count``. + + The counter and the buffer are read under the same lock acquisition as + :meth:`get_variables`, so the returned rows and count describe one + instant. Unlike :meth:`get_variables` the copy is proportional to what + actually arrived rather than to the whole buffer, which matters for a + poller running every few milliseconds: it holds the writer's lock for + less time, and -- because a poller that allocates a fresh copy of the + entire buffer hundreds of times a second is what drives the interpreter + into a garbage collection -- it keeps that thread from being the one + that pays for everyone else's finalizers. + + Parameters + ---------- + tag : str + The buffer to read from. Newest sample first, as written by the + streamers. + last_count : int + The counter value this caller has already consumed up to. + count_tag : str or None + The counter tag. Defaults to ``tag + "_count"``. + + Returns + ------- + count : int + The counter value at the instant of the read. + samples : numpy.ndarray + The rows that arrived since ``last_count``, newest first. Empty when + nothing new arrived. + dropped : int + How many of those samples the buffer had already overwritten, and so + could not be returned. + """ + count_tag = count_tag if count_tag is not None else tag + "_count" + for t in (tag, count_tag): + assert t in self.variables.keys() + distinct_locks = {} + for t in (tag, count_tag): + lock = self.variables[t]["lock"] + distinct_locks[id(lock)] = lock + ordered_locks = [distinct_locks[key] for key in sorted(distinct_locks.keys())] + acquired = [] + try: + for lock in ordered_locks: + lock.acquire() + acquired.append(lock) + data = self.variables[tag]["data"] + count = int(self.variables[count_tag]["data"][0, 0]) + new = count - int(last_count) + if new <= 0: + return count, data[:0].copy(), 0 + dropped = max(0, new - data.shape[0]) + return count, data[:new - dropped].copy(), dropped + finally: + for lock in reversed(acquired): + lock.release() + def modify_variable(self, tag, fn): assert tag in self.variables.keys() with self.variables[tag]["lock"]: self.variables[tag]["data"][:] = fn(self.variables[tag]["data"]) - def cleanup(self, parent = True): for k in self.variables.keys(): self.variables[k]["smh"].close() if parent: self.variables[k]["smh"].unlink() + # State blocks are separate segments, so they leak unless they are + # released alongside the variables they describe. + for k in self.state.keys(): + try: + self.state[k]["smh"].close() + if parent: + self.state[k]["smh"].unlink() + except Exception: + pass self.variables = {} + self.state = {} def get_variable_list(self): - result = [] - for k in self.variables.keys(): - result.append([k, self.variables[k]["shape"], self.variables[k]["type"]]) - return result \ No newline at end of file + return [[k, self.variables[k]["shape"], self.variables[k]["type"]] for k in self.variables.keys()] + + def get_shared_memory_items(self): + return [[i, self.variables[i]["shape"], self.variables[i]["type"], self.variables[i]["lock"]] for i in self.variables] \ No newline at end of file diff --git a/libemg/streamers.py b/libemg/streamers.py index 2543d065..4f72628c 100644 --- a/libemg/streamers.py +++ b/libemg/streamers.py @@ -3,76 +3,268 @@ import pickle import platform import numpy as np -from multiprocessing import Process, Event, Lock +import sifi_bridge_py + +from multiprocessing import Process, Event +from libemg.shared_memory_manager import assign_shared_memory_locks +from libemg.reactive import default_notifier_pool from libemg._streamers._myo_streamer import MyoStreamer from libemg._streamers._delsys_streamer import DelsysEMGStreamer from libemg._streamers._delsys_API_streamer import DelsysAPIStreamer -if platform.system() != 'Linux': - from libemg._streamers._oymotion_windows_streamer import Gforce -else: - from libemg._streamers._oymotion_streamer import OyMotionStreamer +from libemg._streamers._oymotion_streamer import Gforce from libemg._streamers._emager_streamer import EmagerStreamer from libemg._streamers._sifi_bridge_streamer import SiFiBridgeStreamer from libemg._streamers._leap_streamer import LeapStreamer -def sifibridge_streamer(version="1_1", - shared_memory_items = None, - ecg=False, - emg=True, - eda=False, - imu=False, - ppg=False, - notch_on=True, notch_freq=60, - emg_fir_on = True, - emg_fir=[20,450], - eda_cfg = True, - fc_lp = 0, # low pass eda - fc_hp = 5, # high pass eda - freq = 250,# eda sampling frequency - streaming=False, - mac= None): - """The streamer for the sifi armband. - This function connects to the sifi bridge and streams its data to the SharedMemory. This is used - for the SiFi biopoint and bioarmband. - Note that the IMU is acc_x, acc_y, acc_z, quat_w, quat_x, quat_y, quat_z. +def sifi_biopoint_streamer( + name = None, + shared_memory_items = None, + ecg = False, + emg = True, + eda = False, + imu = False, + ppg = False, + temperature = False, + filtering = True, + emg_notch_freq = 60, + emg_bandpass = (20,450), + eda_bandpass = (0,5), + eda_freq = 0, + streaming=True, + night_mode = False, + high_gain = False, + mac= None, + ecg_fs = 500, + emg_fs = 2000, + eda_fs = 50, + imu_fs = 50, + ppg_sps = 50, + ppg_avg = 1, + temperature_fs = 1 +): + """ + The streamer for the SiFi BioPoint. + + This function connects to SiFi Bridge and streams its data to the SharedMemory. + + **Note**: The IMU keys are: + + - Acceleration: ax, ay, az + - Quaternions: qw, qx, qy, qz + + Parameters + ---------- + + device: string, default = BioPoint_v1_3 + The name or MAC of the device. + shared_memory_items, default = [] + The key, size, datatype, and multiprocessing Lock for all data to be shared between processes. + ecg, default = False + Enable electrocardiography recording from the main sensor unit. + emg, default = True + Enable electromyography recording. + eda, default = False + Enable electrodermal recording. + imu, default = False + Enable inertial measurement unit recording + ppg, default = False + The flag to enable photoplethysmography recording + temperature, default = False + The flag to record skin temperature. The device reports temperature in its status packet, so this only controls whether it is stored in shared memory. + filtering, default = True + Enable on-device filtering, including bandpass filters and notch filters. + emg_notch_freq, default = 60 + EMG notch filter frequency, useful for eliminating Mains power interference. Can be {None, 50, 60} Hz. + emg_bandpass, default = (20, 450) + The low and high cutoff frequency of the EMG bandpass filter. + eda_bandpass, default = (0, 5) + The low and high cutoff frequency of the EDA bandpass filter. + eda_freq, default = 0 + The excitation signal frequency for EDA/BIOZ. Setting an AC value may inject a lot of noise into the EMG sensor. + streaming, default = True + Whether to package the modalities together within packets for lower latency (sifibridge's low-latency mode), only supported for BioPoint v1.3 and up. + night_mode, default = False + Turn the device LEDs off during acquisition. + high_gain, default = False + Use more of the ECG/EMG ADC's dynamic range, at the cost of saturating more easily. + mac, default = None: + Optional MAC address the device to connect to, useful when multiple devices are in the vicinity and you want to connect to a specific one. + ecg_fs, default = 500 + The ECG sampling rate (Hz). Can be {250, 500, 1000, 2000}. + emg_fs, default = 2000 + The EMG sampling rate (Hz). Can be {500, 1000, 2000}. + eda_fs, default = 50 + The EDA sampling rate (Hz). Can be {4, 8, 16, 32, 50}. + imu_fs, default = 50 + The IMU sampling rate (Hz). Can be {25, 50, 100, 200}. + ppg_sps, default = 50 + The PPG sampling rate (Hz). Can be {50, 100, 200, 400, 800}. + ppg_avg, default = 1 + The PPG averaging factor. Can be {1, 2, 4, 8, 16, 32}. The effective PPG sampling rate (ppg_sps / ppg_avg) must be <= 400 Hz. + temperature_fs, default = 1 + The temperature sampling rate (Hz). Can be {0.1, 1, 2, 10}. + + Returns + ---------- + + Object: streamer + The sifi streamer process object. + Object: shared memory + The shared memory items list to be passed to the OnlineDataHandler. + + Examples + --------- + + >>> streamer, shared_memory = sifibridge_streamer() + """ + + if shared_memory_items is None: + shared_memory_items = [] + if emg: + shared_memory_items.append(["emg", (4000,1), np.double]) + shared_memory_items.append(["emg_count", (1,1), np.int32]) + if imu: + shared_memory_items.append(["imu", (200,7), np.double]) + shared_memory_items.append(["imu_count", (1,1), np.int32]) + if ecg: + shared_memory_items.append(["ecg", (1000,1), np.double]) + shared_memory_items.append(["ecg_count", (1,1), np.int32]) + if eda: + shared_memory_items.append(["eda", (200,1), np.double]) + shared_memory_items.append(["eda_count", (1,1), np.int32]) + if ppg: + shared_memory_items.append(["ppg", (200,4), np.double]) + shared_memory_items.append(["ppg_count", (1,1), np.int32]) + if temperature: + shared_memory_items.append(["temperature", (100,1), np.double]) + shared_memory_items.append(["temperature_count", (1,1), np.int32]) + + assign_shared_memory_locks(shared_memory_items) + + sb = SiFiBridgeStreamer( + name, + shared_memory_items, + ecg, + emg, + eda, + imu, + ppg, + filtering, + emg_notch_freq, + emg_bandpass, + eda_bandpass, + eda_freq, + streaming, + night_mode, + high_gain, + mac, + ecg_fs=ecg_fs, + emg_fs=emg_fs, + eda_fs=eda_fs, + imu_fs=imu_fs, + ppg_sps=ppg_sps, + ppg_avg=ppg_avg, + temperature_fs=temperature_fs, + bioarmband=False + ) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + sb.notifier_pool = default_notifier_pool() + sb.start() + return sb, shared_memory_items + + +def sifi_bioarmband_streamer( + name = None, + shared_memory_items = None, + ecg = False, + emg = True, + eda = False, + imu = False, + ppg = False, + temperature = False, + filtering = True, + emg_notch_freq = 60, + emg_bandpass = (20,450), + eda_bandpass = (0,5), + eda_freq = 0, + streaming = True, + night_mode = False, + high_gain = False, + mac = None, + ecg_fs = 500, + emg_fs = 1600, + eda_fs = 50, + imu_fs = 50, + ppg_sps = 50, + ppg_avg = 1, + temperature_fs = 1 +): + """ + The streamer for the SiFi BioArmband. + + This function connects to SiFi Bridge and streams its data to the SharedMemory. + + **Note**: The IMU keys are: + + - Acceleration: ax, ay, az + - Quaternions: qw, qx, qy, qz + Parameters ---------- - version: string (option), default = '1_1' - The version for the sifi streamer. + + name: string, default = BioArmband + The name of the Sifi Device. For example: BioArmband, BioPoint_v1_3, etc. shared_memory_items, default = [] The key, size, datatype, and multiprocessing Lock for all data to be shared between processes. ecg, default = False - The flag to enable electrocardiography recording from the main sensor unit. + Enable electrocardiography recording from the main sensor unit. emg, default = True - The flag to enable electromyography recording. + Enable electromyography recording. eda, default = False - The flag to enable electrodermal recording. + Enable electrodermal recording. imu, default = False - The flag to enable inertial measurement unit recording + Enable inertial measurement unit recording ppg, default = False The flag to enable photoplethysmography recording - notch_on, default = True - The flag to enable a fc Hz notch filter on device (firmware). - notch_freq, default = 60 - The cutoff frequency of the notch filter specified by notch_on. - emg_fir_on, default = True - The flag to enable a bandpass filter on device (firmware). - emg_fir, default = [20, 450] - The low and high cutoff frequency of the bandpass filter specified by emg_fir_on. - eda_cfg, default = True - The flag to specify if using high or low frequency current for EDA or bioimpedance. - fc_lp, default = 0 - The low cutoff frequency for the bioimpedance. - fc_hp, default = 5 - The high cutoff frequency for the bioimpedance. - freq, default = 250 - The sampling frequency for bioimpedance. - streaming, default = False - Whether to package the modalities together within packets for lower latency. - mac, default = None: - mac address of the device to be connected to + temperature, default = False + The flag to record skin temperature. The device reports temperature in its status packet, so this only controls whether it is stored in shared memory. + filtering, default = True + Enable on-device filtering, including bandpass filters and notch filters. + emg_notch_freq, default = 60 + EMG notch filter frequency, useful for eliminating Mains power interference. Can be {None, 50, 60} Hz. + emg_bandpass, default = (20, 450) + The low and high cutoff frequency of the EMG bandpass filter. + eda_bandpass, default = (0, 5) + The low and high cutoff frequency of the EDA bandpass filter. + eda_freq, default = 0 + The excitation signal frequency for EDA/BIOZ. Setting an AC value may inject a lot of noise into the EMG sensor. + streaming, default = True + Whether to package the modalities together within packets for lower latency (sifibridge's low-latency mode), only supported for BioPoint v1.3 and up. + night_mode, default = False + Turn the device LEDs off during acquisition. + high_gain, default = False + Use more of the ECG/EMG ADC's dynamic range, at the cost of saturating more easily. + mac, default = None: + Optional MAC address the device to connect to, useful when multiple devices are in the vicinity and you want to connect to a specific one. + ecg_fs, default = 500 + The ECG sampling rate (Hz). Can be {250, 500, 1000, 2000}. + emg_fs, default = 1600 + The EMG sampling rate (Hz). Can be {500, 1000, 1600, 2000}. + eda_fs, default = 50 + The EDA sampling rate (Hz). Can be {4, 8, 16, 32, 50}. + imu_fs, default = 50 + The IMU sampling rate (Hz). Can be {25, 50, 100, 200}. + ppg_sps, default = 50 + The PPG sampling rate (Hz). Can be {50, 100, 200, 400, 800}. + ppg_avg, default = 1 + The PPG averaging factor. Can be {1, 2, 4, 8, 16, 32}. The effective PPG sampling rate (ppg_sps / ppg_avg) must be <= 400 Hz. + temperature_fs, default = 1 + The temperature sampling rate (Hz). Can be {0.1, 1, 2, 10}. + Returns ---------- + Object: streamer The sifi streamer process object. Object: shared memory @@ -80,6 +272,7 @@ def sifibridge_streamer(version="1_1", Examples --------- + >>> streamer, shared_memory = sifibridge_streamer() """ @@ -89,40 +282,90 @@ def sifibridge_streamer(version="1_1", shared_memory_items.append(["emg", (3000,8), np.double]) shared_memory_items.append(["emg_count", (1,1), np.int32]) if imu: - shared_memory_items.append(["imu", (100,10), np.double]) + shared_memory_items.append(["imu", (200,7), np.double]) shared_memory_items.append(["imu_count", (1,1), np.int32]) if ecg: - shared_memory_items.append(["ecg", (100,10), np.double]) + shared_memory_items.append(["ecg", (1000,1), np.double]) shared_memory_items.append(["ecg_count", (1,1), np.int32]) if eda: - shared_memory_items.append(["eda", (100,10), np.double]) + shared_memory_items.append(["eda", (200,1), np.double]) shared_memory_items.append(["eda_count", (1,1), np.int32]) if ppg: - shared_memory_items.append(["ppg", (100,10), np.double]) + shared_memory_items.append(["ppg", (200,4), np.double]) shared_memory_items.append(["ppg_count", (1,1), np.int32]) + if temperature: + shared_memory_items.append(["temperature", (100,1), np.double]) + shared_memory_items.append(["temperature_count", (1,1), np.int32]) + + assign_shared_memory_locks(shared_memory_items) - for item in shared_memory_items: - item.append(Lock()) - sb = SiFiBridgeStreamer(version=version, - shared_memory_items=shared_memory_items, - notch_on=notch_on, - ecg=ecg, - emg=emg, - eda=eda, - imu=imu, - ppg=ppg, - notch_freq=notch_freq, - emgfir_on=emg_fir_on, - emg_fir = emg_fir, - eda_cfg = eda_cfg, - fc_lp = fc_lp, # low pass eda - fc_hp = fc_hp, # high pass eda - freq = freq,# eda sampling frequency - streaming=streaming, - mac = mac) + + sb = SiFiBridgeStreamer( + name, + shared_memory_items, + ecg, + emg, + eda, + imu, + ppg, + filtering, + emg_notch_freq, + emg_bandpass, + eda_bandpass, + eda_freq, + streaming, + night_mode, + high_gain, + mac, + ecg_fs=ecg_fs, + emg_fs=emg_fs, + eda_fs=eda_fs, + imu_fs=imu_fs, + ppg_sps=ppg_sps, + ppg_avg=ppg_avg, + temperature_fs=temperature_fs, + bioarmband=True + ) + + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + sb.notifier_pool = default_notifier_pool() sb.start() return sb, shared_memory_items + +def mock_emg_stream(file_path, num_channels, sampling_rate=100, port=12345, ip="127.0.0.1"): + """Stream EMG samples from a CSV file over UDP in a background process.""" + Process( + target=_stream_mock_emg, + args=(file_path, num_channels, sampling_rate, port, ip), + daemon=True, + ).start() + + +def _stream_mock_emg(file_path, num_channels, sampling_rate, port, ip): + if sampling_rate <= 0: + raise ValueError("sampling_rate must be greater than zero.") + + data = np.loadtxt(file_path, delimiter=",") + if data.ndim == 1: + data = data[np.newaxis, :] + if not 0 < num_channels <= data.shape[1]: + raise ValueError( + f"num_channels must be between 1 and {data.shape[1]}, got {num_channels}." + ) + + interval = 1 / sampling_rate + next_sample_time = time.perf_counter() + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + for sample in data[:, :num_channels]: + next_sample_time += interval + delay = next_sample_time - time.perf_counter() + if delay > 0: + time.sleep(delay) + sock.sendto(pickle.dumps(list(sample)), (ip, port)) + + def myo_streamer( shared_memory_items : list | None = None, emg : bool = True, @@ -163,9 +406,11 @@ def myo_streamer( shared_memory_items.append(["imu", (250,10), np.double]) shared_memory_items.append(["imu_count", (1,1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) myo = MyoStreamer(filtered, emg, imu, shared_memory_items) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + myo.notifier_pool = default_notifier_pool() myo.start() return myo, shared_memory_items @@ -203,7 +448,7 @@ def delsys_streamer(shared_memory_items : list | None = None, Returns ---------- Object: streamer - The sifi streamer object. + The delsys streamer object. Object: shared memory The shared memory object. Examples @@ -218,8 +463,7 @@ def delsys_streamer(shared_memory_items : list | None = None, if imu: shared_memory_items.append(["imu", (500,6), np.double]) shared_memory_items.append(["imu_count", (1,1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) delsys = DelsysEMGStreamer(shared_memory_items=shared_memory_items, emg=emg, @@ -230,13 +474,16 @@ def delsys_streamer(shared_memory_items : list | None = None, aux_port=aux_port, channel_list=channel_list, timeout=timeout) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + delsys.notifier_pool = default_notifier_pool() delsys.start() return delsys, shared_memory_items -def delsys_api_streamer(license : str = None, - key : str = None, - num_channels : int = None, +def delsys_api_streamer(license : str, + key : str, + num_channels : int | None = None, dll_folder : str = 'resources/', shared_memory_items : list | None = None, emg : bool = True): @@ -252,7 +499,7 @@ def delsys_api_streamer(license : str = None, key : str Delsys key num_channels: int - The number of delsys sensors you are using. + The number of delsys sensors you are using. Not used if shared_memory_items is passed, otherwise is a required parameter. dll_folder: string : optional (default='resources/') The location of the DLL files installed from the Delsys Github. shared_memory_items : list (optional) @@ -263,24 +510,25 @@ def delsys_api_streamer(license : str = None, Returns ---------- Object: streamer - The sifi streamer object. + The delsys streamer object. Object: shared memory The shared memory object. Examples --------- - >>> streamer, shared_memory = delsys_streamer() + >>> streamer, shared_memory = delsys_api_streamer(LICENSE, KEY, num_channels=4) """ - assert license is not None - assert key is not None if shared_memory_items is None: + assert num_channels is not None, f"No shared memory items were passed, so num_channels must be set. Please set num_channels to the number of Delsys sensors. Got: {num_channels}." shared_memory_items = [] if emg: shared_memory_items.append(["emg", (5300,num_channels), np.double]) shared_memory_items.append(["emg_count", (1,1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) delsys = DelsysAPIStreamer(key, license, dll_folder, shared_memory_items=shared_memory_items, emg=emg) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + delsys.notifier_pool = default_notifier_pool() delsys.start() return delsys, shared_memory_items @@ -308,7 +556,7 @@ def oymotion_streamer(shared_memory_items : list | None = None, Returns ---------- Object: streamer - The sifi streamer object + The oymotion streamer object Object: shared memory The shared memory object Examples @@ -331,20 +579,14 @@ def oymotion_streamer(shared_memory_items : list | None = None, if imu: shared_memory_items.append(["imu", (100,10), np.double]) shared_memory_items.append(["imu_count", (1,1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) - operating_system = platform.system().lower() - - # I'm only addressing this atm. - if operating_system == "windows" or operating_system == 'mac': - oym = Gforce(sampling_rate, res, emg, imu, shared_memory_items) - oym.start() - else: - # This has not been updated to the new memory manager methods. - # oym = OyMotionStreamer(ip, port, sampRate=sampling, resolution=res) - # oym.start_stream() - raise Exception("Oymotion Streamer is not implemented for Linux.") + oym = Gforce(sampling_rate, res, emg, imu, shared_memory_items) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + oym.notifier_pool = default_notifier_pool() + oym.start() + return oym, shared_memory_items @@ -362,7 +604,7 @@ def emager_streamer(shared_memory_items = None): Returns ---------- Object: streamer - The sifi streamer object. + The emager streamer object. Object: shared memory The shared memory object. Examples @@ -375,9 +617,11 @@ def emager_streamer(shared_memory_items = None): shared_memory_items.append(['emg', (2000, 64), np.double]) # buffer size doesn't have a huge effect - pretty much as long as it's bigger than window size shared_memory_items.append(['emg_count', (1, 1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) ema = EmagerStreamer(shared_memory_items) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + ema.notifier_pool = default_notifier_pool() ema.start() return ema, shared_memory_items @@ -523,9 +767,11 @@ def leap_streamer(shared_memory_items : list | None =None, shared_memory_items.append(['finger_width', (230,3), np.double]) shared_memory_items.append(['finger_width_count', (1,1), np.int32]) - for item in shared_memory_items: - item.append(Lock()) + assign_shared_memory_locks(shared_memory_items) ls = LeapStreamer(shared_memory_items) + # Inherited by the child process: a commit can only wake an observer that + # holds the same pool, and a pool cannot be looked up by name. + ls.notifier_pool = default_notifier_pool() ls.start() return ls, shared_memory_items diff --git a/libemg/utils.py b/libemg/utils.py index 765beb29..bae48681 100644 --- a/libemg/utils.py +++ b/libemg/utils.py @@ -1,3 +1,4 @@ +import gc import os import numpy as np @@ -7,6 +8,45 @@ from matplotlib.patches import Circle +def _release_interactive_plot(show): + """Run something that opens an interactive plot, and dispose of it here. + + An interactive matplotlib window is a pile of Tk widgets -- around twenty + ``tkinter.Variable`` and ``tkinter.PhotoImage`` objects per figure, most of + them the toolbar. Closing the window does not finalize them: they become + ordinary garbage, freed whenever some thread next triggers a collection. If + that thread is not the one running the Tk main loop, every single ``__del__`` + marshals its Tcl call to the main loop, waits a full second for a loop that + has already exited, then gives up with ``RuntimeError: main thread is not in + main loop``. Twenty objects is twenty seconds of that thread stopped dead -- + long enough for a shared-memory logger to miss an entire recording, since + what the device produces meanwhile is overwritten before it can be read. + + Giving the plot a call frame of its own is what makes the cleanup possible. + The figure and every closure over it die with that frame, so by the time + ``show`` has returned there is nothing left holding the window together and + the collection below finalizes all of it here, on the thread that owns the + Tk main loop, where the calls are direct and immediate. + + Parameters + ---------- + show: callable + Builds the figure, shows it, and closes it (``matplotlib.pyplot.close``) + before returning. It must not hand the figure back or store it on + anything that outlives the call, or the window survives the collection + and is left for a worker thread to trip over. + + Returns + ---------- + result: object + Whatever ``show`` returned. + """ + try: + return show() + finally: + gc.collect() + + def get_windows(data, window_size, window_increment): """Extracts windows from a given set of data. @@ -30,7 +70,18 @@ def get_windows(data, window_size, window_increment): >>> data = np.loadtxt('data.csv', delimiter=',') >>> windows = get_windows(data, 100, 50) """ - num_windows = int((data.shape[0]-window_size)/window_increment) + 1 + # Floor-divide and clamp: int() truncates toward zero, so data shorter + # than one window gave a negative quotient that truncated to 0 and a + # count of 1 -- a short window handed downstream as though it were + # window_size long, so features were wrong rather than absent, and the + # odd shape later broke np.vstack in _parse_windows_helper. + num_windows = max(0, (data.shape[0] - window_size) // window_increment + 1) + if num_windows == 0: + # Correctly *shaped* empty result rather than np.array([]) (shape + # (0,)), so callers can still read .shape[1]/.shape[2] and stack it + # with np.vstack/np.concatenate without special-casing. + num_channels = 1 if data.ndim == 1 else data.shape[1] + return np.zeros((0, num_channels, window_size), dtype=data.dtype) windows = [] st_id=0 ed_id=st_id+window_size @@ -62,7 +113,7 @@ def _get_fn_windows(data, window_size, window_increment, fn): fn_of_windows = np.apply_along_axis(lambda x: fn(x), axis=2, arr=windows) return fn_of_windows.squeeze() -def make_regex(left_bound, right_bound, values=[]): +def make_regex(left_bound, right_bound, values = None): """Regex creation helper for the data handler. The OfflineDataHandler relies on regexes to parse the file/folder structures and extract data. @@ -74,8 +125,8 @@ def make_regex(left_bound, right_bound, values=[]): The left bound of the regex. right_bound: string The right bound of the regex. - values: list - The values between the two regexes. + values: list or None (optional), default = None + The values between the two regexes. If None, will try to find the values using a wildcard. Defaults to None. Returns ---------- @@ -87,10 +138,16 @@ def make_regex(left_bound, right_bound, values=[]): >>> make_regex(left_bound = "_C_", right_bound="_EMG.csv", values = [0,1,2,3,4,5]) """ left_bound_str = "(?<="+ left_bound +")" - mid_str = "(?:" - for i in values: - mid_str += i + "|" - mid_str = mid_str[:-1] - mid_str += ")" + + if values is None: + # Apply wildcard + mid_str = '(.*?)' + else: + mid_str = "(?:" + for i in values: + mid_str += i + "|" + mid_str = mid_str[:-1] + mid_str += ")" + right_bound_str = "(?=" + right_bound +")" return left_bound_str + mid_str + right_bound_str diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7199a11e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[project] +name = "libemg" +version = "2.0.0" +description = "LibEMG - Myoelectric Control Library" +readme = "README.md" +requires-python = ">=3.13" +# Runtime only. Test and documentation tooling live in [dependency-groups] +# below: none of it is imported by the library, and declaring it here made +# every downstream project inherit an exact pytest/sphinx pin, which collides +# with whatever test runner that project already uses. +# +# Floors are deliberately conservative. They were previously generated from +# whichever versions happened to be installed, which pinned consumers to +# near-latest releases for no reason -- requests>=2.34.2 alone was enough to +# make libemg uninstallable alongside label-studio. requires-python >=3.13 +# already rules out genuinely old releases, so a low floor here cannot resolve +# to one that predates Python 3.13 support. +# +# websockets was pinned to ==8.1, which cannot run here at all: 8.1 passes +# loop= to asyncio.Lock, removed in Python 3.10, so importing _leap_streamer +# and connecting raised TypeError on every supported interpreter. 14.2 and +# 16.0 both run _leap_streamer's `await client.connect(...)` / `async for` +# unchanged, so the floor is >=14. +dependencies = [ + "bleak>=0.21", + "datetime>=4.3", + "dearpygui>=1.9", + "h5py>=3.8", + "librosa>=0.10", + "matplotlib>=3.7", + "numpy>=1.24", + "onedrivedownloader>=1.1", + "opencv-python>=4.8", + "pillow>=10.0", + "pygame>=2.5", + "pyomyo==0.0.5", + "pyserial>=3.5", + "pywavelets>=1.4", + "requests>=2.31", + "scikit-learn>=1.3", + "scipy>=1.11", + "sifi-bridge-py==2.0.0b19", + "websockets>=14", + "wfdb>=4.1", +] + +[project.optional-dependencies] +# _delsys_API_streamer imports pythonnet lazily, inside a try, so only users of +# the Delsys API streamer need the .NET runtime bindings. +delsys = ["pythonnet>=3.0"] + +[dependency-groups] +dev = [ + "pytest>=7.1.3", + "pytest-cov>=4.0.0", + "pytest-skip-slow>=0.0.3", +] +docs = [ + "linkify-it-py>=2.0.0", + "myst-parser>=4.0.0", + "sphinx>=8.1.3", + "sphinx-rtd-theme>=3.0.2", +] diff --git a/requirements.txt b/requirements.txt index 619df973..4970fe3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,30 @@ -numpy<2 -scipy==1.7.2 -pytest==7.1.3 -pytest-cov==4.0.0 -scikit-learn -pillow -matplotlib==3.5.0 -librosa==0.9.2 -pyomyo==0.0.5 -pyserial -wfdb -bleak -semantic-version -requests -# For Docs -sphinx==5.0.0 -sphinx_rtd_theme==1.0.0 -myst_parser==0.18.1 -linkify-it-py==2.0.0 -# For testing -pytest-skip-slow==0.0.3 -dearpygui -# For oymotion - only on linx -bluepy -PyWavelets==1.4.1 -# for new sgt -dearpygui -opencv-python -datetime -websockets==8.1 +scipy +pytest==7.1.3 +pytest-cov==4.0.0 +scikit-learn +pillow +matplotlib +librosa +pyomyo==0.0.5 +pyserial +wfdb +bleak +requests +h5py +# For Docs +sphinx>=8.1.3 +sphinx_rtd_theme>=3.0.2 +myst_parser>=4.0.0 +linkify-it-py==2.0.0 +# For testing +pytest-skip-slow==0.0.3 +dearpygui +PyWavelets +# for new sgt +dearpygui +opencv-python +datetime +websockets>=14 +h5py +onedrivedownloader +sifi-bridge-py==2.0.0b19 \ No newline at end of file diff --git a/setup.py b/setup.py index 436f1b7d..a3118155 100644 --- a/setup.py +++ b/setup.py @@ -1,70 +1,73 @@ -from setuptools import setup, find_packages -import os -import codecs - -here = os.path.abspath(os.path.dirname(__file__)) - -with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: - long_description = "\n" + fh.read() - -# To release: -# python setup.py sdist -# python -m twine upload --repository testpypi dist/* --verbose <------ testpypi -# - -VERSION = "1.0.0" -DESCRIPTION = "LibEMG - Myoelectric Control Library" -LONG_DESCRIPTION = "A library for designing and exploring real-time and offline myoelectric control systems." - -setup( - name="libemg", - version=VERSION, - author="Ethan Eddy, Evan Campbell, Angkoon Phinyomark, Scott Bateman, and Erik Scheme", - description=DESCRIPTION, - packages=find_packages(exclude=["*tests*"]), - long_description_content_type="text/markdown", - long_description=long_description, - install_requires=[ - "numpy<2.0", - "scipy", - "scikit-learn", - "pillow", - "matplotlib", - "librosa", - "wfdb", - "pyserial", - "PyWavelets", - "requests", - "semantic-version", - "websockets", - "opencv-python", - "pythonnet", - "bleak", - "dearpygui" - ], - keywords=[ - "emg", - "myoelectric_control", - "pattern_recognition", - "muscle-based input", - ], - classifiers=[ - "Development Status :: 5 - Production/Stable ", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Operating System :: Unix", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - ], -) - - -# In order to push to pypi we first need to build the binaries -# navigate to the project folder in the cmd -# run: -# python setup.py sdist bdist_wheel -# you should have binaries specific to the new version specified in the setup.py file -# if you have other version binaries in the /dist folder, delete them. -# now to actually upload it to pypi, you need twine (pip install twine if you don't have it) -# now run: -# twine upload dist/* +from setuptools import setup, find_packages +import os +import codecs + +here = os.path.abspath(os.path.dirname(__file__)) + +with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh: + long_description = "\n" + fh.read() + +# To release: +# python setup.py sdist +# python -m twine upload --repository testpypi dist/* --verbose <------ testpypi +# + +VERSION = "2.0.0" +DESCRIPTION = "LibEMG - Myoelectric Control Library" +LONG_DESCRIPTION = "A library for designing and exploring real-time and offline myoelectric control systems." + +setup( + name="libemg", + version=VERSION, + author="Ethan Eddy, Evan Campbell, Angkoon Phinyomark, Scott Bateman, and Erik Scheme", + description=DESCRIPTION, + packages=find_packages(exclude=["*tests*"]), + long_description_content_type="text/markdown", + long_description=long_description, + install_requires=[ + "numpy", + "scipy", + "scikit-learn", + "pillow", + "matplotlib", + "librosa", + "wfdb", + "pyserial", + "PyWavelets", + "requests", + "websockets>=14", + "opencv-python", + "pythonnet", + "bleak", + "dearpygui", + "h5py", + "onedrivedownloader", + "sifi-bridge-py==2.0.0b19", + "pygame", + ], + keywords=[ + "emg", + "myoelectric_control", + "pattern_recognition", + "muscle-based input", + ], + classifiers=[ + "Development Status :: 5 - Production/Stable ", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Operating System :: Unix", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + ], +) + + +# In order to push to pypi we first need to build the binaries +# navigate to the project folder in the cmd +# run: +# python setup.py sdist bdist_wheel +# you should have binaries specific to the new version specified in the setup.py file +# if you have other version binaries in the /dist folder, delete them. +# now to actually upload it to pypi, you need twine (pip install twine if you don't have it) +# now run: +# twine upload dist/* diff --git a/sifi_bridge_windows.exe b/sifi_bridge_windows.exe deleted file mode 100644 index 9f02a93e..00000000 Binary files a/sifi_bridge_windows.exe and /dev/null differ diff --git a/tests/test_offline_metrics.py b/tests/test_offline_metrics.py index a3d833c2..27cb7566 100644 --- a/tests/test_offline_metrics.py +++ b/tests/test_offline_metrics.py @@ -1,6 +1,5 @@ -import pytest -import pickle -import numpy as np +import pytest +import numpy as np from sklearn.metrics import * from libemg.offline_metrics import OfflineMetrics @@ -13,14 +12,12 @@ def om(): return OfflineMetrics() @pytest.fixture(scope='session') -def y_true(): - file = open('tests/data/test_labels','rb') - return pickle.load(file) +def y_true(): + return np.loadtxt('tests/data/test_labels.fixture') @pytest.fixture(scope='session') -def y_predictions(): - file = open('tests/data/predictions','rb') - return pickle.load(file) +def y_predictions(): + return np.loadtxt('tests/data/predictions.fixture', dtype=np.int32) def test_CA(om, y_true, y_predictions): assert om.get_CA(y_true, y_predictions) == accuracy_score(y_true, y_predictions) @@ -56,4 +53,18 @@ def test_PREC(om, y_true, y_predictions): def test_F1(om, y_true, y_predictions): # Assuming there is a rounding error - assert om.get_F1(y_true, y_predictions) - f1_score(y_true, y_predictions, average='weighted') < 0.0000000001 \ No newline at end of file + assert om.get_F1(y_true, y_predictions) - f1_score(y_true, y_predictions, average='weighted') < 0.0000000001 + +def test_REMOVE(om): + preds = np.array([0,1,-1,-1,2,2,0,0,-1]) + labels = np.array([0,1,0,0,2,2,0,0,2]) + preds, labels = om._ignore_rejected(preds, labels) + assert np.all(preds == np.array([0,1,2,2,0,0])) + assert np.all(labels == np.array([0,1,2,2,0,0])) + +def test_REMOVE2(om): + preds = np.array([0,1,2,3,4,5,6,7,8,9,0]) + labels = np.array([0,1,2,3,4,5,6,7,8,9,0]) + preds2, labels2 = om._ignore_rejected(preds, labels) + assert np.all(preds2 == preds) + assert np.all(labels2 == labels) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..2e290414 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2548 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "audioread" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "standard-aifc" }, + { name = "standard-sunau" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/4a/874ecf9b472f998130c2b5e145dcdb9f6131e84786111489103b66772143/audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190", size = 20082, upload-time = "2025-10-26T19:44:13.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[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/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { 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 = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[[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 = "datetime" +version = "6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/32/decbfd165e9985ba9d8c2d34a39afe5aeba2fc3fe390eb6e9ef1aab98fa8/datetime-6.0.tar.gz", hash = "sha256:c1514936d2f901e10c8e08d83bf04e6c9dbd7ca4f244da94fec980980a3bc4d5", size = 64167, upload-time = "2025-11-25T08:00:34.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/7a/ea0f3e3ea74be36fc7cf54f966cde732a3de72697983cdb5646b0a4dacde/datetime-6.0-py3-none-any.whl", hash = "sha256:d19988f0657a4e72c9438344157254a8dcad6aea8cd5ae70a5d1b5a75e5dc930", size = 52637, upload-time = "2025-11-25T08:00:33.077Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" }, + { url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" }, + { url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" }, +] + +[[package]] +name = "dearpygui" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/db/56a293ad392b8dafdd3eb1a0c4d367efbde99aa6930359685fe270984dd1/dearpygui-2.3.1-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:d2d29e72b031886893b32269e1478eff1fd651512e6c2bceda91059c3bc8ee0e", size = 2002694, upload-time = "2026-05-01T22:46:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/df598671f40a7fffb8390946115d9e9cf12d3f4a52f5b9381bddc5699aee/dearpygui-2.3.1-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:e3f64c8d4cae68a3b5be1b7531d08400ccbf1e4ff03c7acfc85954f98993f864", size = 2678564, upload-time = "2026-05-01T22:46:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d1/87cfb577d4a1615e809e2f6e0fd720bd4ad6cd25e365ef2fd08a6e09c86e/dearpygui-2.3.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:0d8b7a5a04cd4a6e25dbf162aa0405cf12143a1c82b33e371c20e5ba8d9349f3", size = 2503196, upload-time = "2026-05-01T22:46:20.276Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/05f74181a59353bdc927b6ec46333714f633c5755f3d4bcda34d6eb100a5/dearpygui-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:7946722c50f1e08866dfdb78eb33dd4e67dea1a3cfe4400b159e1c96861116dc", size = 1882082, upload-time = "2026-05-01T22:45:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/4a/08/68ce3ba941cf09bcf288f2603d7178c936d473172fd545e0665b885cb840/dearpygui-2.3.1-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:e2cbabd8d383308ea3520360e100d1e95c9a957cd26247431cd4920b23c3b56e", size = 2003034, upload-time = "2026-05-01T22:46:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3f/ac003b6d636c9f5aae910a62d00b6c119403212cf2d7772d7c59ca8ef6dd/dearpygui-2.3.1-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:953442c7272e57f3686e393edc5cf6ce73cc2f47142739735dfabed403e33770", size = 2678545, upload-time = "2026-05-01T22:46:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/657a2f1e2f604f356c39ed5b870aaed5d07fdeac3139fae2a455473b64ca/dearpygui-2.3.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:43a561b5dc589944a3a2b469e5e68f1ab35ae38b3dcdf1f813ed9d6e024153f8", size = 2501699, upload-time = "2026-05-01T22:46:21.497Z" }, + { url = "https://files.pythonhosted.org/packages/8c/56/93b2310891589063e0226cb07fe05e08fbdb3f409f80f9edefb1cebea84e/dearpygui-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:272cc52c66bc5a4a332f13a3af5e603070d47ca7cc4976e6a8c247594e2b3dd0", size = 1943067, upload-time = "2026-05-01T22:45:52.255Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[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/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { 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 = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/7fcd9b4c9eed82e91fb15568992561019ae7a829d1f696b2c844355d95dd/h5py-3.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9c9d307c0ef862d1cd5714f72ecfafe0a5d7529c44845afa8de9f46e5ba8bd65", size = 3678608, upload-time = "2026-03-06T13:48:35.183Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b7/9366ed44ced9b7ef357ab48c94205280276db9d7f064aa3012a97227e966/h5py-3.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c1eff849cdd53cbc73c214c30ebdb6f1bb8b64790b4b4fc36acdb5e43570210", size = 3054773, upload-time = "2026-03-06T13:48:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/4964bc0e91e86340c2bbda83420225b2f770dcf1eb8a39464871ad769436/h5py-3.16.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e2c04d129f180019e216ee5f9c40b78a418634091c8782e1f723a6ca3658b965", size = 5198886, upload-time = "2026-03-06T13:48:38.879Z" }, + { url = "https://files.pythonhosted.org/packages/f1/16/d905e7f53e661ce2c24686c38048d8e2b750ffc4350009d41c4e6c6c9826/h5py-3.16.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e4360f15875a532bc7b98196c7592ed4fc92672a57c0a621355961cafb17a6dd", size = 5404883, upload-time = "2026-03-06T13:48:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/58f34cb74af46d39f4cd18ea20909a8514960c5a3e5b92fd06a28161e0a8/h5py-3.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3fae9197390c325e62e0a1aa977f2f62d994aa87aab182abbea85479b791197c", size = 5192039, upload-time = "2026-03-06T13:48:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ca/934a39c24ce2e2db017268c08da0537c20fa0be7e1549be3e977313fc8f5/h5py-3.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43259303989ac8adacc9986695b31e35dba6fd1e297ff9c6a04b7da5542139cc", size = 5421526, upload-time = "2026-03-06T13:48:44.838Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/615a450205e1b56d16c6783f5ccd116cde05550faad70ae077c955654a75/h5py-3.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa48993a0b799737ba7fd21e2350fa0a60701e58180fae9f2de834bc39a147ab", size = 3183263, upload-time = "2026-03-06T13:48:47.117Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/a6faef5ed632cae0c65ac6b214a6614a0b510c3183532c521bdb0055e117/h5py-3.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:1897a771a7f40d05c262fc8f37376ec37873218544b70216872876c627640f63", size = 2663450, upload-time = "2026-03-06T13:48:48.707Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/0c8bb8aedb62c772cf7c1d427c7d1951477e8c2835f872bc0a13d1f85f86/h5py-3.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15922e485844f77c0b9d275396d435db3baa58292a9c2176a386e072e0cf2491", size = 3760693, upload-time = "2026-03-06T13:48:50.453Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1f/fcc5977d32d6387c5c9a694afee716a5e20658ac08b3ff24fdec79fb05f2/h5py-3.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:df02dd29bd247f98674634dfe41f89fd7c16ba3d7de8695ec958f58404a4e618", size = 3181305, upload-time = "2026-03-06T13:48:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a1/af87f64b9f986889884243643621ebbd4ac72472ba8ec8cec891ac8e2ca1/h5py-3.16.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:0f456f556e4e2cebeebd9d66adf8dc321770a42593494a0b6f0af54a7567b242", size = 5074061, upload-time = "2026-03-06T13:48:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/146f5eaff3dc246a9c7f6e5e4f42bd45cc613bce16693bcd4d1f7c958bf5/h5py-3.16.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3e6cb3387c756de6a9492d601553dffea3fe11b5f22b443aac708c69f3f55e16", size = 5279216, upload-time = "2026-03-06T13:48:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/12a13424f1e604fc7df9497b73c0356fb78c2fb206abd7465ce47226e8fd/h5py-3.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8389e13a1fd745ad2856873e8187fd10268b2d9677877bb667b41aebd771d8b7", size = 5070068, upload-time = "2026-03-06T13:48:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/41/8c/bbe98f813722b4873818a8db3e15aa3e625b59278566905ac439725e8070/h5py-3.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:346df559a0f7dcb31cf8e44805319e2ab24b8957c45e7708ce503b2ec79ba725", size = 5300253, upload-time = "2026-03-06T13:49:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/87e6705b4d6890e7cecdf876e2a7d3e40654a2ae37482d79a6f1b87f7b92/h5py-3.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4c6ab014ab704b4feaa719ae783b86522ed0bf1f82184704ed3c9e4e3228796e", size = 3381671, upload-time = "2026-03-06T13:49:04.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/91/9fad90cfc5f9b2489c7c26ad897157bce82f0e9534a986a221b99760b23b/h5py-3.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:faca8fb4e4319c09d83337adc80b2ca7d5c5a343c2d6f1b6388f32cfecca13c1", size = 2740706, upload-time = "2026-03-06T13:49:06.347Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[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/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { 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 = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "libemg" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "bleak" }, + { name = "datetime" }, + { name = "dearpygui" }, + { name = "h5py" }, + { name = "librosa" }, + { name = "linkify-it-py" }, + { name = "matplotlib" }, + { name = "myst-parser" }, + { name = "onedrivedownloader" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyomyo" }, + { name = "pyserial" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-skip-slow" }, + { name = "pywavelets" }, + { name = "requests" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sifi-bridge-py" }, + { name = "sphinx" }, + { name = "sphinx-rtd-theme" }, + { name = "websockets" }, + { name = "wfdb" }, +] + +[package.metadata] +requires-dist = [ + { name = "bleak", specifier = ">=3.0.2" }, + { name = "datetime", specifier = ">=6.0" }, + { name = "dearpygui", specifier = ">=2.3.1" }, + { name = "h5py", specifier = ">=3.16.0" }, + { name = "librosa", specifier = ">=0.11.0" }, + { name = "linkify-it-py", specifier = "==2.0.0" }, + { name = "matplotlib", specifier = ">=3.11.0" }, + { name = "myst-parser", specifier = ">=4.0.0" }, + { name = "onedrivedownloader", specifier = ">=1.1.3" }, + { name = "opencv-python", specifier = ">=4.13.0.92" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pyomyo", specifier = "==0.0.5" }, + { name = "pyserial", specifier = ">=3.5" }, + { name = "pytest", specifier = "==7.1.3" }, + { name = "pytest-cov", specifier = "==4.0.0" }, + { name = "pytest-skip-slow", specifier = "==0.0.3" }, + { name = "pywavelets", specifier = ">=1.9.0" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "scikit-learn", specifier = ">=1.9.0" }, + { name = "scipy", specifier = ">=1.18.0" }, + { name = "sifi-bridge-py", specifier = "==2.0.0b19" }, + { name = "sphinx", specifier = ">=8.1.3" }, + { name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, + { name = "websockets", specifier = "==8.1" }, + { name = "wfdb", specifier = ">=4.3.1" }, +] + +[[package]] +name = "librosa" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioread" }, + { name = "decorator" }, + { name = "joblib" }, + { name = "lazy-loader" }, + { name = "msgpack" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pooch" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "soundfile" }, + { name = "soxr" }, + { name = "standard-aifc" }, + { name = "standard-sunau" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload-time = "2025-03-11T15:09:54.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/8d/abb58e1ed268d5ef787bf95c7e42d0f95f3aa7f9cd41ff990c25fcc8ed0c/linkify-it-py-2.0.0.tar.gz", hash = "sha256:476464480906bed8b2fa3813bf55566282e55214ad7e41b7d1c2b564666caf2f", size = 23060, upload-time = "2022-05-07T07:00:33.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/1a/2280e2eb892162ef5c0480a131d1d176b61f5f24abdce8dd9862454f7d14/linkify_it_py-2.0.0-py3-none-any.whl", hash = "sha256:1bff43823e24e507a099e328fc54696124423dd6320c75a9da45b4b754b748ad", size = 19752, upload-time = "2022-05-07T07:00:31.688Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.0" +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/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/41/aa47f156b061d14c98b906f76c428507397708ec63ff94f410ae1752b426/matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5", size = 9450532, upload-time = "2026-06-12T02:28:06.769Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4f/5a9eb0375e81413953febf8af7b012a6b6357f53438a15c4f5ad86c6bbb5/matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313", size = 9279760, upload-time = "2026-06-12T02:28:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/1117d53077e3ac3152503a84e9cf7a5c239576805ee71276e80c2aaa7471/matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09", size = 10031623, upload-time = "2026-06-12T02:28:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/e937138daffad65b71bf831a377809dcbc830fb4f31a31e067dc1faa2575/matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f", size = 10839372, upload-time = "2026-06-12T02:28:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c2/438ecc197ffb8023b6b9922915542f2172f5fd45b76703b0b4fc47322243/matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c", size = 10924099, upload-time = "2026-06-12T02:28:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/40/2e/395883da416f378b3ed2c9f3e843ac477eae1ce731b671b79adaa6f0bacd/matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06", size = 9329727, upload-time = "2026-06-12T02:28:18.581Z" }, + { url = "https://files.pythonhosted.org/packages/61/82/2c388956abf8bf392dfb5b8917c502f1082df6a941b781ab8c8e5ba2474b/matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771", size = 9003506, upload-time = "2026-06-12T02:28:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/34454baa44da7975ada82e9aea37105ec47059514dc967d3be14426ba8dc/matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24", size = 9499838, upload-time = "2026-06-12T02:28:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/98fe79a398cf232219f090163a7fa7e6766e9f2e0ad26df54d6f8934d8ee/matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620", size = 9332298, upload-time = "2026-06-12T02:28:24.796Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/b4b7c33151e74e5c802f3cde1ba807ebfc38401e329b44e215a5888dd76d/matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3", size = 10045491, upload-time = "2026-06-12T02:28:27.141Z" }, + { url = "https://files.pythonhosted.org/packages/71/28/394548efd68354110c1a1be11fe6b6e559e06d1a23da35908a0e316c55a9/matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3", size = 10857059, upload-time = "2026-06-12T02:28:29.222Z" }, + { url = "https://files.pythonhosted.org/packages/c8/44/e7922e6e2a4d63bdfbc9dc4a53e3850ab438d46cf42e6779bb15ec92c948/matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1", size = 10939576, upload-time = "2026-06-12T02:28:31.66Z" }, + { url = "https://files.pythonhosted.org/packages/3d/be/b1ca96003a441d619b727fee21d671fdff7a5ce2f1bb797b2521aa2f679a/matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3", size = 9379519, upload-time = "2026-06-12T02:28:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/e3/72/4bf3b91821c34596dd6a7bdac5836d94f744144c8208939ef49d8ec43f7e/matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0", size = 9055456, upload-time = "2026-06-12T02:28:35.878Z" }, + { url = "https://files.pythonhosted.org/packages/57/52/a94102ac99eb78e2fe9b826674f9ef9ee23327110ea6ab4776c1b4eb6209/matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79", size = 9452137, upload-time = "2026-06-12T02:28:37.93Z" }, + { url = "https://files.pythonhosted.org/packages/7c/03/b8cdb625a21f710dfa11bbca1f48fb4057d2c0286975f8b415bf80942c99/matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3", size = 9281514, upload-time = "2026-06-12T02:28:40.028Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2d/4e1240ea82ee197dfb3851e71f71c87eeeb975f1753b56a0588e4e80739a/matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9", size = 10843005, upload-time = "2026-06-12T02:28:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/29/dc/6377ecfaa5fef79430f74a1a16638b4e2aa30d4692bae2c19f9d76fe3b01/matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430", size = 11127459, upload-time = "2026-06-12T02:28:44.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/795c405aa7560443a3b01309424cde4a1113b85c90b8a63417444a749617/matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba", size = 10925160, upload-time = "2026-06-12T02:28:46.564Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/3a9e6389a7cfaeff76c56e40c2dabcb13110e21e82f837228c834ebe748c/matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2", size = 9485186, upload-time = "2026-06-12T02:28:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c0/396478ee7cf2091d182db8b4a8695f6a37f1ddb978989cf9dbb84cd5c123/matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d", size = 9160349, upload-time = "2026-06-12T02:28:51.382Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6f/1c3bd51bb2b34eaacdcf3c3d859dbb357f952fc8020c617dc118ad7c9e38/matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847", size = 9500921, upload-time = "2026-06-12T02:28:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/4d861d0121840cb1a3fd4a10deb211efd6fccd481ed23e553f31f4f4da4a/matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e", size = 9332190, upload-time = "2026-06-12T02:28:55.623Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/22f6bc35711a0b5639a784e74e653e77c86210bd4304449dd399a482f74e/matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0", size = 10854181, upload-time = "2026-06-12T02:28:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/9a9eaca731a2939589da520f0ebe8fd8753d0f51fca98c7d20af6dbe261a/matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb", size = 11137715, upload-time = "2026-06-12T02:29:00.555Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f9/9b030b6088354acb0296871bb624b25befc1c42509d3c6cd17420c83a5b8/matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9", size = 10939427, upload-time = "2026-06-12T02:29:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/59/94/6b273eaee4ee250863567d100865da61a5c1527fa67f527b7ed22e0dd29c/matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6", size = 9535809, upload-time = "2026-06-12T02:29:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/95/1d36bddf2b7e2692c1540e78a6e5bc88bc1496b137e3e35a611f91b65ac3/matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159", size = 9209226, upload-time = "2026-06-12T02:29:07.033Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/37/14a4579049c1eb673afd0de0cb4842982acd55b9ce2643e763db858bcea0/numba-0.65.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1735c15c1134a5108b4d6a5c77fc0947924ea066a738dc09a52008c13df9cad3", size = 2681344, upload-time = "2026-04-24T02:02:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/a0/22/b8d873f6466b20aa563fc9b33acd48dec89a07803ddaa2f1c8ca1cd33126/numba-0.65.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c09f49117ef255e1f1c6dad0c7a1ed39868243862a73be5706793241a3755f1b", size = 3810619, upload-time = "2026-04-24T02:02:36.041Z" }, + { url = "https://files.pythonhosted.org/packages/62/08/e16a8b5d9a018962ebb5c66be662317cde32b9f5dab08441f90bed5522fb/numba-0.65.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:594a8680b3fadac99e97e489b1fd89007177e5336713745c3b769528c635a464", size = 3509783, upload-time = "2026-04-24T02:02:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/03c970d57f4c1741354837353ce39fb5206952ae1dba8922d29c86f64805/numba-0.65.1-cp313-cp313-win_amd64.whl", hash = "sha256:85be74c0d036842699a30058f82fb88fc5ffdc59f7615cab5792ea92914c9b62", size = 2750534, upload-time = "2026-04-24T02:02:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/8aed9b726d9ba5f11ad287645fd479e88278db3060a25cb1225d730eb2b7/numba-0.65.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:33f5eb68eb1c843511615d14663ce60258525d6a4c65ab040e2c2b0c4cf17450", size = 2681554, upload-time = "2026-04-24T02:02:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/87/96/f3eb235fafa82a34e2ab5dd7dc9ffff998ebf5f0bbc23fa56a96aeb44da6/numba-0.65.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:71e73029bf53a62cc6afcf96be4bd942290d8b4c55f0a454fb536158115790f7", size = 3779602, upload-time = "2026-04-24T02:02:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/09/90/b0f09b48752d23640b8284f22aa597737e8adaddc7fbfacc4708b7f73a4c/numba-0.65.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a07635e0be926b9bdbffb09137c230fb13f6ec0e564914ba937cee12ce3eb35", size = 3479532, upload-time = "2026-04-24T02:02:45.427Z" }, + { url = "https://files.pythonhosted.org/packages/56/46/3f7fc04fb853559e74b210e0b62c19974ec844cefec611f9e535f4da3761/numba-0.65.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a20fcdabdefbdacf88d85caf70c3b18c4bcb7ebb8f82e6a19486383dd26ab63", size = 2752637, upload-time = "2026-04-24T02:02:47.664Z" }, + { url = "https://files.pythonhosted.org/packages/81/7b/c1a341a9067367778f4152a5f01061cf281fb09582c92c510ec4918cabf6/numba-0.65.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:548dd4b3a4508d5062768d1514b2cd7b015f9a25ec7af651c50dee243965e652", size = 2684600, upload-time = "2026-04-24T02:02:49.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/98ddbcf3e4f04a6dd07e1c67249955920579ba4af6bb6868e3088f4ed282/numba-0.65.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:78abc28feff2c2ff8307fff3975b6438352759c9acb797ecd6b1fb6e7e39e31d", size = 3817198, upload-time = "2026-04-24T02:02:51.266Z" }, + { url = "https://files.pythonhosted.org/packages/a3/83/0dad21057ece5a835599f5d24099b091703995e23dbbf894f259e91c010b/numba-0.65.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7676cb389555805f9b9a1840cbcd1ea6c8bd5376ab6918e3a29c5ea1dbda20", size = 3533862, upload-time = "2026-04-24T02:02:52.987Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/8be7118ffd4c8440881046eac3d0982cc5ab42909508cf5d67024d62a2e4/numba-0.65.1-cp314-cp314t-win_amd64.whl", hash = "sha256:20609346e3bd75204950dcbbfe383a8d7dbf4902f442aedbf00f97fef4aa8f38", size = 2758237, upload-time = "2026-04-24T02:02:54.612Z" }, +] + +[[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/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { 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 = "nvidia-nccl-cu12" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8c/554bb020501d6c04ad8127d83f728137f8f9123f991666efbdcf9095a221/nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:03ecd776fd1d58fd2c9a0a687dcf8db9ecd0057382dba646fa3d65786d4a9ea1", size = 303277471, upload-time = "2026-06-09T03:24:16.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/e7ffa9c324ae260e5dbb4af2cd557bf7a8d155c8ac7b79a785fe1796fb92/nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:8ce1b8213f61f2bfac132e6df890af6450b77cbd140c6ce4e98cb0c2d8e678c9", size = 303361239, upload-time = "2026-06-09T03:24:53.816Z" }, +] + +[[package]] +name = "onedrivedownloader" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/ff/7559ec3ba73e4a5ca40496a9a9329be9910b7601ac07d0568cb3629129e2/onedrivedownloader-1.1.3.tar.gz", hash = "sha256:4ce25960fd407790eae3af1880df60d6bfd52d0176e8f058ec5d5c4e1470ac55", size = 4536, upload-time = "2022-03-31T12:59:13.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a8/379308409ec734a6f7e6774a3b048788cd2c46b5afb6390eadbce9e8a6e2/onedrivedownloader-1.1.3-py3-none-any.whl", hash = "sha256:2eb0bc9ae70108c328b460052da2733ccc1738db57fbc6c0fe501ca7b8483253", size = 5072, upload-time = "2022-03-31T12:59:11.758Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[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 = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + +[[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/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { 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 = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "py" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygame" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/cc/08bba60f00541f62aaa252ce0cfbd60aebd04616c0b9574f755b583e45ae/pygame-2.6.1.tar.gz", hash = "sha256:56fb02ead529cee00d415c3e007f75e0780c655909aaa8e8bf616ee09c9feb1f", size = 14808125, upload-time = "2024-09-29T13:41:34.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/91/718acf3e2a9d08a6ddcc96bd02a6f63c99ee7ba14afeaff2a51c987df0b9/pygame-2.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae6039f3a55d800db80e8010f387557b528d34d534435e0871326804df2a62f2", size = 13090765, upload-time = "2024-09-29T14:27:02.377Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c6/9cb315de851a7682d9c7568a41ea042ee98d668cb8deadc1dafcab6116f0/pygame-2.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a3a1288e2e9b1e5834e425bedd5ba01a3cd4902b5c2bff8ed4a740ccfe98171", size = 12381704, upload-time = "2024-09-29T14:27:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8f/617a1196e31ae3b46be6949fbaa95b8c93ce15e0544266198c2266cc1b4d/pygame-2.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27eb17e3dc9640e4b4683074f1890e2e879827447770470c2aba9f125f74510b", size = 13581091, upload-time = "2024-09-29T11:30:27.653Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/2851a564e40a2dad353f1c6e143465d445dab18a95281f9ea458b94f3608/pygame-2.6.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c1623180e70a03c4a734deb9bac50fc9c82942ae84a3a220779062128e75f3b", size = 14273844, upload-time = "2024-09-29T11:40:04.138Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/aa23aa2e70bcba42c989c02e7228273c30f3b44b9b264abb93eaeff43ad7/pygame-2.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef07c0103d79492c21fced9ad68c11c32efa6801ca1920ebfd0f15fb46c78b1c", size = 13951197, upload-time = "2024-09-29T11:40:06.785Z" }, + { url = "https://files.pythonhosted.org/packages/a6/06/29e939b34d3f1354738c7d201c51c250ad7abefefaf6f8332d962ff67c4b/pygame-2.6.1-cp313-cp313-win32.whl", hash = "sha256:3acd8c009317190c2bfd81db681ecef47d5eb108c2151d09596d9c7ea9df5c0e", size = 10249309, upload-time = "2024-09-29T11:10:23.329Z" }, + { url = "https://files.pythonhosted.org/packages/7e/11/17f7f319ca91824b86557e9303e3b7a71991ef17fd45286bf47d7f0a38e6/pygame-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:813af4fba5d0b2cb8e58f5d95f7910295c34067dcc290d34f1be59c48bd1ea6a", size = 10620084, upload-time = "2024-09-29T11:48:51.587Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/91/c76f3c5e8e80c7047e43c4c05b3e6fda9a7cefad5aae85487007674c966c/pyobjc_framework_corebluetooth-12.2.1.tar.gz", hash = "sha256:7dbb285295097205bebbcb11f55161e5faa02111108fb7b17536176e31971eb0", size = 37568, upload-time = "2026-06-19T16:20:12.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/79/890a53ed45c1006eedcf60627b7d661c8696e5367723ceb25cc6a0216b30/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:30a26eef36c250fc14e73335641e24f764b32b7e42bae945a5d8a1c2347040b5", size = 13235, upload-time = "2026-06-19T16:08:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/7a/40ffc3be8e31b1eb1f8f5eb2a58ef832287fb1ea6b3c452dc8b25b9e064b/pyobjc_framework_corebluetooth-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:756933b9ce6160a986c8877ab667659fa2d8e15aff28d0981b5284fbdd1ea735", size = 13416, upload-time = "2026-06-19T16:08:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/30/ff/6f3b0bb3110ec82dbedaea47de151bd688980f5aadc634ef0cd236fdbd16/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2a2e6d56f51e4ca3e3b9766ef34150a9a7ce5f0cf4f9ee879ec10923af58e97e", size = 13223, upload-time = "2026-06-19T16:08:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/4e12660569219e4a68186ae9709b85278d3ebaf8d2f8e1c826a7337f4f7a/pyobjc_framework_corebluetooth-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50d7e4245dbdc8789dcc1f11fca2e633aa126a298b09db62f8216531fe107ee2", size = 13414, upload-time = "2026-06-19T16:08:31.679Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4c/976ae9bcce3615af806e3c314ea9caa3faacf11ec44f00b1a149559c6cb3/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c8d126c56b71c25218be186930a1b41739f83e931726a52e3298beeb170c5e5b", size = 13222, upload-time = "2026-06-19T16:08:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/99/be/44bb648a6b5c8aec79138bf562dab9eef414016ee31f37066bf81d809ae9/pyobjc_framework_corebluetooth-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:81023518feb75e9b2b676b28198955c51ae00548cf23c73c524c7101263b68db", size = 13424, upload-time = "2026-06-19T16:08:33.336Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/3f/561653aff3f19873457c95c053f0298da517be89fdfc0ec35115ed5b7030/pyobjc_framework_libdispatch-12.2.1.tar.gz", hash = "sha256:0d24eda41c6c258135077f60d410e704bc7b5a67adcb2ca463918896c7363795", size = 40336, upload-time = "2026-06-19T16:20:56.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c6/cfe97f1beb13f5b7ca5c4348158c2de886d58ffba5be09a9376557f7d6f6/pyobjc_framework_libdispatch-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9c0ebf99520083bf17c007a544c100056a0d4ae5c346fb89e1bdfe6d041f16f2", size = 15679, upload-time = "2026-06-19T16:12:51.28Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/ef6b51bc72fe5ac1df80c34b1b13a97d0922ddd6bc5d3ecf5ead1557bf34/pyobjc_framework_libdispatch-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3f56fd71b963a0b6e440ed2f0ea2fb635221758b7eb908ba38f96f5144b83ca3", size = 15946, upload-time = "2026-06-19T16:12:52.098Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/5b4a6c8580f2a486daf4b0d14a2356c47abfda401b329e71e46ac9b5460c/pyobjc_framework_libdispatch-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:999bad9a2c9198c837ba8f57a3ca9f05b4fc4bf7b69318baaa266dd2ab2fc8f7", size = 15699, upload-time = "2026-06-19T16:12:52.917Z" }, + { url = "https://files.pythonhosted.org/packages/bd/44/68cff50cb37a6ea311b7e805105ea13c33043762772714bc25d269c0730d/pyobjc_framework_libdispatch-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3fc93971f40d9757995c1e4b995a1614a468a5178be27e3d81e9bdc0b5e3cf75", size = 15981, upload-time = "2026-06-19T16:12:53.845Z" }, + { url = "https://files.pythonhosted.org/packages/b3/5d/1f48e023555817f1271e86849ebd092743fc8bd292b6f82e87aba5df6122/pyobjc_framework_libdispatch-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:26a096c81c8cf272f4f1bb8f6c4b7565e005d273d218b53b83d925da5292633f", size = 15719, upload-time = "2026-06-19T16:12:54.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/b45c32851a3bcd367c62804c23aa55ea7918af6e16fddf1df23f5d7ca750/pyobjc_framework_libdispatch-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:82c6512fb4985f3bcd6b60b0cff79a4b483b44d1d2e5405010e34dd4b60aa01b", size = 16009, upload-time = "2026-06-19T16:12:55.513Z" }, +] + +[[package]] +name = "pyomyo" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pygame" }, + { name = "pyserial" }, + { name = "scikit-learn" }, + { name = "xgboost" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/93/167814679f7e214f0cec90830fe3c5cc887f29008bb9130d851986124982/pyomyo-0.0.5.tar.gz", hash = "sha256:3c215e91cb97522a26a5cc95a4909d69044a58c1d4a4d0b3654b5aead8f9bd41", size = 14009, upload-time = "2021-11-13T22:03:31.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/b1/8b7455b2e368cd3d1c6e4c634daa53c9fa9417181460e9ba8b25bceb169a/pyomyo-0.0.5-py3-none-any.whl", hash = "sha256:aafe63b09f5a5732a8d2933a5ea0c8851bce01078ceec1a0a70f5fd731cb8d70", size = 13629, upload-time = "2021-11-13T22:03:30.238Z" }, +] + +[[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" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +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 = "pytest" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "py" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/a7/8c63a4966935b0d0b039fd67ebf2e1ae00f1af02ceb912d838814d772a9a/pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39", size = 1257801, upload-time = "2022-09-02T11:13:15.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b9/3541bbcb412a9fd56593005ff32183825634ef795a1c01ceb6dee86e7259/pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7", size = 298172, upload-time = "2022-09-02T11:13:13.78Z" }, +] + +[[package]] +name = "pytest-cov" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/70/da97fd5f6270c7d2ce07559a19e5bf36a76f0af21500256f005a69d9beba/pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470", size = 62013, upload-time = "2022-09-28T18:39:22.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1f/9ec0ddd33bd2b37d6ec50bb39155bca4fe7085fa78b3b434c05459a860e3/pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b", size = 21554, upload-time = "2022-09-28T18:39:21.138Z" }, +] + +[[package]] +name = "pytest-skip-slow" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/2d/98fea2ff37245314ab70ee1f494b53e0abbc1b8896bbcfac97c2d324ec31/pytest-skip-slow-0.0.3.tar.gz", hash = "sha256:5ba043e8b9bbf23bb4cb43fb802d1f5b71823c9869b33434a88170c2c68432ff", size = 4104, upload-time = "2022-04-26T13:07:16.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/c7/b280b8e8051760205796fb1597fa660553402ef6d3fd34c8ab1843251680/pytest_skip_slow-0.0.3-py3-none-any.whl", hash = "sha256:f4b419f545251ce9a24d574c70b15e6f24614d648b5c16ae8551ec0c4b5e48d6", size = 2975, upload-time = "2022-04-26T13:07:15.547Z" }, +] + +[[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 = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywavelets" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a7/dec4e450675d62946ad975f5b4d924437df42d2fae46e91dfddda2de0f5a/pywavelets-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:74f8455c143818e4b026fc67b27fd82f38e522701b94b8a6d1aaf3a45fcc1a25", size = 4316201, upload-time = "2025-08-04T16:19:16.259Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0c/b54b86596c0df68027e48c09210e907e628435003e77048384a2dd6767e3/pywavelets-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c50320fe0a4a23ddd8835b3dc9b53b09ee05c7cc6c56b81d0916f04fc1649070", size = 4286838, upload-time = "2025-08-04T16:19:17.92Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9c/333969c3baad8af2e7999e83addcb7bb1d1fd48e2d812fb27e2e89582cb1/pywavelets-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6e059265223ed659e5214ab52a84883c88ddf3decbf08d7ec6abb8e4c5ed7be", size = 4430753, upload-time = "2025-08-04T16:19:19.529Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/a24c6ff03b026b826ad7b9267bd63cd34ce026795a0302f8a5403840b8e7/pywavelets-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae10ed46c139c7ddb8b1249cfe0989f8ccb610d93f2899507b1b1573a0e424b5", size = 4491315, upload-time = "2025-08-04T16:19:20.717Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/e3fbb502fca3469e51ced4f1e1326364c338be91edc5db5a8ddd26b303fa/pywavelets-1.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8f8b1cc2df012401cb837ee6fa2f59607c7b4fe0ff409d9a4f6906daf40dc86", size = 4437654, upload-time = "2025-08-04T16:19:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/92/44/c9b25084048d9324881a19b88e0969a4141bcfdc1d218f1b4b680b7af1c1/pywavelets-1.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db43969c7a8fbb17693ecfd14f21616edc3b29f0e47a49b32fa4127c01312a67", size = 4496435, upload-time = "2025-08-04T16:19:23.842Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/b27ec18c72b1dee3314e297af39c5f8136d43cc130dd93cb6c178ca820e5/pywavelets-1.9.0-cp313-cp313-win32.whl", hash = "sha256:9e7d60819d87dcd6c68a2d1bc1d37deb1f4d96607799ab6a25633ea484dcda41", size = 4132709, upload-time = "2025-08-04T16:19:25.415Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/78ef3f9fb36cdb16ee82371d22c3a7c89eeb79ec8c9daef6222060da6c79/pywavelets-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:0d70da9d7858c869e24dc254f16a61dc09d8a224cad85a10c393b2eccddeb126", size = 4213377, upload-time = "2025-08-04T16:19:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cd/ca0d9db0ff29e3843f6af60c2f5eb588794e05ca8eeb872a595867b1f3f5/pywavelets-1.9.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4dc85f44c38d76a184a1aa2cb038f802c3740428c9bb877525f4be83a223b134", size = 4354336, upload-time = "2025-08-04T16:19:28.745Z" }, + { url = "https://files.pythonhosted.org/packages/82/d6/70afefcc1139f37d02018a3b1dba3b8fc87601bb7707d9616b7f7a76e269/pywavelets-1.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7acf6f950c6deaecd210fbff44421f234a8ca81eb6f4da945228e498361afa9d", size = 4335721, upload-time = "2025-08-04T16:19:30.371Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/713f731b9ed6df0c36269c8fb62be8bb28eb343b9e26b13d6abda37bce38/pywavelets-1.9.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:144d4fc15c98da56654d0dca2d391b812b8d04127b194a37ad4a497f8e887141", size = 4418702, upload-time = "2025-08-04T16:19:31.743Z" }, + { url = "https://files.pythonhosted.org/packages/44/e8/f801eb4b5f7a316ba20054948c5d6b27b879c77fab2674942e779974bd86/pywavelets-1.9.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1aa3729585408a979d655736f74b995b511c86b9be1544f95d4a3142f8f4b8b5", size = 4470023, upload-time = "2025-08-04T16:19:32.963Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/44b002cb16f2a392f2082308dd470b3f033fa4925d3efa7c46f790ce895a/pywavelets-1.9.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e0e24ad6b8eb399c49606dd1fcdcbf9749ad7f6d638be3fe6f59c1f3098821e2", size = 4426498, upload-time = "2025-08-04T16:19:34.151Z" }, + { url = "https://files.pythonhosted.org/packages/91/fe/2b70276ede7878c5fe8356ca07574db5da63e222ce39a463e84bfad135e8/pywavelets-1.9.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3830e6657236b53a3aae20c735cccead942bb97c54bbca9e7d07bae01645fe9c", size = 4477528, upload-time = "2025-08-04T16:19:35.932Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ed/d58b540c15e36508cfeded7b0d39493e811b0dce18d9d4e6787fb2e89685/pywavelets-1.9.0-cp313-cp313t-win32.whl", hash = "sha256:81bb65facfbd7b50dec50450516e72cdc51376ecfdd46f2e945bb89d39bfb783", size = 4186493, upload-time = "2025-08-04T16:19:37.198Z" }, + { url = "https://files.pythonhosted.org/packages/84/b2/12a849650d618a86bbe4d8876c7e20a7afe59a8cad6f49c57eca9af26dfa/pywavelets-1.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:47d52cf35e2afded8cfe1133663f6f67106a3220b77645476ae660ad34922cb4", size = 4274821, upload-time = "2025-08-04T16:19:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1f/18c82122547c9eec2232d800b02ada1fbd30ce2136137b5738acca9d653e/pywavelets-1.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:53043d2f3f4e55a576f51ac594fe33181e1d096d958e01524db5070eb3825306", size = 4314440, upload-time = "2025-08-04T16:19:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/1c92ac6b538ef5388caf1a74af61cf6af16ea6d14115bb53357469cb38d6/pywavelets-1.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bc36b42b1b125fd9cb56e7956b22f8d0f83c1093f49c77fc042135e588c799", size = 4290162, upload-time = "2025-08-04T16:19:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/d856a2cac8069c20144598fa30a43ca40b5df2e633230848a9a942faf04a/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08076eb9a182ddc6054ac86868fb71df6267c341635036dc63d20bdbacd9ad7e", size = 4437162, upload-time = "2025-08-04T16:19:42.556Z" }, + { url = "https://files.pythonhosted.org/packages/c9/54/777e0495acd4fb008791e84889be33d6e7fc8af095b441d939390b7d2491/pywavelets-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ee1ee7d80f88c64b8ec3b5021dd1e94545cc97f0cd479fb51aa7b10f6def08e", size = 4498169, upload-time = "2025-08-04T16:19:43.791Z" }, + { url = "https://files.pythonhosted.org/packages/76/68/81b97f4d18491a18fbe17e06e2eee80a591ce445942f7b6f522de07813c5/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3226b6f62838a6ccd7782cb7449ee5d8b9d61999506c1d9b03b2baf41b01b6fd", size = 4443318, upload-time = "2025-08-04T16:19:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/92/74/5147f2f0436f7aa131cb1bc13dba32ef5f3862748ae1c7366b4cde380362/pywavelets-1.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb7f4b11d18e2db6dd8deee7b3ce8343d45f195f3f278c2af6e3724b1b93a24", size = 4503294, upload-time = "2025-08-04T16:19:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d4/af998cc71e869919e0ab45471bd43e91d055ac7bc3ce6f56cc792c9b6bc8/pywavelets-1.9.0-cp314-cp314-win32.whl", hash = "sha256:9902d9fc9812588ab2dce359a1307d8e7f002b53a835640e2c9388fe62a82fd4", size = 4144478, upload-time = "2025-08-04T16:19:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/66/1d071eae5cc3e3ad0e45334462f8ce526a79767ccb759eb851aa5b78a73a/pywavelets-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:7e57792bde40e331d6cc65458e5970fd814dba18cfc4e9add9d051e901a7b7c7", size = 4227186, upload-time = "2025-08-04T16:19:49.57Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1f/da0c03ac99bd9d20409c0acf6417806d4cf333d70621da9f535dd0cf27fa/pywavelets-1.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b47c72fb4b76d665c4c598a5b621b505944e5b761bf03df9d169029aafcb652f", size = 4354391, upload-time = "2025-08-04T16:19:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/de9e225d8cc307fbb4fda88aefa79442775d5e27c58ee4d3c8a8580ceba6/pywavelets-1.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:969e369899e7eab546ea5d77074e4125082e6f9dad71966499bf5dee3758be55", size = 4335810, upload-time = "2025-08-04T16:19:52.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/3b/336761359d07cd44a4233ca854704ff2a9e78d285879ccc82d254b9daa57/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8aeffd4f35036c1fade972a61454de5709a7a8fc9a7d177eefe3ac34d76962e5", size = 4422220, upload-time = "2025-08-04T16:19:54.068Z" }, + { url = "https://files.pythonhosted.org/packages/98/61/76ccc7ada127f14f65eda40e37407b344fd3713acfca7a94d7f0f67fe57d/pywavelets-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f63f400fcd4e7007529bd06a5886009760da35cd7e76bb6adb5a5fbee4ffeb8c", size = 4470156, upload-time = "2025-08-04T16:19:55.379Z" }, + { url = "https://files.pythonhosted.org/packages/e0/de/142ca27ee729cf64113c2560748fcf2bd45b899ff282d6f6f3c0e7f177bb/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a63bcb6b5759a7eb187aeb5e8cd316b7adab7de1f4b5a0446c9a6bcebdfc22fb", size = 4430167, upload-time = "2025-08-04T16:19:56.566Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5e/90b39adff710d698c00ba9c3125e2bec99dad7c5f1a3ba37c73a78a6689f/pywavelets-1.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9950eb7c8b942e9bfa53d87c7e45a420dcddbd835c4c5f1aca045a3f775c6113", size = 4477378, upload-time = "2025-08-04T16:19:58.162Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/89f5f4ebcb9d34d9b7b2ac0a868c8b6d8c78d699a36f54407a060cea0566/pywavelets-1.9.0-cp314-cp314t-win32.whl", hash = "sha256:097f157e07858a1eb370e0d9c1bd11185acdece5cca10756d6c3c7b35b52771a", size = 4209132, upload-time = "2025-08-04T16:20:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/68/d2/a8065103f5e2e613b916489e6c85af6402a1ec64f346d1429e2d32cb8d03/pywavelets-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3b6ff6ba4f625d8c955f68c2c39b0a913776d406ab31ee4057f34ad4019fb33b", size = 4306793, upload-time = "2025-08-04T16:20:02.934Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "sifi-bridge-py" +version = "2.0.0b19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "sifibridge-bin" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/fe/a7bc12700d4df9d357e3d4988a51dd6aea49e50f551f5b4f22f7c1d4e474/sifi_bridge_py-2.0.0b19.tar.gz", hash = "sha256:88e2f36dc9fabd935b2e4bdac7751da21475f44bf4a0d5ae7b3fd27d4e670136", size = 136557, upload-time = "2026-07-03T14:43:30.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/92/62d82cac7972078cf8815f4839f35e12f26fec08efc6669e4dccd1c79364/sifi_bridge_py-2.0.0b19-py3-none-any.whl", hash = "sha256:965673932944894ced9ced9bff234ef3f2d40be484c4e609fa961d9e18c2d330", size = 16749, upload-time = "2026-07-03T14:43:28.695Z" }, +] + +[[package]] +name = "sifibridge-bin" +version = "2.0.0b20" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/19/8924c652c295eba7a94d8f021a6b30619b0826047de960d6cf0904285a5f/sifibridge_bin-2.0.0b20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20bd062cf9edc035e41b26c3803b782f7ecc0417650cc868b778a3217a589f71", size = 4147932, upload-time = "2026-07-01T13:43:00.273Z" }, + { url = "https://files.pythonhosted.org/packages/bb/07/6206eab5ea0894dac4c92fd28d36e6a1b433aaa13e60393a5ca78f1d8498/sifibridge_bin-2.0.0b20-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:f5949e8497a9ebf92867894bedd9fc2e8efac9472d2096677f3d1728fffeda50", size = 4466602, upload-time = "2026-07-01T13:43:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/1dc6ce711e9284644f5e08bae6a0ca67c42f5118bfcf34a2e59a47b736f8/sifibridge_bin-2.0.0b20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c7aa33b3ffa4c87bea74586b959abf04041c814618b4d014db14d4f8e154f7c", size = 5298134, upload-time = "2026-07-01T13:43:03.969Z" }, + { url = "https://files.pythonhosted.org/packages/46/28/e785cb710ae423c8bf90d712b9a3c136d2ce4e922219fb81081bab300b81/sifibridge_bin-2.0.0b20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ac58eb215637e4490907c97c5aded86e148eca0bd9bf03ed4d9e4ddf788975b", size = 5227579, upload-time = "2026-07-01T13:43:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d1/72f9627af5398d5c4051a6f31a41c3b7dc2dec46b1b05968c3b9527e99f3/sifibridge_bin-2.0.0b20-py3-none-win_amd64.whl", hash = "sha256:efbdd017f0794ea521fde9df621bcb1490a3d14afeb47a5a9134f220a053e0b4", size = 5935061, upload-time = "2026-07-01T13:43:08.293Z" }, +] + +[[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" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "soundfile" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" }, + { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" }, +] + +[[package]] +name = "soxr" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/11/27cebce4a108f77afea7c80545115536b45e3f11ebfb914f638fdd9ba847/soxr-1.1.0.tar.gz", hash = "sha256:9f228ae21c78fa9359ca98d8a5e8e91f30639e438e574133dace62c5b5309e44", size = 173067, upload-time = "2026-05-03T00:15:18.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/f3da7973b5f1b05d2d7e94d5376b881dcbc05297900cae6c3d33d95b209b/soxr-1.1.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:e0e09fa633ce2e67df08b298afced4d184f6e753fc330f241022250f1d0d61da", size = 204124, upload-time = "2026-05-03T00:14:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4", size = 165457, upload-time = "2026-05-03T00:14:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/88/2b/2e5eba817a762a2ec589ff165b8bc5955b25a0ad140045f7cd8e45410543/soxr-1.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf98c0d7b7d5ef5bf072fee8d3020e8b664f2d195933ea7bc5089267c2e22a06", size = 206529, upload-time = "2026-05-03T00:14:57.646Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/0e55195893228609c9a08c3b13b7a83a46c3a992cd00d3304f0f320cfb07/soxr-1.1.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b033078e86f3c4a658e5697fac8995764fad9e799563616b630136b613167f1", size = 240413, upload-time = "2026-05-03T00:14:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4d/621e4150e4815246ad552d215a8a294a90143fedd19ee442cf82d3b3abc8/soxr-1.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:6ae2a174bffea94e8ead857dad85999d3f49f091774dbad5b046c0417d7092f4", size = 174357, upload-time = "2026-05-03T00:15:00.724Z" }, + { url = "https://files.pythonhosted.org/packages/76/cd/77b74f1e95af0e11e52e9a034421aece7f7b45afd15a909afd41d5a5d102/soxr-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a941f5aaa0b8abced24318105c1ea22576afcc1138c19f625716ce4e2f76ad64", size = 207990, upload-time = "2026-05-03T00:15:02.1Z" }, + { url = "https://files.pythonhosted.org/packages/30/86/600cc31f982288167a59972746f117790162012546f995a32b5a55394b16/soxr-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:feebcba99ac99adb8009d46c8f4c1956b8c167576b0ae8a6fb47502e9a6f78e7", size = 169288, upload-time = "2026-05-03T00:15:03.75Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/80cd9aae0645513db1076d4384e8b2d895faf5009218b4a04348012c54fc/soxr-1.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52c9ca84e3dc656d83acc424574770e20ea8e0704dc3842d4e27b0fe9d3ba449", size = 211405, upload-time = "2026-05-03T00:15:05.395Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/cc3c80ac9b2289da4cf46c5d53b05e4327e6f5560a25868d06f9e2213af1/soxr-1.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4977323ef9c3aa3c2a26ff5fe0191c84b8fd759daf7afb1f25a91a55ad8b730", size = 244617, upload-time = "2026-05-03T00:15:07.134Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/f7af5fae841ffe32ed8440234ea2ad6adecca3bd92b6101076268c429000/soxr-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e17d4ef9b0185214b2c0935605ae63f827ea423bc74964be44763d68d2b6c21e", size = 187253, upload-time = "2026-05-03T00:15:08.813Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, + { name = "standard-chunk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "standard-sunau" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "websockets" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/2b/cf738670bb96eb25cb2caf5294e38a9dc3891a6bcd8e3a51770dbc517c65/websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", size = 58874, upload-time = "2019-11-01T13:40:26.52Z" } + +[[package]] +name = "wfdb" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fsspec" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "requests" }, + { name = "scipy" }, + { name = "soundfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/af/6fff8f3b2c23f58405fb790d0b60a3c61c5837356d9cda9646bcf0d6bf4f/wfdb-4.3.1.tar.gz", hash = "sha256:d33e9b4674da6cf87bcfcdac640eea4fbe4f95e82a8b209bb6db05c345cd68df", size = 163551, upload-time = "2026-02-03T19:22:26.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/45/05cc2ecbf61163bbbb18678dcdc7e7e7285f9f50f4dcf96a9ff07633ac52/wfdb-4.3.1-py3-none-any.whl", hash = "sha256:aa1801cf835797b9051ab7955fb900ce6c5f1e1b6cd9cc9979e6c7304e157063", size = 163935, upload-time = "2026-02-03T19:22:28.173Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757, upload-time = "2025-06-06T07:00:13.269Z" }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363, upload-time = "2025-06-06T07:00:14.135Z" }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722, upload-time = "2025-06-06T07:00:14.999Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/91cfdf941a1ba791708ab3477fc4e46793c8fe9117fc3e0a8c5ac5d7a09c/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a", size = 109015, upload-time = "2025-09-20T07:09:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/7460655628d0f340a93524f5236bb9f8514eb0e1d334b38cba8a89f6c1a6/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c", size = 115931, upload-time = "2025-09-20T07:09:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/70/e1248dea2ab881eb76b61ff1ad6cb9c07ac005faf99349e4af0b29bc3f1b/winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102", size = 109561, upload-time = "2025-09-20T07:09:52.733Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037, upload-time = "2025-06-06T07:00:25.818Z" }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822, upload-time = "2025-06-06T07:00:26.666Z" }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326, upload-time = "2025-06-06T07:00:27.477Z" }, + { url = "https://files.pythonhosted.org/packages/27/3d/421d04a20037370baf13de929bc1dc5438b306a76fe17275ec5d893aae6c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214", size = 92264, upload-time = "2025-09-20T07:09:53.563Z" }, + { url = "https://files.pythonhosted.org/packages/07/c7/43601ab82fe42bcff430b8466d84d92b31be06cc45c7fd64e9aac40f7851/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2", size = 97517, upload-time = "2025-09-20T07:09:54.411Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/e3303f6a25a2d98e424b06580fc85bbfd068f383424c67fa47cb1b357a46/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a", size = 94122, upload-time = "2025-09-20T07:09:55.187Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342, upload-time = "2025-06-06T07:00:56.16Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844, upload-time = "2025-06-06T07:00:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540, upload-time = "2025-06-06T07:00:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/cb447ca7730a1e05730272309b074da6a04af29a8c0f5121014db8a2fc02/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f", size = 185249, upload-time = "2025-09-20T07:09:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/fa/f465d5d44dda166bf7ec64b7a950f57eca61f165bfe18345e9a5ea542def/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851", size = 193739, upload-time = "2025-09-20T07:09:59.893Z" }, + { url = "https://files.pythonhosted.org/packages/78/08/51c53ac3c704cd92da5ed7e7b9b57159052f6e46744e4f7e447ed708aa22/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892", size = 194836, upload-time = "2025-09-20T07:10:00.87Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276, upload-time = "2025-06-06T07:02:05.178Z" }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536, upload-time = "2025-06-06T07:02:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362, upload-time = "2025-06-06T07:02:06.997Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/5785cd1ec54dc0f0e6f3e6a466d07a62b8014a6e2b782e80444ef87e83ab/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106", size = 134252, upload-time = "2025-09-20T07:10:12.935Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/68d91068048410f49794c0b19c45759c63ca559607068cfe5affba2f211b/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472", size = 145509, upload-time = "2025-09-20T07:10:13.797Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a4/898951d5bfc474aa9c7d133fe30870f0f2184f4ba3027eafb779d30eb7bc/winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22", size = 141334, upload-time = "2025-09-20T07:10:14.637Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/94/c22a14fd424632f3f3c0b25672218db9e8f4ae9e1355e0b148f2fe6015b5/winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8", size = 38613, upload-time = "2025-06-06T07:08:04.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/24cec0cc228642554b48d436a7617d7162fb952919c55fc26e2d99c310bd/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290", size = 40180, upload-time = "2025-06-06T07:08:04.849Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/776453af26e78c0d0c0e1bfa89f86fd81322872f31a3e5dafb344dd47bf2/winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f", size = 36989, upload-time = "2025-06-06T07:08:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/76/79/4627afae6b389ddd1e5f1d691663c6b14d6c8f98959082aed1217cc57ef9/winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6", size = 39521, upload-time = "2025-09-20T07:11:17.992Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/c6aea91908ee7279ed51d12157bc8aeecb8850af2441073c3c91b261ad31/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf", size = 41121, upload-time = "2025-09-20T07:11:18.747Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/652f14e3c501452ad8e0723518d9bbd729219b47f4a4dbe2966c2f82dca8/winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312", size = 38114, upload-time = "2025-09-20T07:11:19.599Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774, upload-time = "2025-06-06T14:02:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827, upload-time = "2025-06-06T14:02:05.601Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180, upload-time = "2025-06-06T14:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/55/70/2869ea2112c565caace73c9301afd1d7afcc49bdd37fac058f0178ba95d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee", size = 131701, upload-time = "2025-09-20T07:17:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, +] + +[[package]] +name = "xgboost" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/41/846d4de2b8fc694073fd3ac5052caf68caa1ea11cb7fa32d7ad9c049b232/xgboost-3.3.0.tar.gz", hash = "sha256:58bcb8a4cace648cdab7b94fa4f16d2c9ff26d90dd4d26907168106fa06d8746", size = 1224702, upload-time = "2026-06-17T21:26:50.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/72/3b68983c0215ef65d48e9eeb1f168c3c6e3d62a61ece605de3209c79cae1/xgboost-3.3.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:07688a377046b8640897b62421150bf73c6cc7101823474ec6ad08b93290f587", size = 2553505, upload-time = "2026-06-17T21:21:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/c9/62/b49e756822b29909d0c95ed334662dc6c7c81a99ec6bc10dc18e69f3d6e7/xgboost-3.3.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:af7cea10f418b7c251ddc8da440f57bdab2990b5fc9f74a35a92b0f150ea287d", size = 2376040, upload-time = "2026-06-17T21:22:01.981Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/a0adcd1ee28f525bd5c9dc3ebe78a7599bf97c22866d6449f967b829e338/xgboost-3.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:624a83aeb1e7ba081719795db179f4ce6fff12e79de05cd9baf15ee48fd22f0e", size = 98180629, upload-time = "2026-06-17T21:24:00.804Z" }, + { url = "https://files.pythonhosted.org/packages/47/1f/8b3e578cfd8e3bcdb4374e2bbe0b40b4e5320accb5cbdcf535ecc512eb5c/xgboost-3.3.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f59edaf28eccd1c519788607c72ed907ee6cedfa933d706620bc1612d24b354e", size = 98716607, upload-time = "2026-06-17T21:26:21.058Z" }, + { url = "https://files.pythonhosted.org/packages/07/6b/087fd5d28fdbb90d385c50ee9308a820241b82feebdf42e72e19a48e4b32/xgboost-3.3.0-py3-none-win_amd64.whl", hash = "sha256:b06057f6a018fc04e6b3e0c15568ca636b8151a5b5f333478e500fcaf4fc7594", size = 69522696, upload-time = "2026-06-17T21:20:53.707Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +]