Compare commits

...

10 Commits

Author SHA1 Message Date
jack 72334795da refactor: reorganize backend modules into sub-packages for models, data, visualization, wafer, and controllers 2026-06-11 12:15:00 -07:00
jack b9f8032203 Refactor code structure for improved readability and maintainability 2026-06-11 11:57:46 -07:00
jack 97ca58bfc2 feat: Add Wafer Map tab and associated components
- Introduced a new Wafer Map tab in the HomePage.qml, which loads WaferMapTab.qml when selected.
- Created WaferMapTab.qml to display wafer map with live data and controls.
- Added ReadoutPanel.qml for displaying sensor statistics and thresholds.
- Implemented SourcePanel.qml for file selection and filtering.
- Developed TransportBar.qml for playback controls.
- Added WaferMapView.qml to visualize wafer data with interactive features.
- Created ReplaceSensorDialog.qml for sensor value overrides.
- Updated Theme.qml for new color schemes and UI adjustments.
- Modified qmldir files to include new components in the ISC.Tabs and ISC.Tabs.components modules.
2026-06-11 11:57:24 -07:00
jack b52b983bb2 Add wafer layouts and RBF heatmap functionality
- Introduced new YAML layout files for wafers B, C, D, F, X, Z, and their reversed versions.
- Implemented RBF heatmap interpolation using CuPy and NumPy for GPU acceleration.
- Created a backend module to load wafer layouts from YAML files, mirroring the existing schema.
- Developed a QQuickPaintedItem for rendering wafer maps, including sensor markers, heatmaps, and labels.
- Enhanced the drawing capabilities with concentric rings, crosshair axes, and orientation markers.
2026-06-11 11:56:55 -07:00
jack ca1a514f23 feat: Wafer Map integration
## Backend — Session & playback
- Add SessionController (QML-facing live/review controller) with
  loadedFile property for active-file highlight binding
- Add SessionModel, FramePlayer, and CsvRecorder for state machine,
  frame seek/step/speed, and CSV recording
- Add SensorEditor for per-sensor value overrides (replace + offset)
2026-06-11 11:55:34 -07:00
jack 59528eb6c9 Add layout files for various wafers: aepwafer, bcdwafer, fwafer, xwafer, zwafer, and zwafer_rev 2026-06-08 15:10:13 -07:00
jack 9cd3170e8a Replay Tabs:
- Add stability detection and threshold classification features with corresponding tests
2026-06-04 13:25:11 -07:00
jack 9779baa468 Restructure into src/ layout under pygui package
Move all application source under src/pygui/ and rewire imports,
build config, and QML module path to match.
- Relocate backend/, serialcomm/, and the ISC QML module into
  src/pygui/; convert main.py into pygui/__main__.py with a main()
  entry point (run via `python -m pygui` or the new `isc` script)
- Rewrite absolute imports: backend.* -> pygui.backend.*,
  serialcomm.* -> pygui.serialcomm.* (source + tests)
- Move app icons (isc.ico/icns) into packaging/
- Update README and ISC.qmlproject to the new paths
2026-06-03 11:41:45 -07:00
jack af170666e8 add sensor-band theme tokens 2026-06-03 11:15:21 -07:00
jack 16d8bf48af Merge pull request 'Add SettingsTab, StatusTab, DataTab and wire up DeviceController' (#2) from SettingTab into main
Reviewed-on: #2
2026-05-28 22:54:03 +00:00
91 changed files with 3947 additions and 85 deletions
+7 -1
View File
@@ -5,6 +5,9 @@ __pycache__/
*.pyd *.pyd
*.so *.so
*.a *.a
*.egg-info/
.pytest_cache/
.ruff_cache/
# Virtual environment # Virtual environment
.venv/ .venv/
@@ -14,10 +17,11 @@ env/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
.qtcreator/
*.swp *.swp
*.swo *.swo
*~ *~
docs
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -35,3 +39,5 @@ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Superpowers brainstorming visual companion
.superpowers/
+16
View File
@@ -0,0 +1,16 @@
import type QmlProjectModel 1.0
QmlProject {
name: "ISC"
version: "1.0"
mainFile: "src/pygui/ISC/Main.qml"
dependencies {
module: "QtQuick"
minimumVersion: "6.0.0"
}
importPaths: [
"src/pygui"
]
}
-5
View File
@@ -1,5 +0,0 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
# ===== Tab Components =====
SettingsTab 1.0 SettingsTab.qml
+19 -11
View File
@@ -16,34 +16,42 @@ python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install -e . # install the `pygui` package (src/ layout) in editable mode
``` ```
## Run ## Run
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
python main.py python -m pygui # or run the `isc` console script
``` ```
This launches the Qt window and loads the `ISC` QML module from `ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Project Structure ## Project Structure
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. The application lives under a `src/` layout as the `pygui` package:
- `ISC/Main.qml`: top-level window definition.
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs). - `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens. - `src/pygui/backend/`: Qt-facing models and controllers (device, settings, file browser, wafer parsing).
- `ISC/qmldir`: QML module registration. - `src/pygui/serialcomm/`: serial port, device service, and data-parser layer.
- `src/pygui/ISC/`: the `ISC` QML module (UI).
- `Main.qml`: top-level window definition.
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
- `Theme.qml`: shared theme constants and dark/light mode tokens.
- `qmldir`: QML module registration.
- `tests/`: pytest suite.
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
## Window Configuration ## Window Configuration
Window dimensions and constraints are defined in `ISC/Main.qml`: Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
- **Default size**: 1400 × 820 pixels - **Default size**: 1400 × 820 pixels
- **Minimum size**: 1100 × 700 pixels - **Minimum size**: 1100 × 700 pixels
- **Title bar**: "ISenseCloud" - **Title bar**: "ISenseCloud"
To adjust the window, edit the `Window` block in `ISC/Main.qml`: To adjust the window, edit the `Window` block in `src/pygui/ISC/Main.qml`:
```qml ```qml
Window { Window {
@@ -58,8 +66,8 @@ Window {
## Customization ## Customization
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`. - Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`. - Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
## Troubleshooting ## Troubleshooting
+44
View File
@@ -0,0 +1,44 @@
# This will be the name of the output files
name: aepwafer
wafers: ["A", "E", "P"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
4: { top: [0, 0] },
5: { top: [0, 0] },
6: { top: [0, 0] },
}
+46
View File
@@ -0,0 +1,46 @@
# This will be the name of the output files
name: bcdwafer
wafers: ["B", "C", "D"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
6: { left: [1, 0] },
}
+43
View File
@@ -0,0 +1,43 @@
# This will be the name of the output files
name: fwafer
wafers: ["F"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 200
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
}
+69
View File
@@ -0,0 +1,69 @@
# This will be the name of the output files
name: xwafer
wafers: ["X"]
# round or square wafer
shape: square
# diameter/edge size of the wafer
size: 310
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
133.29, 176.72, 176.72]
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
176.79, 133.36 ]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
21: { left: [0, 0] },
22: { left: [0, 0] },
23: { left: [0, 0] },
24: { left: [0, 0] },
25: { left: [0, 0] },
26: { left: [0, 0] },
27: { left: [0, 0] },
28: { left: [0, 0] },
29: { left: [0, 0] },
30: { left: [0, 0] },
31: { left: [0, 0] },
32: { left: [0, 0] },
33: { left: [0, 0] },
34: { left: [0, 0] },
35: { left: [0, 0] },
36: { left: [0, 0] }
}
+68
View File
@@ -0,0 +1,68 @@
# This will be the name of the output files
name: zwafer
wafers: ["Z"]
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
33: { left: [0, 0] },
34: { left: [0, 0] },
41: { top: [0, 0] },
42: { bottom: [0, 0] },
45: { top: [1, 0] },
46: { bottom: [0.5, 0] },
49: { left: [0, 0] },
50: { bottom: [-1.75, -0.5] },
51: { top: [-1, 0] },
52: { top: [-1, 0] },
53: { bottom: [0, 0] },
54: { left: [0, -0.75] },
55: { right: [0, 0.5] },
58: { bottom: [-0.75, 0] },
60: { top: [0, 0] },
62: { right: [0, 1] },
63: { right: [0, 0] },
64: { left: [0, 0.25] }
}
+71
View File
@@ -0,0 +1,71 @@
# This will be the name of the output files
name: zwafer_rev
wafers: [] # the reversed version doesn't represent any real wafer
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: true
reverse_y: true
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
37: { left: [0, 0] },
38: { left: [0, 0] },
39: { left: [0, 0] },
41: { top: [1.5, 0] },
42: { bottom: [1, 0] },
43: { left: [0, -1] },
45: { bottom: [-1, -1] },
46: { bottom: [0, -1] },
47: { right: [0, 1] },
48: { left: [0, 0] },
50: { bottom: [-1, 0] },
52: { top: [0, 0] },
54: { right: [0.5, 1] },
56: { left: [0, 0.5] },
57: { left: [0, 0] },
58: { top: [0.5, 0] },
59: { left: [0, 0] },
60: { top: [-1, 0] },
61: { left: [0, 0] },
62: { left: [0, -1] },
63: { right: [0, 1] },
}
-1
View File
@@ -1 +0,0 @@
# ===== Backend Package Marker =====
+71 -1
View File
@@ -1,14 +1,84 @@
# ===== Build System =====
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
# ===== Project Metadata ===== # ===== Project Metadata =====
[project] [project]
name = "pygui" name = "pygui"
version = "0.1.0" version = "0.1.0"
dependencies = [
"pyyaml>=6.0.3",
"scipy>=1.17.1",
"pandas>=2.0.0",
"matplotlib>=3.7.0",
]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest"] dev = ["pytest"]
# ===== Console Entry Point =====
[project.scripts]
isc = "pygui.__main__:main"
# ===== Package Discovery (src layout) =====
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
# Ship the QML module alongside the Python package.
pygui = ["ISC/**/*.qml", "ISC/**/qmldir"]
# ===== PySide Build Inputs ===== # ===== PySide Build Inputs =====
[tool.pyside6-project] [tool.pyside6-project]
files = ["ISC/HomePage.qml", "ISC/Main.qml", "ISC/Tabs/DataTab.qml", "ISC/Tabs/GraphTab.qml", "ISC/Tabs/SelectFileDialog.qml", "ISC/Tabs/SettingsTab.qml", "ISC/Tabs/StatusTab.qml", "ISC/Tabs/qmldir", "ISC/Theme.qml", "ISC/qmldir", "backend/data_model.py", "backend/device_controller.py", "backend/graph_view.py", "backend/file_browser.py", "backend/local_settings.py", "backend/local_settings_model.py", "main.py", "serialcomm/__init__.py", "serialcomm/data_parser.py", "serialcomm/serial_port.py", "serialcomm/device_service.py"] files = [
"src/pygui/ISC/HomePage.qml",
"src/pygui/ISC/Main.qml",
"src/pygui/ISC/Theme.qml",
"src/pygui/ISC/qmldir",
"src/pygui/ISC/Tabs/DataTab.qml",
"src/pygui/ISC/Tabs/SelectFileDialog.qml",
"src/pygui/ISC/Tabs/SettingsTab.qml",
"src/pygui/ISC/Tabs/StatusTab.qml",
"src/pygui/ISC/Tabs/qmldir",
"src/pygui/__main__.py",
"src/pygui/backend/contour_models.py",
"src/pygui/backend/crypto_helper.py",
"src/pygui/backend/csv_file_metadata.py",
"src/pygui/backend/data_model.py",
"src/pygui/backend/data_segment.py",
"src/pygui/backend/device_controller.py",
"src/pygui/backend/file_browser.py",
"src/pygui/backend/frame.py",
"src/pygui/backend/graph_view.py",
"src/pygui/backend/local_settings.py",
"src/pygui/backend/local_settings_model.py",
"src/pygui/backend/marching_squares.py",
"src/pygui/backend/zwafer_models.py",
"src/pygui/backend/zwafer_parser.py",
"src/pygui/backend/frame_stats.py",
"src/pygui/backend/threshold_classifier.py",
"src/pygui/backend/stability_detector.py",
"src/pygui/backend/csv_recorder.py",
"src/pygui/backend/frame_player.py",
"src/pygui/backend/stream_reader.py",
"src/pygui/backend/session_model.py",
"src/pygui/backend/session_controller.py",
"src/pygui/backend/wafer_layouts.py",
"src/pygui/backend/rbf_heatmap.py",
"src/pygui/backend/wafer_map_item.py",
"src/pygui/serialcomm/__init__.py",
"src/pygui/serialcomm/data_parser.py",
"src/pygui/serialcomm/device_service.py",
"src/pygui/serialcomm/serial_port.py",
"src/pygui/ISC/Tabs/WaferMapTab.qml",
"src/pygui/ISC/Tabs/components/WaferMapView.qml",
"src/pygui/ISC/Tabs/components/SourcePanel.qml",
"src/pygui/ISC/Tabs/components/ReadoutPanel.qml",
"src/pygui/ISC/Tabs/components/TransportBar.qml",
"src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml",
"src/pygui/ISC/Tabs/components/qmldir",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+4
View File
@@ -3,3 +3,7 @@ PySide6>=6.6.0
pyqtgraph>=0.13.0 pyqtgraph>=0.13.0
numpy>=1.24.0 numpy>=1.24.0
pyserial>=3.5 pyserial>=3.5
pandas>=2.0.0
matplotlib>=3.7.0
scipy>=1.10.0
pyyaml>=6.0
@@ -158,11 +158,17 @@ Rectangle {
Label { Label {
anchors.centerIn: parent anchors.centerIn: parent
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map"
text: parent.tabName + " content" text: parent.tabName + " content"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: 20 font.pixelSize: 20
} }
Loader{
anchors.fill: parent
active: parent.tabName === "Wafer Map"
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
}
} }
} }
} }
+231
View File
@@ -0,0 +1,231 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
Item {
id: root
anchors.fill: parent
// Live elapsed-time counter
property int _liveSecs: 0
Timer {
id: liveTimer
interval: 1000
repeat: true
running: streamController.mode === "live" && streamController.state !== "idle"
onTriggered: root._liveSecs++
onRunningChanged: if (!running) root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60)
var ss = s % 60
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss
}
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: Theme.rightPaneGap
// ── Toolbar ───────────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
// Mode toggle
TabBar {
id: modeBar
spacing: 2
padding: 2
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusSm
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
TabButton {
text: "Live"
implicitWidth: 58; implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.buttonNeutralBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: 11
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
text: "Review"
implicitWidth: 64; implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.buttonNeutralBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: 11
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
onCurrentIndexChanged:
streamController.setMode(currentIndex === 0 ? "live" : "review")
}
// Live source info: WiFi icon + port · serial
RowLayout {
visible: streamController.mode === "live"
spacing: 5
// WiFi icon (Unicode approximation; swap for SVG if available)
Label {
text: "◉"
color: streamController.state !== "idle"
? Theme.liveColor : Theme.bodyColor
font.pixelSize: 13
}
Label {
text: "Live stream"
color: Theme.bodyColor
font.pixelSize: 11
}
}
Item { Layout.fillWidth: true }
// SET badge + avg temp
Rectangle {
visible: streamController.state === "set"
height: 22; radius: Theme.radiusSm
color: "transparent"
border.color: Theme.liveColor
border.width: 1
implicitWidth: setLabel.implicitWidth + 16
Label {
id: setLabel
anchors.centerIn: parent
text: "SET " + (streamController.stats.avg !== undefined
? streamController.stats.avg + " avg" : "")
color: Theme.liveColor
font.pixelSize: 10
font.weight: Font.Medium
font.letterSpacing: 0.8
}
}
// Record indicator
RowLayout {
spacing: 5
visible: streamController.recording
Rectangle {
width: 7; height: 7; radius: 4
color: Theme.recordColor
SequentialAnimation on opacity {
running: streamController.recording
loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600 }
NumberAnimation { to: 1.0; duration: 600 }
}
}
Label {
text: "REC"
color: Theme.recordColor
font.pixelSize: 10
font.weight: Font.Medium
font.letterSpacing: 1.2
}
}
// LIVE timer
RowLayout {
spacing: 5
visible: streamController.mode === "live" && streamController.state !== "idle"
Rectangle {
width: 7; height: 7; radius: 4
color: Theme.liveColor
}
Label {
text: "LIVE " + root.fmtTime(root._liveSecs)
color: Theme.liveColor
font.pixelSize: 10
font.weight: Font.Medium
font.letterSpacing: 0.8
}
}
// IDLE label (review / not connected)
Label {
visible: streamController.mode === "review" || streamController.state === "idle"
text: streamController.state.toUpperCase()
color: Theme.bodyColor
font.pixelSize: 11
font.weight: Font.Medium
font.letterSpacing: 1.2
}
}
// ── Body: 3 zones ─────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: Theme.rightPaneGap
// Source panel — bordered card
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
SourcePanel {
anchors.fill: parent
anchors.margins: 10
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 8
WaferMapView {
id: waferView
Layout.fillWidth: true
Layout.fillHeight: true
blend: readoutPanel.heatmapBlend
showLabels: readoutPanel.showLabels
}
TransportBar { Layout.fillWidth: true }
}
// Readout panel — bordered card
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusMd
clip: true
ReadoutPanel {
id: readoutPanel
anchors.fill: parent
anchors.margins: 10
}
}
}
}
}
@@ -0,0 +1,240 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
ColumnLayout {
spacing: 0
property var s: streamController.stats
property alias showLabels: labelsToggle.checked
property alias heatmapBlend: heatmapSlider.value
// ── READOUT ───────────────────────────────────────────────────────────
Label {
text: "READOUT"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
bottomPadding: 4
}
GridLayout {
columns: 2
rowSpacing: 4
columnSpacing: 4
Layout.fillWidth: true
// MIN TEMP
Rectangle {
Layout.fillWidth: true; height: 52
color: Theme.subtleSectionBackground; radius: Theme.radiusSm
border.color: Theme.cardBorder; border.width: 1
ColumnLayout {
anchors { fill: parent; margins: 7 }
spacing: 1
Label { text: "MIN TEMP"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
Label {
text: s.min !== undefined ? s.min + " #" + s.minIndex : "—"
color: Theme.sensorLow
font.pixelSize: 15; font.weight: Font.Bold
}
}
}
// MAX TEMP
Rectangle {
Layout.fillWidth: true; height: 52
color: Theme.subtleSectionBackground; radius: Theme.radiusSm
border.color: Theme.cardBorder; border.width: 1
ColumnLayout {
anchors { fill: parent; margins: 7 }
spacing: 1
Label { text: "MAX TEMP"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
Label {
text: s.max !== undefined ? s.max + " #" + s.maxIndex : "—"
color: Theme.sensorHigh
font.pixelSize: 15; font.weight: Font.Bold
}
}
}
// DIFFERENTIAL
Rectangle {
Layout.fillWidth: true; height: 52
color: Theme.subtleSectionBackground; radius: Theme.radiusSm
border.color: Theme.cardBorder; border.width: 1
ColumnLayout {
anchors { fill: parent; margins: 7 }
spacing: 1
Label { text: "DIFFERENTIAL"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.headingColor
font.pixelSize: 15; font.weight: Font.Bold
}
}
}
// AVERAGE
Rectangle {
Layout.fillWidth: true; height: 52
color: Theme.subtleSectionBackground; radius: Theme.radiusSm
border.color: Theme.cardBorder; border.width: 1
ColumnLayout {
anchors { fill: parent; margins: 7 }
spacing: 1
Label { text: "AVERAGE"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.pixelSize: 15; font.weight: Font.Bold
}
}
}
}
// SIGMA — full-width
Rectangle {
Layout.fillWidth: true; height: 44
color: Theme.subtleSectionBackground; radius: Theme.radiusSm
border.color: Theme.cardBorder; border.width: 1
RowLayout {
anchors { fill: parent; leftMargin: 7; rightMargin: 7; topMargin: 4; bottomMargin: 4 }
ColumnLayout {
spacing: 1
Label { text: "SIGMA (σ)"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
Label { text: "3σ"; color: Theme.bodyColor; font.pixelSize: 9; font.letterSpacing: 1 }
}
Item { Layout.fillWidth: true }
ColumnLayout {
spacing: 1
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.pixelSize: 15; font.weight: Font.Bold
Layout.alignment: Qt.AlignRight
}
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.bodyColor
font.pixelSize: 12; font.weight: Font.Medium
Layout.alignment: Qt.AlignRight
}
}
}
}
Item { height: 8 }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
Item { height: 8 }
// ── DISPLAY ───────────────────────────────────────────────────────────
Label {
text: "DISPLAY"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
bottomPadding: 4
}
CheckBox {
id: labelsToggle
text: "Labels"
checked: true
font.pixelSize: 11
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Heatmap"
color: Theme.bodyColor
font.pixelSize: 11
Layout.preferredWidth: 52
}
Slider {
id: heatmapSlider
from: 0; to: 1; value: 0
Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%"
}
}
Item { height: 8 }
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
Item { height: 8 }
// ── THRESHOLDS ────────────────────────────────────────────────────────
Label {
text: "THRESHOLDS"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
bottomPadding: 4
}
Label { text: "Set Point (°C)"; color: Theme.bodyColor; font.pixelSize: 11 }
TextField {
id: spField
Layout.fillWidth: true
text: "149.0"
font.pixelSize: 12
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
onEditingFinished: pushThresholds()
}
Item { height: 4 }
Label { text: "Margin (±°C)"; color: Theme.bodyColor; font.pixelSize: 11 }
TextField {
id: mgField
Layout.fillWidth: true
text: "1.0"
font.pixelSize: 12
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
onEditingFinished: pushThresholds()
}
Item { height: 4 }
CheckBox {
id: autoCheck
text: "Auto range (mean ± 1σ)"
checked: true
font.pixelSize: 11
onCheckedChanged: pushThresholds()
}
Item { Layout.fillHeight: true }
function pushThresholds() {
var sp = parseFloat(spField.text) || 149.0
var mg = parseFloat(mgField.text) || 1.0
streamController.setThresholds(sp, mg, autoCheck.checked)
}
}
@@ -0,0 +1,206 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
Dialog {
id: root
title: "Sensor Override"
modal: true
standardButtons: Dialog.NoButton
width: 300
// called by WaferMapView's TapHandler
property int sensorIndex: -1
property string sensorLabel: ""
property real currentValue: 0.0
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
function openFor(index) {
sensorIndex = index
var dot = streamController.sensorDots[index]
if (!dot) return
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1)
currentValue = dot.value
valueField.text = ""
offsetField.text = ""
open()
}
background: Rectangle {
color: Theme.cardBackground
radius: 8
border.color: Theme.cardBorder
}
ColumnLayout {
anchors.fill: parent
spacing: Theme.rightPaneGap
// ── header ───────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
Label {
text: "Sensor #" + (root.sensorIndex + 1) +
(root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
color: Theme.headingColor
font.bold: true
font.pixelSize: 13
Layout.fillWidth: true
}
Rectangle {
visible: root.hasOverride
width: overrideTag.implicitWidth + 12
height: overrideTag.implicitHeight + 6
radius: 4
color: Theme.statusWarningColor
opacity: 0.85
Label {
id: overrideTag
anchors.centerIn: parent
text: "OVERRIDE"
color: "white"
font.pixelSize: 10
font.bold: true
font.letterSpacing: 1
}
}
}
Label {
text: "Live: " + root.currentValue.toFixed(2)
color: Theme.bodyColor
font.pixelSize: 12
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
// ── replace field ─────────────────────────────────────────
Label { text: "Replace with value"; color: Theme.bodyColor; font.pixelSize: 11 }
TextField {
id: valueField
Layout.fillWidth: true
placeholderText: "100"
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
}
// ── offset field ──────────────────────────────────────────
Label { text: "Or add offset (± delta)"; color: Theme.bodyColor; font.pixelSize: 11 }
TextField {
id: offsetField
Layout.fillWidth: true
placeholderText: "e.g. +0.5 or -0.5 (blank = skip)"
inputMethodHints: Qt.ImhFormattedNumbersOnly
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
}
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
// ── buttons ───────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 8
Button {
id: applyBtn
text: "Apply"
Layout.fillWidth: true
hoverEnabled: true
enabled: valueField.text !== "" || offsetField.text !== ""
opacity: enabled ? 1.0 : 0.4
background: Rectangle {
radius: Theme.radiusSm
color: applyBtn.down ? Theme.buttonNeutralPressed : applyBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: applyBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: 13
}
onClicked: {
if (valueField.text !== "") {
var v = parseFloat(valueField.text)
if (!isNaN(v))
streamController.replaceSensor(root.sensorIndex, v)
}
if (offsetField.text !== "") {
var d = parseFloat(offsetField.text)
if (!isNaN(d))
streamController.offsetSensor(root.sensorIndex, d)
}
root.close()
}
}
Button {
id: clearBtn
text: "Clear"
Layout.fillWidth: true
hoverEnabled: true
enabled: root.hasOverride
opacity: enabled ? 1.0 : 0.4
background: Rectangle {
radius: Theme.radiusSm
color: clearBtn.down ? Theme.buttonNeutralPressed : clearBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: clearBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: 13
}
onClicked: {
streamController.clearSensorEdit(root.sensorIndex)
root.close()
}
}
Button {
id: cancelBtn
text: "Cancel"
hoverEnabled: true
background: Rectangle {
radius: Theme.radiusSm
color: cancelBtn.down ? Theme.buttonNeutralPressed : cancelBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: Text {
text: cancelBtn.text
color: Theme.buttonNeutralText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.bold: true
font.pixelSize: 13
}
onClicked: root.close()
}
}
}
}
@@ -0,0 +1,229 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
ColumnLayout {
spacing: 6
// ── Section label ─────────────────────────────────────────────────────
Label {
text: "SOURCE"
color: Theme.bodyColor
font.pixelSize: 10
font.letterSpacing: 1.8
font.weight: Font.Medium
}
// ── Directory button ──────────────────────────────────────────────────
Button {
id: dirBtn
Layout.fillWidth: true
hoverEnabled: true
implicitHeight: 32
leftPadding: 8
rightPadding: 8
background: Rectangle {
radius: Theme.radiusSm
color: dirBtn.pressed ? Theme.buttonNeutralPressed
: dirBtn.hovered ? Theme.buttonNeutralHover
: Theme.buttonNeutralBackground
border.width: Theme.borderThin
border.color: Theme.fieldBorder
}
contentItem: RowLayout {
spacing: 6
Label {
text: "▤"
color: Theme.bodyColor
font.pixelSize: 12
}
Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
color: Theme.headingColor
font.pixelSize: 11
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
onClicked: file_browser.chooseDirectory()
}
// ── Filter ────────────────────────────────────────────────────────────
TextField {
id: filter
Layout.fillWidth: true
placeholderText: "Filter…"
font.pixelSize: 11
color: Theme.fieldText
placeholderTextColor: Theme.fieldPlaceholder
selectedTextColor: Theme.fieldBackground
selectionColor: Theme.fieldText
background: Rectangle {
radius: Theme.radiusXs
color: Theme.fieldBackground
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
}
}
// ── File list ─────────────────────────────────────────────────────────
ListView {
id: fileList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: 2
highlight: null
highlightFollowsCurrentItem: false
model: file_browser.files
delegate: ItemDelegate {
id: fileItem
width: ListView.view.width
padding: 0
highlighted: false
focusPolicy: Qt.NoFocus
readonly property bool matchesFilter: {
if (filter.text === "") return true
var q = filter.text.toLowerCase()
return (modelData.baseName + modelData.waferType + modelData.date)
.toLowerCase().indexOf(q) >= 0
}
// Active only in review mode; no highlight during live streaming
readonly property bool isActive:
streamController.mode === "review" &&
streamController.loadedFile !== "" &&
modelData.fileName === streamController.loadedFile
visible: matchesFilter
height: matchesFilter ? implicitHeight : 0
onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle {
color: fileItem.isActive
? Qt.rgba(1, 1, 1, 0.06)
: fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
radius: Theme.radiusSm
// Left accent bar — shown only when active
Rectangle {
visible: fileItem.isActive
width: 3
radius: 1.5
anchors {
top: parent.top; bottom: parent.bottom; left: parent.left
topMargin: 5; bottomMargin: 5
}
color: {
var t = modelData.waferType
if (t === "A" || t === "E" || t === "P") return "#3B82F6"
if (t === "B" || t === "C" || t === "D") return "#10B981"
if (t === "Z") return "#8B5CF6"
return Theme.headingColor
}
}
}
contentItem: RowLayout {
spacing: 8
anchors.margins: 8
// Wafer-type avatar (circle)
Rectangle {
width: 28; height: 28
radius: 14
color: {
var t = modelData.waferType
if (t === "A" || t === "E" || t === "P") return "#1D4ED8"
if (t === "B" || t === "C" || t === "D") return "#065F46"
if (t === "Z") return "#7C3AED"
return "#374151"
}
Label {
anchors.centerIn: parent
text: modelData.waferType || "?"
font.pixelSize: 12
font.weight: Font.Bold
color: "#FFFFFF"
}
}
// Typography hierarchy
ColumnLayout {
spacing: 1
Layout.fillWidth: true
// Primary: serial number
Label {
text: modelData.serialNumber || modelData.baseName
color: Theme.headingColor
font.pixelSize: 13
font.weight: Font.Bold
elide: Text.ElideRight
Layout.fillWidth: true
}
// Secondary: date · time
RowLayout {
spacing: 3
Layout.fillWidth: true
Label {
text: {
var d = modelData.date || ""
return d.length >= 10 ? d.substring(0, 10) : (d || "—")
}
color: Theme.bodyColor
font.pixelSize: 10
}
Label {
visible: (modelData.timeStr || "") !== ""
text: "·"
color: Theme.bodyColor
font.pixelSize: 10
opacity: 0.45
}
Label {
visible: (modelData.timeStr || "") !== ""
text: modelData.timeStr || ""
color: Theme.bodyColor
font.pixelSize: 10
opacity: 0.65
}
}
// Tertiary: edited metadata (chamber · notes) if any
Label {
visible: {
var c = modelData.chamber || ""
var n = modelData.notes || ""
return c !== "" || n !== ""
}
text: {
var parts = []
if (modelData.chamber) parts.push(modelData.chamber)
if (modelData.notes) parts.push(modelData.notes)
return parts.join(" · ")
}
color: Theme.bodyColor
font.pixelSize: 9
opacity: 0.7
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
}
}
// ── Footer ────────────────────────────────────────────────────────────
Label {
text: file_browser.files.length + " files · CSV only"
color: Theme.bodyColor
font.pixelSize: 10
}
}
@@ -0,0 +1,91 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
RowLayout {
spacing: 2
component TBtn: Button {
implicitWidth: 44
implicitHeight: 32
hoverEnabled: true
font.pixelSize: 13
background: Rectangle {
color: parent.pressed ? Theme.transportButtonHover
: parent.hovered ? Theme.transportButtonBg
: Theme.transportBackground
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
// Left fill — centers the button cluster
Item { Layout.fillWidth: true }
TBtn { text: "⏮"; onClicked: streamController.step(-1) }
TBtn { text: "⏹"; onClicked: streamController.stop() }
TBtn { text: "⏸"; onClicked: streamController.pause() }
// Play: slightly wider; leftPadding nudge compensates for ▶ optical offset
TBtn {
text: "▶"
implicitWidth: 52
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: 2
}
onClicked: streamController.play()
}
TBtn { text: "⏭"; onClicked: streamController.step(1) }
// Right fill — balances centering
Item { Layout.fillWidth: true }
// Speed cycle
Button {
id: speedBtn
property var speeds: [1, 2, 5]
property int idx: 0
implicitWidth: 44
implicitHeight: 32
hoverEnabled: true
text: speeds[idx] + "×"
font.pixelSize: 11
font.weight: Font.Medium
background: Rectangle {
color: parent.pressed ? Theme.transportButtonHover
: parent.hovered ? Theme.transportButtonBg
: Theme.transportBackground
radius: Theme.radiusSm
}
contentItem: Text {
text: parent.text
color: Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: {
idx = (idx + 1) % speeds.length
streamController.setSpeed(speeds[idx])
}
}
Item { width: 8 }
Label {
text: "frame " + (streamController.frameIndex + 1) + " / " + streamController.frameTotal
color: Theme.bodyColor
font.pixelSize: 11
}
}
@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls
import ISC
import ISC.Wafer
Item {
id: root
property real blend: 0.0
property bool showLabels: true
WaferMapItem {
id: map
anchors.fill: parent
sensors: streamController.sensorLayout // [{label,x,y}]
values: streamController.sensorValues // [float]
bands: streamController.sensorBands // ["in_range"|"high"|"low"]
target: streamController.target
margin: streamController.margin
blend: root.blend
showLabels: root.showLabels
// Bind to Theme so colors update on dark/light mode switch
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
TapHandler {
onTapped: (ev) => {
var idx = map.which_marker(ev.position.x, ev.position.y)
if (idx >= 0) replaceDialog.openFor(idx)
}
}
}
ReplaceSensorDialog { id: replaceDialog}
}
+7
View File
@@ -0,0 +1,7 @@
# ===== ISC Tabs Components Module =====
module ISC.Tabs.components
SourcePanel 1.0 SourcePanel.qml
ReadoutPanel 1.0 ReadoutPanel.qml
TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
+8
View File
@@ -0,0 +1,8 @@
# ===== ISC Tabs Module =====
module ISC.Tabs
WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml
StatusTab 1.0 StatusTab.qml
SelectFileDialog 1.0 SelectFileDialog.qml
+28 -10
View File
@@ -21,15 +21,18 @@ import QtQuick
QtObject { QtObject {
// 1. Mode // 1. Mode
property bool isDarkMode: false property bool isDarkMode: true
// 2. Tone palette (base values; consume via semantic tokens below) // 2. Tone palette (base values; consume via semantic tokens below)
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA" readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F2F2F2" readonly property color tone150: isDarkMode ? "#161616" : "#F6F6F6"
readonly property color tone300: isDarkMode ? "#242424" : "#E8E8E8" readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F0F0F0"
readonly property color tone250: isDarkMode ? "#212121" : "#EAEAEA"
readonly property color tone300: isDarkMode ? "#282828" : "#E2E2E2"
readonly property color tone350: isDarkMode ? "#303030" : "#D6D6D6"
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111" readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A" readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
readonly property color toneBorder: isDarkMode ? "#2B2B2B" : "#D8D8D8" readonly property color toneBorder: isDarkMode ? "#2E2E2E" : "#D4D4D4"
// 3. Surfaces // 3. Surfaces
readonly property color pageBackground: tone100 readonly property color pageBackground: tone100
@@ -41,11 +44,12 @@ QtObject {
// 4. Borders // 4. Borders
readonly property color cardBorder: toneBorder readonly property color cardBorder: toneBorder
readonly property color cardSurfaceBorder: isDarkMode ? "#3C3C3C" : "#C8C8C8"
readonly property color responseBorder: toneBorder readonly property color responseBorder: toneBorder
readonly property color workspaceBorder: toneBorder readonly property color workspaceBorder: toneBorder
readonly property color innerFrameBorder: toneBorder readonly property color innerFrameBorder: toneBorder
readonly property color outerFrameBorder: isDarkMode ? "#343434" : "#CECECE" readonly property color outerFrameBorder: isDarkMode ? "#383838" : "#CACACA"
readonly property color softBorder: isDarkMode ? "#222222" : "#E2E2E2" readonly property color softBorder: isDarkMode ? "#252525" : "#E0E0E0"
// 5. Text // 5. Text
readonly property color headingColor: toneText readonly property color headingColor: toneText
@@ -87,17 +91,31 @@ QtObject {
readonly property color sideRailBackground: tone200 readonly property color sideRailBackground: tone200
readonly property color sideActiveBackground: tone300 readonly property color sideActiveBackground: tone300
// 10a. Transport / toolbar surfaces
readonly property color transportBackground: isDarkMode ? "#0D0D0D" : "#E8E8E8"
readonly property color transportButtonBg: isDarkMode ? "#2A2A2A" : "#D4D4D4"
readonly property color transportButtonHover: isDarkMode ? "#3A3A3A" : "#C4C4C4"
readonly property color liveColor: isDarkMode ? "#22C55E" : "#16A34A"
readonly property color recordColor: isDarkMode ? "#EF4444" : "#DC2626"
// 10. Status // 10. Status
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44" readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18" readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545" readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
// -- 10b. Sensor bands (wafer map dots)
readonly property color sensorInRange: statusSuccessColor
readonly property color sensorHigh: statusErrorColor
readonly property color sensorLow: isDarkMode ? "#5B9DF5" : "#2F6FE0"
readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder
// 11. Geometry // 11. Geometry
// Radius // Radius
readonly property int radiusXs: 4 // fields, tight elements readonly property int radiusXs: 6 // fields, tight elements
readonly property int radiusSm: 6 // buttons readonly property int radiusSm: 8 // buttons
readonly property int radiusMd: 10 // cards, group boxes readonly property int radiusMd: 12 // cards, group boxes
readonly property int radiusLg: 14 // large panels / dialogs readonly property int radiusLg: 18 // large panels / dialogs
// Border width // Border width
readonly property int borderThin: 1 readonly property int borderThin: 1
readonly property int borderStrong: 2 readonly property int borderStrong: 2
View File
View File
+20 -7
View File
@@ -5,13 +5,16 @@ from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from backend.device_controller import DeviceController from pygui.backend.controllers.device_controller import DeviceController
from backend.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from backend.local_settings_model import LocalSettingsModel from pygui.backend.data.local_settings_model import LocalSettingsModel
from backend.file_browser import FileBrowser from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.controllers.session_controller import SessionController
# Importing wafer_map_item registers the @QmlElement WaferMapItem (QML: import ISC.Wafer).
from pygui.backend.visualization.wafer_map_item import WaferMapItem
# ===== Application Entry Point ===== # ===== Application Entry Point =====
if __name__ == "__main__": def main() -> int:
# ===== UI Style Setup ===== # ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported. # Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic") QQuickStyle.setStyle("Basic")
@@ -34,12 +37,22 @@ if __name__ == "__main__":
device_controller = DeviceController(raw_settings, data_dir) device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== Session Controller (live/review wafer dashboard) =====
stream_controller = SessionController()
engine.rootContext().setContextProperty("streamController", stream_controller)
# ===== QML Startup ===== # ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir.
engine.addImportPath(Path(__file__).parent) engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("ISC", "Main") engine.loadFromModule("ISC", "Main")
# ===== Exit Handling ===== # ===== Exit Handling =====
if not engine.rootObjects(): if not engine.rootObjects():
sys.exit(-1) return -1
sys.exit(app.exec()) return app.exec()
if __name__ == "__main__":
sys.exit(main())
View File
+44
View File
@@ -0,0 +1,44 @@
# This will be the name of the output files
name: aepwafer
wafers: ["A", "E", "P"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
4: { top: [0, 0] },
5: { top: [0, 0] },
6: { top: [0, 0] },
}
+46
View File
@@ -0,0 +1,46 @@
# This will be the name of the output files
name: bcdwafer
wafers: ["B", "C", "D"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 300
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
6: { left: [1, 0] },
}
+43
View File
@@ -0,0 +1,43 @@
# This will be the name of the output files
name: fwafer
wafers: ["F"]
# round or square wafer
shape: round
# diameter/edge size of the wafer
size: 200
# large or small markers and labels
marker_size: large
font_size: large
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the origin
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
2: { left: [1, 0] },
3: { left: [1, 0] },
4: { left: [1, 0] },
5: { left: [1, 0] },
}
+69
View File
@@ -0,0 +1,69 @@
# This will be the name of the output files
name: xwafer
wafers: ["X"]
# round or square wafer
shape: square
# diameter/edge size of the wafer
size: 310
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top implies that the X/Y values
# for that axis are negative, or else we'll be drawing outside the image.
x_origin: left
y_origin: bottom
# sensor X and Y values, in mm relative to the origin
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
133.29, 176.72, 176.72]
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
176.79, 133.36 ]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 1
# Normally, positive X is to the right, and positive Y is up.
# These settings can be used to reverse one or both of these.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
21: { left: [0, 0] },
22: { left: [0, 0] },
23: { left: [0, 0] },
24: { left: [0, 0] },
25: { left: [0, 0] },
26: { left: [0, 0] },
27: { left: [0, 0] },
28: { left: [0, 0] },
29: { left: [0, 0] },
30: { left: [0, 0] },
31: { left: [0, 0] },
32: { left: [0, 0] },
33: { left: [0, 0] },
34: { left: [0, 0] },
35: { left: [0, 0] },
36: { left: [0, 0] }
}
+68
View File
@@ -0,0 +1,68 @@
# This will be the name of the output files
name: zwafer
wafers: ["Z"]
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: false
reverse_y: false
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
33: { left: [0, 0] },
34: { left: [0, 0] },
41: { top: [0, 0] },
42: { bottom: [0, 0] },
45: { top: [1, 0] },
46: { bottom: [0.5, 0] },
49: { left: [0, 0] },
50: { bottom: [-1.75, -0.5] },
51: { top: [-1, 0] },
52: { top: [-1, 0] },
53: { bottom: [0, 0] },
54: { left: [0, -0.75] },
55: { right: [0, 0.5] },
58: { bottom: [-0.75, 0] },
60: { top: [0, 0] },
62: { right: [0, 1] },
63: { right: [0, 0] },
64: { left: [0, 0.25] }
}
+71
View File
@@ -0,0 +1,71 @@
# This will be the name of the output files
name: zwafer_rev
wafers: [] # the reversed version doesn't represent any real wafer
# round or square wafer
shape: round
# diameter/size of the wafer
size: 300
# large or small markers and labels
marker_size: small
font_size: small
# origin settings. x_origin can be left, right, or center. y_origin can
# be top, bottom, or center. Using right or top probably also needs
# reverse_x/reverse_y to be set as well.
x_origin: center
y_origin: center
# sensor X and Y values, in mm relative to the wafer center
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
# set this to either 0 or 1 for the initial sensor number (usually 1)
start_sn: 0
# If one or both axes are reversed, this can be used to fix it
# without having to edit all of the coordinates.
# These should be false for most normal wafers.
reverse_x: true
reverse_y: true
# these are sensors whose labels need to be repositioned. Sensor numbers
# start at start_sn. Directions are up, down, left, and right, and values
# are multiples of the marker size.
label_exceptions: {
37: { left: [0, 0] },
38: { left: [0, 0] },
39: { left: [0, 0] },
41: { top: [1.5, 0] },
42: { bottom: [1, 0] },
43: { left: [0, -1] },
45: { bottom: [-1, -1] },
46: { bottom: [0, -1] },
47: { right: [0, 1] },
48: { left: [0, 0] },
50: { bottom: [-1, 0] },
52: { top: [0, 0] },
54: { right: [0.5, 1] },
56: { left: [0, 0.5] },
57: { left: [0, 0] },
58: { top: [0.5, 0] },
59: { left: [0, 0] },
60: { top: [-1, 0] },
61: { left: [0, 0] },
62: { left: [0, -1] },
63: { right: [0, 1] },
}
+32
View File
@@ -0,0 +1,32 @@
# ===== Backend Package =====
# Backward-compatible re-exports so existing callers keep working
# while imports are migrated to the new sub-package paths.
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
from pygui.backend.models.frame import Frame # noqa: F401
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
from pygui.backend.models.session_model import SessionModel # noqa: F401
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
from pygui.backend.data.data_records import read_data_records # noqa: F401
from pygui.backend.data.data_segment import DataSegment # noqa: F401
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment # noqa: F401
from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor, DataRecord # noqa: F401
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
from pygui.backend.wafer.wafer_layouts import load_layout, available_families # noqa: F401
@@ -0,0 +1,6 @@
# ===== Controllers Sub-package =====
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
__all__ = ["DeviceController", "SessionController"]
@@ -15,17 +15,22 @@ from typing import Any, Optional
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
from backend.data_model import TemperatureTableModel from pygui.backend.models.data_model import TemperatureTableModel
from backend.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
from backend.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_temperatures, convert_to_temperatures,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_to_csv, save_to_csv,
) )
from serialcomm.device_service import DeviceService from pygui.serialcomm.device_service import DeviceService
from serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
from pygui.backend.controllers.session_controller import SessionController
# import pygui.backend.wafer_map_item
stream_controller = SessionController()
# engine.rootContext().setContextProperty("streamController", stream_controller)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -88,8 +93,8 @@ class DeviceController(QObject):
self.statusRestored.emit() self.statusRestored.emit()
# Marshal worker-thread results onto the Qt main thread. # Marshal worker-thread results onto the Qt main thread.
self._detectFinished.connect(self._handle_detect_finished, Qt.QueuedConnection) self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
self._readFinished.connect(self._handle_read_finished, Qt.QueuedConnection) self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
# ---- Properties ---- # ---- Properties ----
@Property(list, notify=portsUpdated) @Property(list, notify=portsUpdated)
@@ -504,7 +509,7 @@ class DeviceController(QObject):
self._settings.last_csv_path = self._last_csv_path self._settings.last_csv_path = self._last_csv_path
# Save to disk # Save to disk
LocalSettings.save_settings(LocalSettings._settings_path(self._data_dir), self._settings) LocalSettings.save_settings(str(LocalSettings._settings_path(self._data_dir)), self._settings)
# ---- Helpers ---- # ---- Helpers ----
@@ -0,0 +1,338 @@
"""QML-facing controller for the live/review wafer dashboard."""
from __future__ import annotations
import logging
from typing import Any, Optional
from PySide6.QtCore import QObject, Property, QTimer, Qt, Signal, Slot
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.session_model import SessionModel
from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader
from pygui.backend.models.sensor_editor import SensorEditor
log = logging.getLogger(__name__)
MODE_LIVE = "live"
MODE_REVIEW = "review"
class SessionController(QObject):
# public signals
frameUpdated = Signal() # any property below may have changed
modeChanged = Signal()
stateChanged = Signal()
recordingChanged = Signal()
sensorsChanged = Signal()
loadedFileChanged = Signal()
# private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._mode = MODE_REVIEW
self._model = SessionModel()
self._player = FramePlayer()
self._reader: Optional[StreamReader] = None
self._recorder = CsvRecorder()
self._sensors: list[Sensor] = []
self._last = None # last SessionUpdate
self._elapsed = 0.0
self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance)
self._speed = 1.0
# Q1: coalesce live repaints to ~20 Hz; data is still processed every frame.
self._repaint_timer = QTimer(self)
self._repaint_timer.setInterval(50) # ~20 Hz
self._repaint_timer.timeout.connect(self._flush_repaint)
self._dirty = False
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
self._sensor_editor = SensorEditor()
self._last_raw_frame: Frame | None = None
self._loaded_file: str = ""
# ---- properties QML binds to ----
@Property(str, notify=modeChanged)
def mode(self) -> str: return self._mode
@Property(int, notify=frameUpdated)
def frameIndex(self) -> int: return self._player.index
@Property(int, notify=frameUpdated)
def frameTotal(self) -> int: return self._player.total
@Property(str, notify=stateChanged)
def state(self) -> str: return self._last.state if self._last else "idle"
@Property(bool, notify=recordingChanged)
def recording(self) -> bool: return self._recorder.is_recording
@Property(list, notify=frameUpdated)
def sensorDots(self) -> list[dict]:
"""Per-sensor render data for the radial map."""
if not self._last:
return []
out = []
for i, s in enumerate(self._sensors):
v = self._last.values[i] if i < len(self._last.values) else 0.0
band = self._last.bands[i] if i < len(self._last.bands) else "in_range"
out.append({"label": s.label, "x": s.x, "y": s.y,
"value": round(v, 2), "band": band, "index": i})
return out
# ---- WaferMapItem bindings (split view of sensorDots) ----
@Property("QVariantList", notify=sensorsChanged)
def sensorLayout(self) -> list:
"""[{label, x, y}] for WaferMapItem.sensors."""
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
@Property("QVariantList", notify=frameUpdated)
def sensorValues(self) -> list:
"""[float] in sensor order."""
if not self._last:
return []
return [round(v, 3) for v in self._last.values]
@Property("QVariantList", notify=frameUpdated)
def sensorBands(self) -> list:
"""['in_range'|'high'|'low'] in sensor order."""
if not self._last:
return []
return list(self._last.bands)
@Property(float, notify=frameUpdated)
def target(self) -> float:
"""Resolved band center (frame mean in auto mode, else set_point)."""
return self._last.target if self._last else 149.0
@Property(float, notify=frameUpdated)
def margin(self) -> float:
"""Resolved band half-width (frame 1σ in auto mode, else margin)."""
return self._last.margin if self._last else 1.0
@Property(dict, notify=frameUpdated)
def stats(self) -> dict:
if not self._last:
return {}
s = self._last.stats
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
@Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file
@Property("QVariantList", notify=frameUpdated)
def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset."""
return self._sensor_editor.active_indices()
# ---- mode + thresholds ----
@Slot(str)
def setMode(self, mode: str) -> None:
if mode == self._mode:
return
self.stopStream()
self._play_timer.stop()
self._mode = mode
self.modeChanged.emit()
@Slot(float, float, bool)
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
if self._last: # re-band current frame in place
self._reprocess_current()
# ---- review: file load + playback ----
@Slot(str)
def loadFile(self, file_path: str) -> None:
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
sensors: list[Sensor] = []
frames = []
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records)
else:
try:
data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc)
return
if data is None or not data.sensors:
log.warning("Could not parse %s", file_path)
return
records = read_data_records(file_path)
sensors = data.sensors
frames = frames_from_wafer_data(data, records)
if not sensors or not frames:
log.warning("No sensors or data in %s", file_path)
return
self._sensors = sensors
self._player.load(frames)
self._model.reset()
self._loaded_file = file_path
self.loadedFileChanged.emit()
self.sensorsChanged.emit()
self._emit_current()
@Slot()
def play(self) -> None:
self._play_timer.start(self._next_interval_ms())
@Slot()
def pause(self) -> None: self._play_timer.stop()
@Slot()
def stop(self) -> None:
self._play_timer.stop()
self._player.seek(0)
self._emit_current()
@Slot(int)
def step(self, delta: int) -> None:
self._play_timer.stop()
self._player.step(delta)
self._emit_current()
@Slot(int)
def seek(self, i: int) -> None:
self._player.seek(i)
self._emit_current()
@Slot(float)
def setSpeed(self, x: float) -> None:
self._speed = max(0.1, x)
if self._play_timer.isActive():
self._play_timer.start(self._next_interval_ms())
def _next_interval_ms(self) -> int:
"""Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
ms = self._player.next_frame_ms()
if ms <= 0:
ms = 500.0
return max(1, int(ms / self._speed))
def _advance(self) -> None:
if self._player.at_end:
self._play_timer.stop()
return
self._player.step(1)
self._emit_current()
self._play_timer.start(self._next_interval_ms())
def _emit_current(self) -> None:
frame = self._player.current()
if frame is None:
return
self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values))
self._last = self._model.process(edited)
self.frameUpdated.emit()
self.stateChanged.emit()
def _reprocess_current(self) -> None:
"""Re-run the model on the last raw frame"""
frame = self._last_raw_frame
if frame is None:
return
edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values))
self._last = self._model.process(edited)
self.frameUpdated.emit()
self.stateChanged.emit()
# ---- live: stream start/stop ----
@Slot(str)
def startStream(self, port: str) -> None:
from pygui.serialcomm.serial_port import SerialPort # transport open
import serial as pyserial
def parse_line(raw: str, seq: int) -> Frame:
parts = [float(x) for x in raw.split(",")]
return Frame(seq=seq, time=parts[0], values=parts[1:])
transport = pyserial.Serial(port, SerialPort.BAUDRATE, timeout=1)
self._reader = StreamReader(
transport, parse_line,
on_frame=lambda f: self._liveFrame.emit(f))
self._reader.start()
self._repaint_timer.start() # Q1: begin ~20 Hz repaints
@Slot()
def stopStream(self) -> None:
self._repaint_timer.stop()
if self._reader is not None:
self._reader.stop()
self._reader = None
self.stopRecording()
@Slot(object)
def _on_live_frame(self, frame: Frame) -> None:
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time,
values=self._sensor_editor.apply(frame.values))
self._last = self._model.process(edited)
if self._recorder.is_recording:
self._recorder.write(frame) # always record raw values, never edited
self._dirty = True
def _flush_repaint(self) -> None:
if not self._dirty:
return
self._dirty = False
self.frameUpdated.emit()
self.stateChanged.emit()
# ---- recording ----
@Slot(str, str)
def startRecording(self, path: str, serial: str = "") -> None:
self._recorder.start(path, self._sensors, serial)
self.recordingChanged.emit()
@Slot()
def stopRecording(self) -> None:
if self._recorder.is_recording:
self._recorder.stop()
self.recordingChanged.emit()
@Slot(int, float)
def replaceSensor(self, index: int, value: float) -> None:
"""Override sensor `index` to display `value` every frame."""
self._sensor_editor.set_replacement(index, value)
self._reprocess_current()
@Slot(int, float)
def offsetSensor(self, index: int, delta: float) -> None:
"""Shift sensor `index` by `delta` every frame."""
self._sensor_editor.set_offset(index, delta)
self._reprocess_current()
@Slot(int)
def clearSensorEdit(self, index: int) -> None:
"""Remove all overrides for sensor `index`."""
self._sensor_editor.clear(index)
self._reprocess_current()
@Slot()
def clearSensorEdits(self) -> None:
"""Remove all sensor overrides."""
self._sensor_editor.clear()
self._reprocess_current()
+2
View File
@@ -0,0 +1,2 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
+16
View File
@@ -0,0 +1,16 @@
# ===== Data Sub-package =====
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.data.data_records import read_data_records, is_official_csv, read_official_csv
from pygui.backend.data.data_segment import DataSegment
from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
__all__ = [
"CsvRecorder", "CSVFileMetadata",
"read_data_records", "is_official_csv", "read_official_csv",
"DataSegment",
"FileBrowser",
"LocalSettings", "LocalSettingsModel",
]
+46
View File
@@ -0,0 +1,46 @@
"""Append live frames"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Optional, TextIO
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder:
def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None
@property
def is_recording(self) -> bool:
return self._file_handle is not None
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline ="")
file_handle.write(f"Wafer ID={serial}\n")
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
file_handle.write("X (mm)," + ",".join(str(s.x) for s in sensors) + "\n")
file_handle.write("Y (mm)," + ",".join(str(s.y) for s in sensors) + "\n")
file_handle.write("data\n")
file_handle.flush()
self._file_handle, self._path = file_handle, path
def write(self, frame: Frame) -> None:
if self._file_handle is None:
return
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
self._file_handle.write(",".join(row) + "\n")
self._file_handle.flush()
def stop(self) -> Optional[str]:
if self._file_handle is None:
return None
self._file_handle.close()
path, self._file_handle, self._path = self._path, None, None
return path
+66
View File
@@ -0,0 +1,66 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations
from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = []
in_data = False
with Path(file_path).open("r", encoding="utf-8") as file_handle:
for line in file_handle:
line = line.rstrip().rstrip(",").strip()
if not line:
continue
if not in_data:
if line.split(",")[0].lower() == "data":
in_data = True
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try:
nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:]))
return records
def is_official_csv(file_path: str) -> bool:
"""Return True if the file uses the headerless official format (sensor-name header row, no 'data' sentinel)."""
with Path(file_path).open("r", encoding="utf-8") as f:
first = f.readline().rstrip().rstrip(",")
if not first:
return False
parts = [p.strip() for p in first.split(",") if p.strip()]
# Official format: first cell looks like "SensorN" (starts with letter, not key=value)
return bool(parts) and parts[0].lower().startswith("sensor") and "=" not in parts[0]
def read_official_csv(file_path: str) -> tuple[list[Sensor], list[DataRecord]]:
"""Parse the headerless official CSV: row 0 = sensor names, rows 1+ = values (no timestamp)."""
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
stem = Path(file_path).stem
wafer_id = stem.split("-")[0] if "-" in stem else stem
try:
sensors = load_layout_for_wafer_id(wafer_id)
except KeyError:
return [], []
records: list[DataRecord] = []
with Path(file_path).open("r", encoding="utf-8") as f:
f.readline() # skip sensor-name header
for i, line in enumerate(f):
line = line.rstrip().rstrip(",")
if not line:
continue
cols = [c.strip() for c in line.split(",") if c.strip()]
try:
values = [float(c) for c in cols]
except ValueError:
continue
if values:
records.append(DataRecord(time=float(i) * 0.5, values=values))
return sensors, records
@@ -8,8 +8,8 @@ from pathlib import Path
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox from PySide6.QtWidgets import QFileDialog, QMessageBox
from backend.csv_file_metadata import CSVFileMetadata from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from backend.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
# ===== File Browser Model ===== # ===== File Browser Model =====
@@ -120,24 +120,37 @@ class FileBrowser(QObject):
for csv_path in sorted(self._current_directory.glob("*.csv")): for csv_path in sorted(self._current_directory.glob("*.csv")):
try: try:
metadata = self._load_metadata(csv_path) metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
date_str = metadata.string_date_format()
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
time_str = date_str[11:] if len(date_str) > 10 else ""
self._files.append( self._files.append(
{ {
"selected": selected_state.get(str(csv_path), False), "selected": selected_state.get(str(csv_path), False),
"baseName": csv_path.stem,
"wafer": metadata.wafer, "wafer": metadata.wafer,
"date": metadata.string_date_format(), "waferType": wafer_type,
"serialNumber": serial,
"date": date_str,
"timeStr": time_str,
"chamber": metadata.chamber, "chamber": metadata.chamber,
"notes": metadata.notes, "notes": metadata.notes,
"masterType": master_state.get(str(csv_path), "") or metadata.master_type, "masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": metadata.get_wafer_type() in {"A", "B", "C"}, "highlight": wafer_type in {"A", "B", "C"},
} }
) )
except Exception: except Exception:
stem = csv_path.stem
self._files.append( self._files.append(
{ {
"selected": selected_state.get(str(csv_path), False), "selected": selected_state.get(str(csv_path), False),
"wafer": csv_path.stem, "baseName": stem,
"wafer": stem,
"waferType": stem[0].upper() if stem else "",
"serialNumber": stem[1:] if len(stem) > 1 else "",
"date": "", "date": "",
"timeStr": "",
"chamber": "", "chamber": "",
"notes": "Unable to parse metadata", "notes": "Unable to parse metadata",
"masterType": master_state.get(str(csv_path), ""), "masterType": master_state.get(str(csv_path), ""),
@@ -186,7 +199,7 @@ class FileBrowser(QObject):
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata: def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
match = re.match( match = re.match(
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\d+))?$", r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
csv_path.stem, csv_path.stem,
) )
if not match: if not match:
@@ -194,7 +207,9 @@ class FileBrowser(QObject):
date_text = "" date_text = ""
try: try:
parsed = datetime.strptime(match.group("date"), "%Y%m%d") date_part = match.group("date")
time_part = match.group("time") or "000000"
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
date_text = CSVFileMetadata.format_date(parsed) date_text = CSVFileMetadata.format_date(parsed)
except ValueError: except ValueError:
date_text = "" date_text = ""
@@ -236,7 +251,7 @@ class FileBrowser(QObject):
def showInfo(self, message: str) -> None: def showInfo(self, message: str) -> None:
self._show_info(message) self._show_info(message)
@Slot(str, result="QVariantMap") @Slot(str, result=dict)
def parseCsvMetadata(self, file_path: str) -> dict: def parseCsvMetadata(self, file_path: str) -> dict:
from pathlib import Path as _Path from pathlib import Path as _Path
@@ -6,7 +6,7 @@ from typing import Any
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
from backend.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
@@ -221,7 +221,7 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit() self.waferRetriesChanged.emit()
self._recompute_derived() self._recompute_derived()
@Property("QVariantMap", notify=mastersChanged) @Property(dict, notify=mastersChanged)
def masters(self) -> dict[str, str]: def masters(self) -> dict[str, str]:
return dict(self._masters) return dict(self._masters)
+19
View File
@@ -0,0 +1,19 @@
# ===== Models Sub-package =====
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
from pygui.backend.models.stability_detector import StabilityDetector
__all__ = [
"Frame", "FramePlayer", "frames_from_wafer_data",
"Stats", "compute_stats",
"SessionModel", "SessionUpdate",
"TemperatureTableModel",
"SensorEditor",
"ThresholdConfig", "classify", "resolve_bounds",
"StabilityDetector",
]
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Frame:
"""One sample across all sensors ata a point in time"""
seq: int # monotonically increasing
time: float # seconds (relative or epoch)
values: list[float] # one per sensor, in sensor-layout order
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import ZWaferData
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
class FramePlayer:
def __init__(self) -> None:
self._frames: list[Frame] =[]
self._i =0
def load(self, frames: list[Frame]) -> "FramePlayer":
self._frames = frames
self._i = 0
return self
@property
def total(self) -> int:
return len(self._frames)
@property
def index(self) -> int:
return self._i
@property
def at_end(self) -> bool:
return self._i >= len(self._frames) -1
def current(self) -> Frame | None:
if not self._frames:
return None
return self._frames[self._i]
def _clamp(self, i:int):
return max(0, min(i, len(self._frames) - 1)) if self._frames else 0
def step(self, delta: int) -> "FramePlayer":
self._i = self._clamp(self._i + delta)
return self
def seek(self, i: int) -> "FramePlayer":
self._i = self._clamp(i)
return self
def next_frame_ms(self) -> float:
"""Wall-clock ms until the next frame based on recorded timestamps; 0 if at end."""
if self._i + 1 >= len(self._frames):
return 0.0
return (self._frames[self._i + 1].time - self._frames[self._i].time) * 1000.0
+37
View File
@@ -0,0 +1,37 @@
"""Per-frame descriptive statistics"""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Stats:
min: float; min_index: int
max: float; max_index: int
diff: float; avg: float
sigma: float; three_sigma: float
def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean:
return Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
min_index, min_v = min(clean, key=lambda iv: iv[1])
max_index, max_v = max(clean, key=lambda iv: iv[1])
nums = [v for _, v in clean]
avg = sum(nums) / len(nums)
variance = sum((v - avg) ** 2 for v in nums) / len(nums)
sigma = math.sqrt(variance)
return Stats(
min=min_v,
min_index=min_index,
max=max_v,
max_index=max_index,
diff=max_v - min_v,
avg=avg,
sigma=sigma,
three_sigma=3 * sigma,
)
+48
View File
@@ -0,0 +1,48 @@
"""Per-session sensor value overrides (replacement and/or offset)."""
from __future__ import annotations
class SensorEditor:
def __init__(self) -> None:
self._replacements: dict[int, float] = {}
self._offsets: dict[int, float] = {}
def set_replacement(self, index: int, value: float) -> None:
"""Force sensor `index` to read `value` for every frame this session."""
self._replacements[index] = value
def set_offset(self, index: int, value: float) -> None:
"""Shift sensor `index` by `delta` (applied after any replacement)."""
self._offsets[index] = value
def clear(self, index: int | None = None) -> None:
"""Remove overrides. Pass an index to clear one sensor; omit to clear all."""
if index is None:
self._replacements.clear()
self._offsets.clear()
else:
self._replacements.pop(index, None)
self._offsets.pop(index, None)
def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets)
def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets))
def apply(self, values: list[float]) -> list[float]:
"""Return a new list wit all overrides applied (original unchanged)"""
if not self.has_overrides():
return list(values)
result = list(values)
for i in range(len(result)):
v = result[i]
if i in self._replacements:
v = self._replacements[i]
if i in self._offsets:
v += self._offsets[i]
result[i] = v
return result
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_stats import Stats, compute_stats
from pygui.backend.models.stability_detector import StabilityDetector
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
@dataclass(frozen=True)
class SessionUpdate:
seq: int
values: list[float]
bands: list[str]
stats: Stats
state: str
target: float
margin: float
class SessionModel:
def __init__(self, thresholds: ThresholdConfig | None = None) -> None:
self._config = thresholds or ThresholdConfig()
self._stability = StabilityDetector()
def set_thresholds(self, config: ThresholdConfig) -> None:
self._config = config
def reset(self) -> None:
self._stability.reset()
def process(self, frame: Frame) -> SessionUpdate:
stats = compute_stats(frame.values)
target, margin = resolve_bounds(frame.values, self._config)
bands = [classify(v, target, margin)for v in frame.values]
# State always track the user's process set_point, even in auto-color mode.
state = self._stability.update(stats.avg, frame.time, self._config.set_point)
return SessionUpdate(
seq=frame.seq, values=frame.values, bands=bands,
stats=stats, state=state, target=target, margin=margin,
)
@@ -0,0 +1,34 @@
"""Detect process state (Idle, Ramp, Set) from the running average temp"""
from __future__ import annotations
from typing import Optional
STATE_IDLE = "idle"
STATE_RAMP = "ramp"
STATE_SET = "set"
class StabilityDetector:
def __init__(self, idle_below: float = 50.0, tolerance: float = 1.0,
settle_seconds: float = 10.0) -> None:
self._idle_below = idle_below
self._tolerance = tolerance
self._settle_seconds = settle_seconds
self._near_since: Optional[float] = None # When avg entered the +- tolerance band
def reset(self) -> None:
self._near_since = None
def update(self, avg: float, time: float, set_point: float) ->str:
if avg < self._idle_below:
self._near_since = None
return STATE_IDLE
if abs(avg - set_point) <= self._tolerance:
if self._near_since is None:
self._near_since = time
if time - self._near_since >= self._settle_seconds:
return STATE_SET
return STATE_RAMP
self._near_since = None
return STATE_RAMP
@@ -0,0 +1,42 @@
"""Classify sensor values into three bands around (target, margin)
Auto mode derives target=mean, margin=1
"""
from __future__ import annotations
import math
from dataclasses import dataclass
BAND_IN = "in_range"
BAND_HIGH = "high"
BAND_LOW = "low"
@dataclass(frozen=True)
class ThresholdConfig:
set_point: float = 149.0 # process target: used as band TARGET when auto=False
margin: float = 1.0 # used as band MARGIN when auto=False
auto: bool = True # auto=True: target=frame mean, margin=frame 1σ
def resolve_bounds(values: list[float], cfg: ThresholdConfig) -> tuple[float, float]:
if not cfg.auto:
return cfg.set_point, cfg.margin
clean = [v for v in values if not math.isnan(v)]
if not clean:
return cfg.set_point, cfg.margin
mean = sum(clean) / len(clean)
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
return mean, math.sqrt(variance)
def classify(value: float, target: float, margin: float) -> str:
if math.isnan(value):
return BAND_IN
if value > target + margin:
return BAND_HIGH
if value < target - margin:
return BAND_LOW
return BAND_IN
def classify_all(values: list[float], cfg: ThresholdConfig) -> list[str]:
target, margin = resolve_bounds(values, cfg)
return [classify(v, target, margin)for v in values ]
@@ -0,0 +1,11 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.wafer_map_item import WaferMapItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
__all__ = [
"WaferMapItem", "GraphView",
"interpolate_field",
"ContourLine", "ContourSegment",
]
@@ -2,7 +2,7 @@ from typing import List, Tuple, Optional
import numpy as np import numpy as np
from backend.contour_models import ContourLine, ContourSegment from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
# ===== Contour Generation ===== # ===== Contour Generation =====
@@ -0,0 +1,61 @@
"""RBF (thin-plate spline) heatmap field.
Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy.
"""
from __future__ import annotations
import numpy as np
from scipy.interpolate import RBFInterpolator
try:
import cupy as _cupy # type: ignore
BACKEND = "cupy"
except Exception:
_cupy = None
BACKEND = "numpy"
_KERNEL = "thin_plate_spline"
_SMOOTHING = 0.0
def interpolate_field(
xs: np.ndarray,
ys: np.ndarray,
vs: np.ndarray,
*,
width: int,
height: int,
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
round_clip: bool = False,
) -> np.ndarray:
"""Return a (height, width) float64 array of interpolated values.
Args:
xs, ys: sensor positions in mm (1-D arrays, length N)
vs: sensor values (length N)
width/height: output grid dimensions in pixels
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
round_clip: if True, pixels outside the inscribed ellipse become NaN
"""
coords = np.column_stack([xs, ys])
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
xmin, xmax, ymin, ymax = extent
gx = np.linspace(xmin, xmax, width)
gy = np.linspace(ymin, ymax, height)
grid_x, grid_y = np.meshgrid(gx, gy)
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
field = rbf(flat).reshape(height, width)
if round_clip:
cx = (xmin + xmax) / 2
cy = (ymin + ymax) / 2
rx = (xmax - xmin) / 2
ry = (ymax - ymin) / 2
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
field = np.where(dist <= 1.0, field, np.nan)
return field.astype(np.float64)
@@ -0,0 +1,406 @@
"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.
Draws:
• Radial ring template (concentric guides + crosshair axes + top notch)
• RBF heatmap layer (blended under markers via `blend` 0→1)
• Sensor marker circles colored by band (low/in_range/high)
• Numbered labels (toggle via `showLabels`)
All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
"""
from __future__ import annotations
import math
import numpy as np
from PySide6.QtCore import Property, QPoint, Signal, Slot, Qt
from PySide6.QtGui import (
QBrush, QColor, QFont, QImage, QPainter, QPen, QPolygon,
)
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.wafer.zwafer_models import Sensor
QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1
@QmlElement
class WaferMapItem(QQuickPaintedItem):
"""Painted wafer map; driven by SessionController via QML property bindings."""
sensorsChanged = Signal()
valuesChanged = Signal()
bandsChanged = Signal()
targetChanged = Signal()
marginChanged = Signal()
blendChanged = Signal()
showLabelsChanged = Signal()
colorsChanged = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._sensors: list[Sensor] = []
self._values: list[float] = []
self._bands: list[str] = []
self._target: float = 149.0
self._margin: float = 1.0
self._blend: float = 0.0
self._show_labels: bool = True
# Dark-theme color defaults (match Theme.qml tokens)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder)
self._low_color = QColor("#5B9DF5") # sensorLow
self._in_range_color = QColor("#22C55E") # sensorInRange
self._high_color = QColor("#EF4444") # sensorHigh
self._text_color = QColor("#CBD5E1") # bodyColor
# Internal draw state
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
self._marker_r: int = 4
self._heatmap: QImage | None = None
self.widthChanged.connect(self._on_resize)
self.heightChanged.connect(self._on_resize)
# ── Qt properties ────────────────────────────────────────────────────
@Property("QVariantList", notify=sensorsChanged)
def sensors(self) -> list:
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
@sensors.setter
def sensors(self, val: list) -> None:
self._sensors = [Sensor(label=d["label"], x=float(d["x"]), y=float(d["y"]))
for d in (val or [])]
self._rebuild()
self.sensorsChanged.emit()
@Property("QVariantList", notify=valuesChanged)
def values(self) -> list:
return self._values
@values.setter
def values(self, val: list) -> None:
self._values = list(val or [])
self._rebuild_heatmap()
self.valuesChanged.emit()
self.update()
@Property("QVariantList", notify=bandsChanged)
def bands(self) -> list:
return self._bands
@bands.setter
def bands(self, val: list) -> None:
self._bands = list(val or [])
self.bandsChanged.emit()
self.update()
@Property(float, notify=targetChanged)
def target(self) -> float:
return self._target
@target.setter
def target(self, val: float) -> None:
self._target = float(val)
self._rebuild_heatmap()
self.targetChanged.emit()
self.update()
@Property(float, notify=marginChanged)
def margin(self) -> float:
return self._margin
@margin.setter
def margin(self, val: float) -> None:
self._margin = float(val)
self._rebuild_heatmap()
self.marginChanged.emit()
self.update()
@Property(float, notify=blendChanged)
def blend(self) -> float:
return self._blend
@blend.setter
def blend(self, val: float) -> None:
self._blend = max(0.0, min(1.0, float(val)))
if self._blend > 0 and self._heatmap is None:
self._rebuild_heatmap()
self.blendChanged.emit()
self.update()
@Property(bool, notify=showLabelsChanged)
def showLabels(self) -> bool:
return self._show_labels
@showLabels.setter
def showLabels(self, val: bool) -> None:
self._show_labels = bool(val)
self.showLabelsChanged.emit()
self.update()
# Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color
@ringColor.setter
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor: return self._axis_color
@axisColor.setter
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def lowColor(self) -> QColor: return self._low_color
@lowColor.setter
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def inRangeColor(self) -> QColor: return self._in_range_color
@inRangeColor.setter
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def highColor(self) -> QColor: return self._high_color
@highColor.setter
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
@Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor: return self._text_color
@textColor.setter
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
# ── slots ─────────────────────────────────────────────────────────────
@Slot(float, float, result=int)
def which_marker(self, x: float, y: float) -> int:
"""Return the sensor index nearest to (x, y) within marker radius, else -1."""
r = max(self._marker_r, 6)
for idx, (mx, my) in self._markers.items():
if abs(mx - x) <= r and abs(my - y) <= r:
return idx
return -1
# ── internal ─────────────────────────────────────────────────────────
def _on_resize(self) -> None:
self._rebuild()
def _rebuild(self) -> None:
self._compute_markers()
self._rebuild_heatmap()
self.update()
def _draw_size(self) -> int:
return max(1, int(min(self.width(), self.height())))
def _center(self) -> tuple[int, int]:
return int(self.width() / 2), int(self.height() / 2)
def _wafer_radius_mm(self) -> float:
"""Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
if not self._sensors:
return 150.0
r = max(math.hypot(s.x, s.y) for s in self._sensors)
return r * 1.05
def _sensor_ring_radii_mm(self) -> list[float]:
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
if not self._sensors:
r = self._wafer_radius_mm()
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
groups: list[float] = []
for r in radii:
if not groups or r - groups[-1] > 2.0:
groups.append(r)
else:
groups[-1] = (groups[-1] + r) / 2 # merge close values
# Always include the outer boundary ring so the wafer circle is drawn.
outer = self._wafer_radius_mm()
if not groups or outer - groups[-1] > 2.0:
groups.append(outer)
return groups
def _scale(self, ds: int, r_mm: float) -> float:
"""Pixels per mm. The wafer radius maps to ds//2 - 4 px."""
return (ds / 2 - 4) / r_mm
def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
"""Center-origin mm → pixel (top-left origin). Y is flipped."""
return cx + int(x_mm * scale), cy - int(y_mm * scale)
def _compute_markers(self) -> None:
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
sc = self._scale(ds, r_mm)
cx, cy = self._center()
self._marker_r = max(3, ds // 70)
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
for i, s in enumerate(self._sensors)}
def _rebuild_heatmap(self) -> None:
if not self._sensors or not self._values or self._blend == 0.0:
self._heatmap = None
return
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors])
ys = np.array([s.y for s in self._sensors])
vs = np.array(self._values[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._heatmap = None
return
try:
field = interpolate_field(
xs, ys, vs,
width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=True,
)
except Exception:
self._heatmap = None
return
self._heatmap = self._field_to_qimage(field, ds)
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
"""Apply a band-aware tri-color gradient → RGBA QImage."""
lo_b = self._target - self._margin
hi_b = self._target + self._margin
span = hi_b - lo_b or 1.0
# t: 0 = lo_b, 1 = hi_b (clipped)
t = np.clip((field - lo_b) / span, 0.0, 1.0)
def c(q: QColor) -> np.ndarray:
return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)
lo_c = c(self._low_color)
mid_c = c(self._in_range_color)
hi_c = c(self._high_color)
t2 = t * 2 # 0→2 across full range
lower = t <= 0.5
t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half
t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half
rgb = np.where(
lower[:, :, np.newaxis],
lo_c * (1 - t_lo) + mid_c * t_lo,
mid_c * (1 - t_hi) + hi_c * t_hi,
)
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
# ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None:
ds = self._draw_size()
r_px = int(ds / 2 - 4)
cx, cy = self._center()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
self._paint_template(painter, cx, cy, r_px)
if self._heatmap and self._blend > 0.0:
painter.setOpacity(self._blend)
painter.drawImage(cx - self._heatmap.width() // 2,
cy - self._heatmap.height() // 2, self._heatmap)
painter.setOpacity(1.0)
self._paint_markers(painter)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
ds = self._draw_size()
r_mm = self._wafer_radius_mm()
sc = self._scale(ds, r_mm)
# Concentric rings at actual sensor group radii (falls back to 25/50/75/100% when no sensors).
ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
painter.setPen(ring_pen)
for ring_r_mm in self._sensor_ring_radii_mm():
rr = max(1, int(ring_r_mm * sc))
painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)
# Crosshair axes
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
painter.setPen(axis_pen)
painter.drawLine(cx, cy - r_px, cx, cy + r_px)
painter.drawLine(cx - r_px, cy, cx + r_px, cy)
# Top notch triangle (wafer orientation marker)
nw = max(6, ds // 25)
nh = max(4, ds // 35)
notch = QPolygon([
QPoint(cx, cy - r_px),
QPoint(cx - nw // 2, cy - r_px + nh),
QPoint(cx + nw // 2, cy - r_px + nh),
])
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(self._axis_color))
painter.drawPolygon(notch)
def _paint_markers(self, painter: QPainter) -> None:
r = self._marker_r
id_font = QFont()
id_font.setPointSize(max(5, r))
id_font.setBold(True)
temp_font = QFont()
temp_font.setPointSize(max(4, r - 1))
# Pre-compute ID font metrics for vertical centering
painter.setFont(id_font)
id_fm = painter.fontMetrics()
id_line_h = id_fm.height()
id_ascent = id_fm.ascent()
band_color = {
"in_range": self._in_range_color,
"high": self._high_color,
"low": self._low_color,
}
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
if self._show_labels:
has_temp = i < len(self._values)
lx = px + r + 3
# Two-line block: split the gap at dot center; single-line: original position
y1 = (py - id_line_h // 2) if has_temp else (py + id_ascent // 2)
# Sensor ID — bold, muted text color
painter.setFont(id_font)
painter.setPen(QPen(self._text_color))
painter.drawText(lx, y1, s.label)
# Temperature — band color, smaller font, below ID
if has_temp:
painter.setFont(temp_font)
painter.setPen(QPen(color))
painter.drawText(lx, y1 + id_line_h, f"{self._values[i]:.2f}")
+10
View File
@@ -0,0 +1,10 @@
# ===== Wafer Sub-package =====
from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor, DataRecord
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.backend.wafer.wafer_layouts import load_layout, available_families
__all__ = [
"ZWaferData", "Sensor", "DataRecord",
"ZWaferParser",
"load_layout", "available_families",
]
+79
View File
@@ -0,0 +1,79 @@
"""Load wafer sensor layouts from bundled YAML files.
YAML schema mirrors the replay app's wafer_desc.py format:
X/Y — sensor positions in mm relative to x_origin/y_origin
size — wafer diameter/edge in mm
shape — "round" or "square"
start_sn — first sensor number (usually 1)
x_origin — "left" | "right" | "center"
y_origin — "bottom" | "top" | "center"
Returned Sensor coords are center-origin mm (negative values toward the edge).
"""
from __future__ import annotations
from pathlib import Path
import yaml
from pygui.backend.wafer.zwafer_models import Sensor
_LAYOUTS_DIR = Path(__file__).parent.parent.parent / "assets" / "layouts"
def _family_name(raw_name: str) -> str:
return raw_name.replace("wafer", "").strip("_")
def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
def load_layout(family: str) -> list[Sensor]:
for path in _LAYOUTS_DIR.glob("*.yaml"):
data = _load_yaml(path)
if _family_name(data["name"]) == family:
return _to_sensors(data)
raise KeyError(f"Unknown wafer family: {family!r}")
def load_layout_for_wafer_id(wafer_id: str) -> list[Sensor]:
"""Match 'B00108' → bcdwafer by looking up the first char in each YAML's 'wafers' list."""
prefix = wafer_id[0].upper() if wafer_id else ""
for path in _LAYOUTS_DIR.glob("*.yaml"):
data = _load_yaml(path)
if prefix in data.get("wafers", []):
return _to_sensors(data)
raise KeyError(f"No layout found for wafer ID prefix {prefix!r}")
def _to_sensors(data: dict) -> list[Sensor]:
xs: list[float] = data["X"]
ys: list[float] = data["Y"]
size: float = float(data["size"])
start_sn: int = data.get("start_sn", 1)
reverse_x: bool = data.get("reverse_x", False)
reverse_y: bool = data.get("reverse_y", False)
x_orig: str = data.get("x_origin", "left")
y_orig: str = data.get("y_origin", "bottom")
x_shift = {"left": size / 2, "right": -(size / 2), "center": 0.0}.get(x_orig, 0.0)
y_shift = {"bottom": size / 2, "top": -(size / 2), "center": 0.0}.get(y_orig, 0.0)
sensors: list[Sensor] = []
for i, (x_mm, y_mm) in enumerate(zip(xs, ys)):
if reverse_x:
x_mm = -x_mm
if reverse_y:
y_mm = -y_mm
sensors.append(Sensor(
label=str(start_sn + i),
x=x_mm - x_shift,
y=y_mm - y_shift,
))
return sensors
@@ -2,7 +2,7 @@ from pathlib import Path
from typing import Tuple, Optional from typing import Tuple, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from backend.zwafer_models import ZWaferData, Sensor from pygui.backend.wafer.zwafer_models import ZWaferData, Sensor
# ===== Z-Wafer CSV Parser ===== # ===== Z-Wafer CSV Parser =====
@@ -32,7 +32,14 @@ class ZWaferParser:
# ===== Header Parsing ===== # ===== Header Parsing =====
def _process_header(self, file_obj) -> ZWaferData: def _process_header(self, file_obj) -> ZWaferData:
"""Parse header and sensor layout from open file object.""" """Parse header and sensor layout from open file object.
Handles two CSV row orderings:
1. Label/X/Y rows BEFORE 'data' (fixture test format)
2. 'data' row BEFORE Label/X/Y (some real CSV files)
The C# parser handles both by collecting sensor layout rows
regardless of whether 'data' has been seen.
"""
wafer_data = ZWaferData() wafer_data = ZWaferData()
labels: Optional[list] = None labels: Optional[list] = None
x_coords: Optional[list] = None x_coords: Optional[list] = None
@@ -47,26 +54,25 @@ class ZWaferParser:
parts = [p.strip() for p in line.split(",")] parts = [p.strip() for p in line.split(",")]
first_part = parts[0].lower() first_part = parts[0].lower()
# Collect sensor layout rows (Label, X (mm), Y (mm))
# regardless of whether 'data' has been seen.
# This matches the C# parser behavior.
if first_part == "label":
labels = parts[1:]
elif first_part == "x (mm)":
x_coords = parts[1:]
elif first_part == "y (mm)":
y_coords = parts[1:]
# Parse metadata (key=value pairs) — only when not a sensor layout row
elif first_part != "data":
self._parse_header_line(wafer_data, parts)
# Detect end of header section # Detect end of header section
if first_part == "data": if first_part == "data":
wafer_data.csv_headers = labels or [] wafer_data.csv_headers = labels or []
self._build_sensor_layout(wafer_data, labels, x_coords, y_coords) self._build_sensor_layout(wafer_data, labels, x_coords, y_coords)
return wafer_data return wafer_data
# Detect switch from metadata to sensor layout
# Parse metadata (key=value pairs)
if not self._parse_header_line(wafer_data, parts):
# If not metadata, it's part of sensor layout
label = parts[0].lower()
values = parts[1:]
if label == "label":
labels = values
elif label == "x (mm)":
x_coords = values
elif label == "y (mm)":
y_coords = values
return wafer_data # Incomplete file — return partial data return wafer_data # Incomplete file — return partial data
# ===== Metadata Parsing ===== # ===== Metadata Parsing =====
@@ -1,6 +1,6 @@
"""Serial port communication layer for the temperature-sensing wafer.""" """Serial port communication layer for the temperature-sensing wafer."""
from serialcomm.device_service import DeviceService from pygui.serialcomm.device_service import DeviceService
from serialcomm.serial_port import SerialPort, WaferInfo from pygui.serialcomm.serial_port import SerialPort, WaferInfo
__all__ = ["DeviceService", "SerialPort", "WaferInfo"] __all__ = ["DeviceService", "SerialPort", "WaferInfo"]
@@ -11,8 +11,8 @@ from typing import Optional
import serial.tools.list_ports import serial.tools.list_ports
from backend.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from serialcomm.serial_port import SerialPort, WaferInfo from pygui.serialcomm.serial_port import SerialPort, WaferInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
+58
View File
@@ -0,0 +1,58 @@
"""Continuos serial frame reader (Live-mode source)"""
from __future__ import annotations
import logging
import threading
from typing import Callable
from pygui.backend.models.frame import Frame
log = logging.getLogger(__name__)
ParseLine = Callable[[str, int], Frame]
class StreamReader:
def __init__(self, transport, parse_line: ParseLine,
on_frame: Callable[[Frame], None],
on_error: Callable[[Exception], None] | None = None ) -> None:
self._transport = transport
self._parse = parse_line
self._on_frame = on_frame
self._on_error = on_error or (lambda e: None)
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._seq = 0
self.error_count = 0
def start(self) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon = True)
self._thread.start()
def _run(self) -> None:
try:
while not self._stop.is_set():
raw = self._transport.readline()
if not raw:
continue
text = raw.decode(errors="ignore").strip()
if not text:
continue
try:
frame= self._parse(text, self._seq)
self._seq += 1
self._on_frame(frame)
except Exception as exc:
self.error_count += 1
self._on_error(exc)
finally:
try:
self._transport.close()
except Exception:
pass
def stop(self) -> None:
self._stop.set()
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout = 2.0)
self._thread = None
+9
View File
@@ -0,0 +1,9 @@
Wafer ID=A12
Acquisition Date=03/26/2025
Label,1,2,3
X (mm),0,10,-10
Y (mm),10,-10,-10
data
0.0,149.0,148.5,150.6
0.5,149.2,148.4,150.7
1.0,149.1,148.6,150.5
1 Wafer ID=A12
2 Acquisition Date=03/26/2025
3 Label,1,2,3
4 X (mm),0,10,-10
5 Y (mm),10,-10,-10
6 data
7 0.0,149.0,148.5,150.6
8 0.5,149.2,148.4,150.7
9 1.0,149.1,148.6,150.5
+40
View File
@@ -0,0 +1,40 @@
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.data.data_records import read_data_records
from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
def test_recording_roundtrips_through_parser(tmp_path):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "rec.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
rec.write(Frame(seq=0, time=0.0, values=[149.0, 148.0]))
rec.write(Frame(seq=1, time=0.5, values=[149.5, 148.5]))
rec.stop()
assert path.exists()
data, _ = ZWaferParser().parse(str(path))
assert data is not None
assert[s.label for s in data.sensors] == ["1", "2"]
assert len(data.csv_headers) == 2
def test_recorded_data_rows_are_readable(tmp_path):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "rec.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
rec.write(Frame(seq=0, time=0.0, values=[149.0, 148.0]))
rec.write(Frame(seq=1, time=0.5, values=[149.5, 148.5]))
rec.stop()
records = read_data_records(str(path))
assert len(records) == 2
assert records[0].time == 0.0
assert records[0].values == [149.0, 148.0]
assert records[1].time == 0.5
assert records[1].values == [149.5, 148.5]
+5 -5
View File
@@ -1,7 +1,7 @@
"""Tests for serialcomm/data_parser.py binary parsing pipeline.""" """Tests for serialcomm/data_parser.py binary parsing pipeline."""
import pytest import pytest
from serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
csv_column_count, csv_column_count,
parse_binary_data, parse_binary_data,
convert_to_temperatures, convert_to_temperatures,
@@ -12,7 +12,6 @@ from serialcomm.data_parser import (
MAXDUT_X, MAXDUT_X,
) )
# ── csv_column_count ────────────────────────────────────────────────────────── # ── csv_column_count ──────────────────────────────────────────────────────────
@@ -203,6 +202,7 @@ class TestConvertToTemperatures:
def test_p_family_single_block(self): def test_p_family_single_block(self):
data = _make_p_block(1, value=0x0100) data = _make_p_block(1, value=0x0100)
hex_data = parse_binary_data(data, "P") hex_data = parse_binary_data(data, "P")
assert hex_data is not None
result = convert_to_temperatures(hex_data, "P") result = convert_to_temperatures(hex_data, "P")
assert len(result) == 1 assert len(result) == 1
assert all(isinstance(v, str) for v in result[0]) assert all(isinstance(v, str) for v in result[0])
@@ -270,9 +270,9 @@ class TestSaveToCsv:
result = save_to_csv(data, family, f"{family}00001", str(tmp_path)) result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
assert result is not None, f"save_to_csv returned None for {family}" assert result is not None, f"save_to_csv returned None for {family}"
headers = open(result).readline().strip().split(",") headers = open(result).readline().strip().split(",")
assert len(headers) == expected_cols, ( assert (
f"{family}: expected {expected_cols} headers, got {len(headers)}" len(headers) == expected_cols
) ), f"{family}: expected {expected_cols} headers, got {len(headers)}"
assert headers[0] == "Sensor1" assert headers[0] == "Sensor1"
assert headers[-1] == f"Sensor{expected_cols}" assert headers[-1] == f"Sensor{expected_cols}"
+19
View File
@@ -0,0 +1,19 @@
"""Tests for src/pygui/backend/data_records.py."""
from pygui.backend.data.data_records import read_data_records
def test_reads_rows_after_data_marker(tmp_path):
p = tmp_path / "s.csv"
p.write_text(
"Wafer ID=A12\n"
"Label,1,2\nX (mm),0,10\nY (mm),10,-10\n"
"data\n"
"0.0,149.0,148.5\n"
"0.5,149.2,148.4\n",
encoding="utf-8",
)
recs = read_data_records(str(p))
assert len(recs) == 2
assert recs[0].time == 0.0
assert recs[0].values == [149.0, 148.5]
assert recs[1].values == [149.2, 148.4]
+30
View File
@@ -0,0 +1,30 @@
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer
def frames(n):
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
def test_loads_and_reports_total():
p = FramePlayer(); p.load(frames(3))
assert p.total == 3 and p.index == 0
current = p.current()
assert current is not None
assert current.values == [149.0]
def test_step_forward_and_back_clamps():
p = FramePlayer(); p.load(frames(3))
assert p.step(1).index == 1
assert p.step(1).index == 2
assert p.step(1).index == 2
assert p.step(-5).index == 0
def test_seek():
p = FramePlayer(); p.load(frames(5))
assert p.seek(3).index == 3
assert p.seek(99).index == 4
def test_at_end():
p = FramePlayer(); p.load(frames(2))
assert not p.at_end
p.seek(1)
assert p.at_end
+22
View File
@@ -0,0 +1,22 @@
import math
import pytest
from pygui.backend.models.frame_stats import compute_stats, Stats
def test_basic_stat():
s = compute_stats([148.0, 150.0, 149.0])
assert s.min == 148.0 and s.min_index == 0
assert s.max == 150.0 and s.max_index == 1
assert s.diff == pytest.approx(2.0)
assert s.avg == pytest.approx(149.0)
assert s.sigma == pytest.approx(math.sqrt(2 / 3))
assert s.three_sigma == pytest.approx(3 * math.sqrt(2 / 3))
def test_empty_values_returns_zeros():
s = compute_stats([])
assert s == Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
def test_ignores_nan():
s = compute_stats([149.0, float("nan"), 151.0])
+29
View File
@@ -0,0 +1,29 @@
"""Tests for src/pygui/backend/rbf_heatmap.py."""
import numpy as np
from pygui.backend.visualization.rbf_heatmap import interpolate_field, BACKEND
def test_field_shape_and_interpolates_at_sites():
xs = np.array([0.0, 10.0, -10.0, 0.0])
ys = np.array([10.0, -10.0, -10.0, 0.0])
vs = np.array([149.0, 150.0, 148.0, 149.5])
field = interpolate_field(xs, ys, vs, width=64, height=64,
extent=(-15, 15, -15, 15))
assert field.shape == (64, 64)
assert np.isfinite(field).all()
assert field.min() <= 150.0 and field.max() >= 148.0
def test_round_clip_produces_nans_outside_circle():
xs = np.array([0.0, 5.0, -5.0])
ys = np.array([5.0, -5.0, 0.0])
vs = np.array([149.0, 150.0, 148.0])
field = interpolate_field(xs, ys, vs, width=64, height=64,
extent=(-15, 15, -15, 15), round_clip=True)
assert np.isnan(field).any()
# center pixel must be finite
assert np.isfinite(field[32, 32])
def test_backend_reported():
assert BACKEND in ("cupy", "numpy")
+49
View File
@@ -0,0 +1,49 @@
"""Tests for src/pygui/backend/sensor_editor.py."""
import pytest
from pygui.backend.models.sensor_editor import SensorEditor
def test_replace_overrides_raw_value():
ed = SensorEditor()
ed.set_replacement(2, 150.0) # index 2 forced to 150.0 regardless of raw
assert ed.apply([149.0, 149.0, 99.0]) == [149.0, 149.0, 150.0]
def test_offset_shifts_raw_value():
ed = SensorEditor()
ed.set_offset(0, +0.5)
assert ed.apply([149.0, 149.0]) == [149.5, 149.0]
def test_replace_and_offset_apply_in_order():
"""Replacement wins over raw, then offset is added on top."""
ed = SensorEditor()
ed.set_replacement(2, 150.0)
ed.set_offset(2, +1.0) # sensor 2: replace 99→150, then +1 → 151
ed.set_offset(0, +0.5)
out = ed.apply([149.0, 149.0, 99.0, 149.0])
assert out == [149.5, 149.0, 151.0, 149.0]
def test_clear_single_index_restores_only_that_sensor():
ed = SensorEditor()
ed.set_replacement(0, 0.0)
ed.set_replacement(1, 0.0)
ed.clear(0) # clear only index 0
assert ed.apply([149.0, 149.0]) == [149.0, 0.0]
def test_clear_restores_all():
ed = SensorEditor()
ed.set_offset(0, 5.0)
ed.set_replacement(1, 200.0)
ed.clear()
assert ed.apply([149.0, 149.0]) == [149.0, 149.0]
def test_no_overrides_returns_copy():
ed = SensorEditor()
original = [149.0, 149.0]
result = ed.apply(original)
assert result == original
assert result is not original
+28
View File
@@ -0,0 +1,28 @@
from pygui.backend.models.frame import Frame
from pygui.backend.models.threshold_classifier import ThresholdConfig, BAND_HIGH, BAND_IN, BAND_LOW
from pygui.backend.models.session_model import SessionModel, SessionUpdate
def test_manual_banding_and_stats():
model = SessionModel(ThresholdConfig(set_point=149.0, margin=1.0, auto=False))
update = model.process(Frame(seq=7, time=1.0, values=[151.0, 149.0, 147.0]))
assert update.seq == 7
assert update.bands == [BAND_HIGH, BAND_IN, BAND_LOW]
assert update.stats.avg == 149.0
assert (update.target, update.margin) == (149.0, 1.0)
assert update.state in ("idle", "ramp", "set")
def test_auto_banding_uses_mean_and_sigma():
model = SessionModel(ThresholdConfig(auto=True))
# Tight cluster around 149 -> small σ -> the 200.0 outlier reads HIGH
update = model.process(Frame(seq=0, time=0.0, values=[149.0, 149.0, 149.0, 200.0]))
assert update.bands[3] == BAND_HIGH
assert update.target == update.stats.avg
def test_set_thresholds_reband_on_next_frame():
m = SessionModel(ThresholdConfig(set_point=149.0, margin=1.0, auto=False))
m.set_thresholds(ThresholdConfig(set_point=149.0, margin=5.0, auto=False))
upd = m.process(Frame(seq=0, time=0.0, values=[151.0]))
assert upd.bands == [BAND_IN]
+35
View File
@@ -0,0 +1,35 @@
from pygui.backend.models.stability_detector import (
StabilityDetector, STATE_IDLE, STATE_RAMP, STATE_SET,
)
SET_POINT = 149.0
def make():
return StabilityDetector(idle_below = 50.0, tolerance=1.0, settle_seconds=2.0)
def test_idle_when_cold():
d = make()
assert d.update(avg=25.0, time=0.0, set_point=SET_POINT) == STATE_IDLE
def test_ramp_while_far_from_setpoint():
d = make()
d.update(avg=100.0, time=0.0, set_point=SET_POINT)
def test_ramp_until_settle_time_elapses():
d = make()
assert d.update(avg=149.2, time=0.0, set_point=SET_POINT) == STATE_RAMP
assert d.update(avg=148.9, time=0.0, set_point=SET_POINT) == STATE_RAMP
def test_set_after_holding_near_setpoint():
d = make()
d.update(avg=149.2, time=0.0, set_point=SET_POINT)
d.update(avg=148.9, time=1.0, set_point=SET_POINT)
assert d.update(avg=149.0, time=2.5, set_point=SET_POINT) == STATE_SET
def test_back_to_ramp_on_disturbance():
d = make()
for t in (0.0, 1.0, 2.5):
d.update(149.0, t, set_point=SET_POINT)
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
+28
View File
@@ -0,0 +1,28 @@
import time
from pygui.backend.models.frame import Frame
from pygui.serialcomm.stream_reader import StreamReader
class FakeTransport:
"""Yield canned line then blocks (returns b'')."""
def __init__(self, lines): self._lines = list(lines); self.closed = False
def readline(self):
time.sleep(0.001)
return self._lines.pop(0) if self._lines else b""
def close(self): self.closed = True
def parse_line(raw: str, seq: int) -> Frame:
parts = [float(x) for x in raw.split(",")]
return Frame(seq = seq, time = parts[0], values = parts[1:])
def test_reads_frames_and_counts_errors():
got, errors = [], []
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
r = StreamReader(transport, parse_line,
on_frame = got.append, on_error = lambda e: errors.append(e))
r.start()
time.sleep(0.1)
r.stop()
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
assert r.error_count == 1
assert transport.closed
+29
View File
@@ -0,0 +1,29 @@
import math
from pygui.backend.models.threshold_classifier import (
ThresholdConfig, classify, classify_all, resolve_bounds,
BAND_IN, BAND_HIGH, BAND_LOW
)
def test_classify_about_target_margin():
assert classify(149.0, target=149.0, margin=1.0) ==BAND_IN # exactly
assert classify(149.9, target=149.0, margin=1.0) ==BAND_IN # within
assert classify(150.5, target=149.0, margin=1.0) ==BAND_HIGH # 1.5 above
assert classify(147.5, target=149.0, margin=1.0) ==BAND_LOW # 1.5 below
def test_manual_bounds_use_set_point_and_margin():
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
assert resolve_bounds([200.0, 0.0], cfg) == (149.0, 1.0)
def test_auto_bounds_use_mean_and_sigma():
cfg = ThresholdConfig(auto=True)
target, margin = resolve_bounds([148.0, 150.0, 149.0], cfg)
assert target == 149.0
assert margin == math.sqrt(2 / 3)
def test_classify_all_manual():
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
assert classify_all([149.0, 151.0, 147.0], cfg) == [BAND_IN, BAND_HIGH, BAND_LOW]
+21
View File
@@ -0,0 +1,21 @@
"""Tests for src/pygui/backend/wafer_layouts.py."""
import pytest
from pygui.backend.wafer.wafer_layouts import load_layout, available_families
from pygui.backend.wafer.zwafer_models import Sensor
def test_lists_bundled_families():
fams = available_families()
assert "aep" in fams and "x" in fams
def test_loads_sensors_in_mm():
sensors = load_layout("aep")
assert sensors and all(isinstance(s, Sensor) for s in sensors)
assert any(s.x < 0 for s in sensors)
assert max(abs(s.x) for s in sensors) < 200.0
def test_unknown_family_raises():
with pytest.raises(KeyError):
load_layout("nope")
Generated
+215 -2
View File
@@ -20,6 +20,85 @@ 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" }, { 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 = "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/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ 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" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@@ -50,7 +129,11 @@ wheels = [
[[package]] [[package]]
name = "pygui" name = "pygui"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [
{ name = "pyyaml" },
{ name = "scipy" },
]
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
@@ -63,7 +146,11 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "pytest", marker = "extra == 'dev'" }] requires-dist = [
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "scipy", specifier = ">=1.17.1" },
]
provides-extras = ["dev"] provides-extras = ["dev"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -84,3 +171,129 @@ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
] ]
[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ 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 = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
]