From 5ecddfe768c853633677ef529ab658ea25582889 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Mon, 24 Aug 2026 07:29:21 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E4=BF=AE=E6=AD=A3(=E5=85=89=E8=B0=B1)?= =?UTF-8?q?=EF=BC=9A=E6=8C=89=20calibre.npz=20=E4=BF=AE=E6=AD=A3=E6=B3=A2?= =?UTF-8?q?=E9=95=BF=E8=8C=83=E5=9B=B4=E5=B9=B6=E5=8E=BB=E9=99=A4=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=84=9A=E6=9C=AC=E7=A1=AC=E7=BC=96=E7=A0=81=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reconstructor 文档注释改为 380–1100 nm、721 点(实测矩阵 (721,10),1 nm 步长),清理行内乱码 - SpectrumWidget 初始 x 轴从 1000 放宽到 1100 nm,与数据范围一致 - validate_endpoint.py / validate_online.py 数据文件改为 --input 必选参数,与 validate_online_multimodal.py 范式统一 --- TController/scripts/validate_endpoint.py | 13 +++++++++---- TController/scripts/validate_online.py | 13 +++++++++---- TController/src/DataProcessor/reconstructor.py | 8 ++++---- TController/src/gui/spectrum_widget.py | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/TController/scripts/validate_endpoint.py b/TController/scripts/validate_endpoint.py index 2c98f08..cc2c3b7 100644 --- a/TController/scripts/validate_endpoint.py +++ b/TController/scripts/validate_endpoint.py @@ -3,6 +3,7 @@ from __future__ import annotations +import argparse import sys import warnings from pathlib import Path @@ -18,11 +19,15 @@ from DataProcessor.calibration import FLOW_RATE, update_from_file -DATA_FILE = Path( - "/home/zhiyir/文档/xwechat_files/wxid_l267qu0nkh512_1601/msg/attach/" - "d819785e5916791e7d3b8b1199d7af8d/2026-06/Rec/" - "da42816e8394beff/F/1/titration_result.xlsx" +parser = argparse.ArgumentParser(description="TController 滴定终点检测算法验证(离线回放)") +parser.add_argument( + "--input", + type=Path, + required=True, + help="滴定数据 xlsx(含「电位-体积曲线」「光谱数据」两个 sheet)", ) +args = parser.parse_args() +DATA_FILE = args.input OUT_DIR = PROJ / "data" / "validation" OUT_DIR.mkdir(parents=True, exist_ok=True) diff --git a/TController/scripts/validate_online.py b/TController/scripts/validate_online.py index 82757af..015d932 100644 --- a/TController/scripts/validate_online.py +++ b/TController/scripts/validate_online.py @@ -7,6 +7,7 @@ from __future__ import annotations +import argparse import sys import warnings from pathlib import Path @@ -22,11 +23,15 @@ update_from_file() -DATA = Path( - "/home/zhiyir/文档/xwechat_files/wxid_l267qu0nkh512_1601/msg/attach/" - "d819785e5916791e7d3b8b1199d7af8d/2026-06/Rec/" - "da42816e8394beff/F/1/titration_result.xlsx" +parser = argparse.ArgumentParser(description="在线滴定终点检测 — 实时回放验证") +parser.add_argument( + "--input", + type=Path, + required=True, + help="滴定数据 xlsx(含「电位-体积曲线」sheet)", ) +args = parser.parse_args() +DATA = args.input print("[1] 加载数据 …") wb = openpyxl.load_workbook(str(DATA), read_only=True) diff --git a/TController/src/DataProcessor/reconstructor.py b/TController/src/DataProcessor/reconstructor.py index ed78f69..80f777c 100644 --- a/TController/src/DataProcessor/reconstructor.py +++ b/TController/src/DataProcessor/reconstructor.py @@ -2,8 +2,8 @@ AS7341 10 通道 → 全光谱重建。 使用 ams-OSRAM 官方 Golden Device 校准矩阵将 10 通道 -(F1–F8, Clear, NIR) 原始 ADC 值重建为 380–1000 nm -连续全光谱(1 nm 步长,621 点)。 +(F1–F8, Clear, NIR) 原始 ADC 值重建为 380–1100 nm +连续全光谱(1 nm 步长,721 点)。 重建流程:: @@ -46,7 +46,7 @@ def is_available() -> bool: def get_wavelengths() -> np.ndarray: - """返回波长数组 (380–1000 nm, 1 nm 步长)。""" + """返回波长数组 (380–1100 nm, 1 nm 步长)。""" return _load()["wavelengths"].copy() @@ -93,6 +93,6 @@ def reconstruct( corrected = fac * np.maximum(raw - ofs, 0.0) spectrum = np.maximum( cal["matrix"] @ corrected, 0.0 - ) # (721, 10) @ (10,) 2192 (721,) + ) # (721, 10) @ (10,) → (721,) return cal["wavelengths"].copy(), spectrum diff --git a/TController/src/gui/spectrum_widget.py b/TController/src/gui/spectrum_widget.py index c9fda74..ba1f101 100644 --- a/TController/src/gui/spectrum_widget.py +++ b/TController/src/gui/spectrum_widget.py @@ -22,7 +22,7 @@ def __init__(self, parent: tk.Misc, **kwargs) -> None: self._set_xlabel(i18n.tr("plot.wavelength")) self._set_ylabel(i18n.tr("plot.intensity")) - self._ax.set_xlim(380, 1000) + self._ax.set_xlim(380, 1100) self._ax.set_ylim(0, 1) self._ax.grid(True, alpha=0.25) From 1bffa393c25b07a375e21d1aca8a8d8f345a61be Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 18:11:39 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(IWDG)=EF=BC=9A?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=8B=AC=E7=AB=8B=E7=9C=8B=E9=97=A8=E7=8B=97?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=AD=BB=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/platform/IWDG.hpp | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/include/platform/IWDG.hpp b/include/platform/IWDG.hpp index 080947b..e80c283 100644 --- a/include/platform/IWDG.hpp +++ b/include/platform/IWDG.hpp @@ -34,6 +34,12 @@ class IWDG_ { /** IWDG 寄存器写使能密钥 */ static constexpr uint16_t KEY_WRITE_ACCESS = 0x5555; + /** 同步等待上限,防止低速/异常时钟域永久阻塞启动 */ + static constexpr uint32_t SYNC_TIMEOUT = 1000000; + + /** 初始化失败标志,可由调试器或诊断代码读取 */ + inline static volatile bool g_initFailed{false}; + /** * @brief 初始化并启动独立看门狗(~5s 超时) * @@ -42,22 +48,40 @@ class IWDG_ { */ static void initialize() noexcept { using namespace STM32F103; - /** 写访问 → 配置 PR/RLR → 喂狗加载 → 再启动(RM0008 推荐顺序) */ + + /** 启动 IWDG 以启动其独立 LSI 时钟域。 */ + IWDG::KR::Write(KEY_START); IWDG::KR::Write(KEY_WRITE_ACCESS); + /** 设置预分频(等待 PVU 清除) */ IWDG::PR::WritePR(PRESCALER); - while (IWDG::SR::ReadPVU() != 0) { - /** 等待预分频值更新完成 */ + uint32_t timeout = SYNC_TIMEOUT; + while (IWDG::SR::ReadPVU() != 0 && timeout-- != 0) { + } + if (IWDG::SR::ReadPVU() != 0) { + g_initFailed = true; + return; } + /** 设置重载值(等待 RVU 清除) */ IWDG::RLR::WriteRL(RELOAD); - while (IWDG::SR::ReadRVU() != 0) { - /** 等待重载值更新完成 */ + timeout = SYNC_TIMEOUT; + while (IWDG::SR::ReadRVU() != 0 && timeout-- != 0) { } + if (IWDG::SR::ReadRVU() != 0) { + g_initFailed = true; + return; + } + /** 首次喂狗,加载计数器 */ IWDG::KR::Write(KEY_RELOAD); - /** 启动 IWDG(写 0xCCCC 到 KR);启动后不可关闭 */ - IWDG::KR::Write(KEY_START); + } + + /** + * @brief 返回初始化是否因时钟域同步失败 + */ + static auto initFailed() noexcept -> bool { + return g_initFailed; } /** From 6ff3cd535b8bea8a18f71e8ccdcadd04fde3fb5d Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 19:31:13 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E9=87=8D=E6=9E=84(=E4=B8=8A=E4=BD=8D?= =?UTF-8?q?=E6=9C=BA)=EF=BC=9A=E8=BF=81=E7=A7=BB=20Rust=20TController=20?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TController/.gitignore | 2 + TController/Cargo.lock | 4788 +++++++++ TController/Cargo.toml | 13 + TController/Logo.ico | Bin 82686 -> 0 bytes TController/Logo.png | Bin 224012 -> 0 bytes TController/README.md | 63 + TController/app/src-tauri/Cargo.toml | 19 + TController/app/src-tauri/build.rs | 3 + .../app/src-tauri/capabilities/default.json | 15 + .../src-tauri/gen/schemas/acl-manifests.json | 1 + .../src-tauri/gen/schemas/capabilities.json | 1 + .../src-tauri/gen/schemas/desktop-schema.json | 2292 +++++ .../src-tauri/gen/schemas/windows-schema.json | 2292 +++++ TController/app/src-tauri/icons/icon.ico | Bin 0 -> 1150 bytes TController/app/src-tauri/src/backend.rs | 1017 ++ TController/app/src-tauri/src/main.rs | 277 + TController/app/src-tauri/tauri.conf.json | 32 + TController/app/ui-next/.gitignore | 42 + TController/app/ui-next/.npmrc | 1 + TController/app/ui-next/README.md | 36 + TController/app/ui-next/app/favicon.ico | Bin 0 -> 25931 bytes TController/app/ui-next/app/globals.css | 179 + TController/app/ui-next/app/layout.tsx | 38 + TController/app/ui-next/app/page.tsx | 7 + TController/app/ui-next/components.json | 25 + .../app/ui-next/components/app-shell.tsx | 408 + .../components/charts/potential-chart.tsx | 236 + .../components/charts/spectrum-chart.tsx | 209 + .../components/pages/calibration-page.tsx | 395 + .../ui-next/components/pages/history-page.tsx | 124 + .../components/pages/maintenance-page.tsx | 176 + .../components/pages/settings-page.tsx | 276 + .../components/pages/titration-page.tsx | 301 + .../app/ui-next/components/ui/badge.tsx | 49 + .../app/ui-next/components/ui/button.tsx | 67 + .../app/ui-next/components/ui/card.tsx | 103 + .../app/ui-next/components/ui/dialog.tsx | 168 + .../ui-next/components/ui/dropdown-menu.tsx | 269 + .../app/ui-next/components/ui/input.tsx | 19 + .../app/ui-next/components/ui/label.tsx | 24 + .../app/ui-next/components/ui/progress.tsx | 31 + .../app/ui-next/components/ui/scroll-area.tsx | 55 + .../app/ui-next/components/ui/select.tsx | 192 + .../app/ui-next/components/ui/separator.tsx | 28 + .../app/ui-next/components/ui/sonner.tsx | 49 + .../app/ui-next/components/ui/switch.tsx | 33 + .../app/ui-next/components/ui/table.tsx | 116 + .../app/ui-next/components/ui/tabs.tsx | 90 + .../app/ui-next/components/ui/tooltip.tsx | 57 + TController/app/ui-next/design.md | 61 + TController/app/ui-next/eslint.config.mjs | 18 + TController/app/ui-next/lib/backend.ts | 233 + TController/app/ui-next/lib/chart-utils.ts | 57 + TController/app/ui-next/lib/i18n.ts | 244 + TController/app/ui-next/lib/mock/calibre.ts | 29 + TController/app/ui-next/lib/mock/simulator.ts | 448 + TController/app/ui-next/lib/store.ts | 273 + TController/app/ui-next/lib/tone.ts | 10 + TController/app/ui-next/lib/types.ts | 107 + TController/app/ui-next/lib/utils.ts | 6 + TController/app/ui-next/next.config.ts | 9 + TController/app/ui-next/package-lock.json | 8759 +++++++++++++++++ TController/app/ui-next/package.json | 35 + TController/app/ui-next/postcss.config.mjs | 7 + TController/app/ui-next/public/file.svg | 1 + TController/app/ui-next/public/globe.svg | 1 + TController/app/ui-next/public/next.svg | 1 + TController/app/ui-next/public/vercel.svg | 1 + TController/app/ui-next/public/window.svg | 1 + TController/app/ui-next/tsconfig.json | 34 + TController/app/ui/index.html | 67 + TController/crates/controller-core/Cargo.toml | 17 + TController/crates/controller-core/src/lib.rs | 20 + .../controller-core/src/processing/ampd.rs | 147 + .../src/processing/calibration.rs | 157 + .../src/processing/divergence.rs | 163 + .../src/processing/endpoint.rs | 775 ++ .../controller-core/src/processing/ewma.rs | 52 + .../controller-core/src/processing/kf.rs | 344 + .../controller-core/src/processing/mod.rs | 18 + .../src/processing/reconstructor.rs | 160 + .../controller-core/src/processing/savgol.rs | 125 + .../controller-core/src/processing/tracker.rs | 604 ++ .../controller-core/src/protocol/crc.rs | 38 + .../controller-core/src/protocol/frames.rs | 250 + .../controller-core/src/protocol/handler.rs | 403 + .../controller-core/src/protocol/mod.rs | 10 + .../controller-core/src/protocol/parser.rs | 164 + .../controller-core/src/protocol/retry.rs | 181 + .../crates/controller-core/src/workflow.rs | 250 + .../tests/endpoint_reliability.rs | 292 + .../controller-core/tests/tmp_diff_python.rs | 288 + .../crates/controller-core/tests/workflow.rs | 155 + TController/scripts/validate_endpoint.py | 293 - TController/scripts/validate_online.py | 223 - .../scripts/validate_online_multimodal.py | 152 - TController/src/Communication/__init__.py | 17 - TController/src/Communication/protocol.py | 538 - TController/src/DataProcessor/__init__.py | 49 - TController/src/DataProcessor/_path.py | 37 - TController/src/DataProcessor/calibration.py | 88 - TController/src/DataProcessor/endpoint.py | 572 -- .../src/DataProcessor/online_features.py | 640 -- .../src/DataProcessor/reconstructor.py | 98 - TController/src/gui/__init__.py | 1 - TController/src/gui/_plot.py | 117 - TController/src/gui/calibration_tab.py | 1084 -- TController/src/gui/i18n.py | 133 - TController/src/gui/locales/en_US.json | 221 - TController/src/gui/locales/zh_CN.json | 221 - TController/src/gui/main_window.py | 1206 --- TController/src/gui/maintenance_tab.py | 209 - TController/src/gui/potential_widget.py | 322 - TController/src/gui/results_panel.py | 392 - TController/src/gui/settings.py | 62 - TController/src/gui/spectrum_widget.py | 111 - TController/src/gui/themes.py | 471 - TController/src/gui/widgets.py | 485 - TController/src/main.py | 53 - TController/tests/test_data_recording.py | 142 - .../tests/test_endpoint_reliability.py | 304 - TController/tests/test_protocol.py | 58 - 122 files changed, 29633 insertions(+), 8299 deletions(-) create mode 100644 TController/.gitignore create mode 100644 TController/Cargo.lock create mode 100644 TController/Cargo.toml delete mode 100644 TController/Logo.ico delete mode 100644 TController/Logo.png create mode 100644 TController/README.md create mode 100644 TController/app/src-tauri/Cargo.toml create mode 100644 TController/app/src-tauri/build.rs create mode 100644 TController/app/src-tauri/capabilities/default.json create mode 100644 TController/app/src-tauri/gen/schemas/acl-manifests.json create mode 100644 TController/app/src-tauri/gen/schemas/capabilities.json create mode 100644 TController/app/src-tauri/gen/schemas/desktop-schema.json create mode 100644 TController/app/src-tauri/gen/schemas/windows-schema.json create mode 100644 TController/app/src-tauri/icons/icon.ico create mode 100644 TController/app/src-tauri/src/backend.rs create mode 100644 TController/app/src-tauri/src/main.rs create mode 100644 TController/app/src-tauri/tauri.conf.json create mode 100644 TController/app/ui-next/.gitignore create mode 100644 TController/app/ui-next/.npmrc create mode 100644 TController/app/ui-next/README.md create mode 100644 TController/app/ui-next/app/favicon.ico create mode 100644 TController/app/ui-next/app/globals.css create mode 100644 TController/app/ui-next/app/layout.tsx create mode 100644 TController/app/ui-next/app/page.tsx create mode 100644 TController/app/ui-next/components.json create mode 100644 TController/app/ui-next/components/app-shell.tsx create mode 100644 TController/app/ui-next/components/charts/potential-chart.tsx create mode 100644 TController/app/ui-next/components/charts/spectrum-chart.tsx create mode 100644 TController/app/ui-next/components/pages/calibration-page.tsx create mode 100644 TController/app/ui-next/components/pages/history-page.tsx create mode 100644 TController/app/ui-next/components/pages/maintenance-page.tsx create mode 100644 TController/app/ui-next/components/pages/settings-page.tsx create mode 100644 TController/app/ui-next/components/pages/titration-page.tsx create mode 100644 TController/app/ui-next/components/ui/badge.tsx create mode 100644 TController/app/ui-next/components/ui/button.tsx create mode 100644 TController/app/ui-next/components/ui/card.tsx create mode 100644 TController/app/ui-next/components/ui/dialog.tsx create mode 100644 TController/app/ui-next/components/ui/dropdown-menu.tsx create mode 100644 TController/app/ui-next/components/ui/input.tsx create mode 100644 TController/app/ui-next/components/ui/label.tsx create mode 100644 TController/app/ui-next/components/ui/progress.tsx create mode 100644 TController/app/ui-next/components/ui/scroll-area.tsx create mode 100644 TController/app/ui-next/components/ui/select.tsx create mode 100644 TController/app/ui-next/components/ui/separator.tsx create mode 100644 TController/app/ui-next/components/ui/sonner.tsx create mode 100644 TController/app/ui-next/components/ui/switch.tsx create mode 100644 TController/app/ui-next/components/ui/table.tsx create mode 100644 TController/app/ui-next/components/ui/tabs.tsx create mode 100644 TController/app/ui-next/components/ui/tooltip.tsx create mode 100644 TController/app/ui-next/design.md create mode 100644 TController/app/ui-next/eslint.config.mjs create mode 100644 TController/app/ui-next/lib/backend.ts create mode 100644 TController/app/ui-next/lib/chart-utils.ts create mode 100644 TController/app/ui-next/lib/i18n.ts create mode 100644 TController/app/ui-next/lib/mock/calibre.ts create mode 100644 TController/app/ui-next/lib/mock/simulator.ts create mode 100644 TController/app/ui-next/lib/store.ts create mode 100644 TController/app/ui-next/lib/tone.ts create mode 100644 TController/app/ui-next/lib/types.ts create mode 100644 TController/app/ui-next/lib/utils.ts create mode 100644 TController/app/ui-next/next.config.ts create mode 100644 TController/app/ui-next/package-lock.json create mode 100644 TController/app/ui-next/package.json create mode 100644 TController/app/ui-next/postcss.config.mjs create mode 100644 TController/app/ui-next/public/file.svg create mode 100644 TController/app/ui-next/public/globe.svg create mode 100644 TController/app/ui-next/public/next.svg create mode 100644 TController/app/ui-next/public/vercel.svg create mode 100644 TController/app/ui-next/public/window.svg create mode 100644 TController/app/ui-next/tsconfig.json create mode 100644 TController/app/ui/index.html create mode 100644 TController/crates/controller-core/Cargo.toml create mode 100644 TController/crates/controller-core/src/lib.rs create mode 100644 TController/crates/controller-core/src/processing/ampd.rs create mode 100644 TController/crates/controller-core/src/processing/calibration.rs create mode 100644 TController/crates/controller-core/src/processing/divergence.rs create mode 100644 TController/crates/controller-core/src/processing/endpoint.rs create mode 100644 TController/crates/controller-core/src/processing/ewma.rs create mode 100644 TController/crates/controller-core/src/processing/kf.rs create mode 100644 TController/crates/controller-core/src/processing/mod.rs create mode 100644 TController/crates/controller-core/src/processing/reconstructor.rs create mode 100644 TController/crates/controller-core/src/processing/savgol.rs create mode 100644 TController/crates/controller-core/src/processing/tracker.rs create mode 100644 TController/crates/controller-core/src/protocol/crc.rs create mode 100644 TController/crates/controller-core/src/protocol/frames.rs create mode 100644 TController/crates/controller-core/src/protocol/handler.rs create mode 100644 TController/crates/controller-core/src/protocol/mod.rs create mode 100644 TController/crates/controller-core/src/protocol/parser.rs create mode 100644 TController/crates/controller-core/src/protocol/retry.rs create mode 100644 TController/crates/controller-core/src/workflow.rs create mode 100644 TController/crates/controller-core/tests/endpoint_reliability.rs create mode 100644 TController/crates/controller-core/tests/tmp_diff_python.rs create mode 100644 TController/crates/controller-core/tests/workflow.rs delete mode 100644 TController/scripts/validate_endpoint.py delete mode 100644 TController/scripts/validate_online.py delete mode 100644 TController/scripts/validate_online_multimodal.py delete mode 100644 TController/src/Communication/__init__.py delete mode 100644 TController/src/Communication/protocol.py delete mode 100644 TController/src/DataProcessor/__init__.py delete mode 100644 TController/src/DataProcessor/_path.py delete mode 100644 TController/src/DataProcessor/calibration.py delete mode 100644 TController/src/DataProcessor/endpoint.py delete mode 100644 TController/src/DataProcessor/online_features.py delete mode 100644 TController/src/DataProcessor/reconstructor.py delete mode 100644 TController/src/gui/__init__.py delete mode 100644 TController/src/gui/_plot.py delete mode 100644 TController/src/gui/calibration_tab.py delete mode 100644 TController/src/gui/i18n.py delete mode 100644 TController/src/gui/locales/en_US.json delete mode 100644 TController/src/gui/locales/zh_CN.json delete mode 100644 TController/src/gui/main_window.py delete mode 100644 TController/src/gui/maintenance_tab.py delete mode 100644 TController/src/gui/potential_widget.py delete mode 100644 TController/src/gui/results_panel.py delete mode 100644 TController/src/gui/settings.py delete mode 100644 TController/src/gui/spectrum_widget.py delete mode 100644 TController/src/gui/themes.py delete mode 100644 TController/src/gui/widgets.py delete mode 100644 TController/src/main.py delete mode 100644 TController/tests/test_data_recording.py delete mode 100644 TController/tests/test_endpoint_reliability.py delete mode 100644 TController/tests/test_protocol.py diff --git a/TController/.gitignore b/TController/.gitignore new file mode 100644 index 0000000..c17da7f --- /dev/null +++ b/TController/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock.bak diff --git a/TController/Cargo.lock b/TController/Cargo.lock new file mode 100644 index 0000000..468384f --- /dev/null +++ b/TController/Cargo.lock @@ -0,0 +1,4788 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "controller-core" +version = "0.1.0" +dependencies = [ + "approx", + "ndarray", + "ndarray-npy", + "serde", + "serde_json", + "serialport", + "thiserror 2.0.20", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libudev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0" +dependencies = [ + "libc", + "libudev-sys", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndarray-npy" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b313788c468c49141a9d9b6131fc15f403e6ef4e8446a0b2e18f664ddb278a9" +dependencies = [ + "byteorder", + "ndarray", + "num-complex", + "num-traits", + "py_literal", + "zip", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialport" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "core-foundation", + "core-foundation-sys", + "io-kit-sys", + "libudev", + "mach2", + "nix", + "scopeguard", + "unescaper", + "windows-sys 0.52.0", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tcontroller-app" +version = "0.1.0" +dependencies = [ + "controller-core", + "serde", + "serde_json", + "tauri", + "tauri-build", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unescaper" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.20", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/TController/Cargo.toml b/TController/Cargo.toml new file mode 100644 index 0000000..9bd3b1b --- /dev/null +++ b/TController/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "2" +members = ["crates/controller-core", "app/src-tauri"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/TController/Logo.ico b/TController/Logo.ico deleted file mode 100644 index 1b24ebec6c41579b40772b3b7df2b6e51edcfc0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 82686 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

4H+ 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 O&c3VH_4#;>)c*t4#MRIM diff --git a/TController/Logo.png b/TController/Logo.png deleted file mode 100644 index 68af947df2b5f8a6393a61362f60be56eb702790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224012 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

nA%LhwBGPJp%dHUn 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�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 Self { + Self { + t1_deriv_threshold: 0.85, + dose: 0.05, + over_titrate: 0.8, + consensus_tol: 0.15, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CalPoint { + pub steps: u32, + pub vol: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryRun { + pub id: String, + pub started_at: u64, + pub duration_s: u64, + pub sample_volume: f64, + pub endpoint: Option, + pub method: Option, + pub confidence: Option, + pub reliability: Option, + pub scenario: String, + pub aborted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BackendSettings { + pub lang: String, + pub theme: String, + pub nav_collapsed: bool, + pub watchdog_enabled: bool, + pub detection: DetectionParams, + pub history: Vec, + pub port: String, + pub baud: u32, + pub sample_input: f64, + pub tubing_p1: bool, + pub tubing_p2: bool, +} + +impl Default for BackendSettings { + fn default() -> Self { + Self { + lang: "zh".into(), + theme: "dark".into(), + nav_collapsed: false, + watchdog_enabled: true, + detection: DetectionParams::default(), + history: Vec::new(), + port: DEFAULT_PORT.into(), + baud: DEFAULT_BAUD, + sample_input: 10.0, + tubing_p1: true, + tubing_p2: true, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KfSnapshot { + pub volume: f64, + pub std: f64, + pub nis: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EndpointSnapshot { + pub stage: String, + pub volume: f64, + pub method: String, + pub confidence: String, + pub potential_volume: Option, + pub spectral_volume: Option, + pub reliability: String, + pub kf: Option, + pub refined: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PotentialPoint { + pub v: f64, + pub t: f64, + pub e: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SpectrumFrame { + pub v: f64, + pub absorbance: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LogEntry { + pub t: u64, + pub level: String, + pub text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendSnapshot { + pub version: String, + pub ports: Vec, + pub connected: bool, + pub connecting: bool, + pub port: String, + pub baud: u32, + pub workflow: String, + pub volume: f64, + pub elapsed_ms: u64, + pub sample_volume: f64, + pub sample_input: f64, + pub tubing_op: Option, + pub tubing_p1: bool, + pub tubing_p2: bool, + pub pump1_running: bool, + pub pump2_running: bool, + pub pump1_steps: u32, + pub pump2_steps: u32, + pub pump_slope: u32, + pub pump_intercept: f64, + pub pump_r2: Option, + pub cal_points: Vec, + pub pot_points: Vec, + pub spectra: Vec, + pub spectral_state: String, + pub last_e: Option, + pub last_deriv: Option, + pub t1: Option, + pub final_result: Option, + pub watchdog_enabled: bool, + pub detection: DetectionParams, + pub rx: u64, + pub tx: u64, + pub bad_frames: u64, + pub heartbeat_tick: u64, + pub logs: Vec, + pub history: Vec, + pub lang: String, + pub theme: String, + pub nav_collapsed: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendInfo { + pub version: String, + pub calibre_found: bool, + pub calibre_path: Option, + pub ports: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectionPatch { + pub t1_deriv_threshold: Option, + pub dose: Option, + pub over_titrate: Option, + pub consensus_tol: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UiSettingsPatch { + pub lang: Option, + pub theme: Option, + pub nav_collapsed: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyCalibrationRequest { + pub points: Vec, + pub slope_steps_per_ml: f64, + pub intercept_ml: f64, + pub r2: Option, +} + +pub struct BackendRuntime { + handler: ProtocolHandler, + workflow: WorkflowEngine, + calibration: PumpCalibration, + reconstructor: Option, + settings_path: PathBuf, + settings: BackendSettings, + points: Vec, + pump_r2: Option, + connected: bool, + connecting: bool, + port: String, + baud: u32, + sample_input: f64, + tubing_op: Option, + tubing_p1: bool, + tubing_p2: bool, + pump1_running: bool, + pump2_running: bool, + pump1_steps: u32, + pump2_steps: u32, + volume: f64, + elapsed_ms: u64, + run_started: Option, + last_poll: Instant, + last_heartbeat: Instant, + last_e: Option, + last_deriv: Option, + spectral_state: String, + pot_points: Vec, + spectra: Vec, + t1: Option, + final_result: Option, + logs: Vec, + rx: u64, + tx: u64, + bad_frames: u64, + heartbeat_tick: u64, +} + +impl BackendRuntime { + pub fn new() -> Self { + let (reconstructor, calibre_path) = match Reconstructor::discover() { + Ok((value, path)) => (Some(value), Some(path)), + Err(_) => (None, None), + }; + let settings_path = settings_path(calibre_path.as_deref()); + let settings: BackendSettings = load_json(&settings_path).unwrap_or_default(); + let mut calibration = calibre_path + .as_deref() + .map(|path| PumpCalibration::load_from(path, 2)) + .unwrap_or_default(); + let (mut points, mut pump_r2) = calibre_path + .as_deref() + .map(|path| PumpCalibration::load_points_from(path, 2)) + .unwrap_or_default(); + if let Some(sidecar) = + load_json::(&calibration_sidecar_path(&settings_path)) + { + if sidecar.slope_ml_per_step > 0.0 { + calibration = PumpCalibration { + slope: sidecar.slope_ml_per_step, + intercept: sidecar.intercept_ml, + }; + points = sidecar + .points + .into_iter() + .map(|point| (point.steps, point.vol)) + .collect(); + pump_r2 = sidecar.r2; + } + } + let persisted_port = settings.port.clone(); + let persisted_baud = settings.baud; + let persisted_sample_input = settings.sample_input; + let persisted_tubing_p1 = settings.tubing_p1; + let persisted_tubing_p2 = settings.tubing_p2; + let workflow = WorkflowEngine::new(calibration.flow_rate(), calibration); + Self { + handler: ProtocolHandler::new(), + workflow, + calibration, + reconstructor, + settings_path, + settings, + points: points + .into_iter() + .map(|(steps, vol)| CalPoint { steps, vol }) + .collect(), + pump_r2, + connected: false, + connecting: false, + port: persisted_port, + baud: persisted_baud, + sample_input: persisted_sample_input, + tubing_op: None, + tubing_p1: persisted_tubing_p1, + tubing_p2: persisted_tubing_p2, + pump1_running: false, + pump2_running: false, + pump1_steps: 0, + pump2_steps: 0, + volume: 0.0, + elapsed_ms: 0, + run_started: None, + last_poll: Instant::now(), + last_heartbeat: Instant::now(), + last_e: None, + last_deriv: None, + spectral_state: "IDLE".into(), + pot_points: Vec::new(), + spectra: Vec::new(), + t1: None, + final_result: None, + logs: Vec::new(), + rx: 0, + tx: 0, + bad_frames: 0, + heartbeat_tick: 0, + } + } + + pub(crate) fn set_sample_input(&mut self, value: f64) { + self.sample_input = value; + self.settings.sample_input = value; + self.save_settings(); + } + + fn save_settings(&self) { + let _ = save_json(&self.settings_path, &self.settings); + } + + pub(crate) fn set_tubing_pumps(&mut self, p1: bool, p2: bool) -> Result<(), String> { + if !p1 && !p2 { + return Err("至少选择一台泵".into()); + } + if self.tubing_op.is_some() || self.workflow.can_manual_stop() { + return Err("当前设备正忙".into()); + } + self.tubing_p1 = p1; + self.tubing_p2 = p2; + self.settings.tubing_p1 = p1; + self.settings.tubing_p2 = p2; + self.save_settings(); + Ok(()) + } + pub(crate) fn start_tubing(&mut self, op: &str) -> Result<(), String> { + if !self.connected { + return Err("请先连接设备".into()); + } + if !matches!(op, "prime" | "empty") { + return Err("未知管路操作".into()); + } + if self.tubing_op.is_some() || self.workflow.can_manual_stop() { + return Err("当前设备正忙".into()); + } + let mut count = 0; + if self.tubing_p1 { + self.handler.send(DownlinkCommand::FreeRun(1)); + self.pump1_running = true; + count += 1; + } + if self.tubing_p2 { + self.handler.send(DownlinkCommand::FreeRun(2)); + self.pump2_running = true; + count += 1; + } + if count == 0 { + return Err("至少选择一台泵".into()); + } + self.tx += count; + self.tubing_op = Some(op.into()); + self.log( + "info", + format!("管路{}开始", if op == "prime" { "预充" } else { "排空" }), + ); + Ok(()) + } + + pub(crate) fn stop_tubing(&mut self) { + if self.tubing_op.is_none() { + return; + } + self.handler.send(DownlinkCommand::FreeStop(0xff)); + self.tx += 1; + self.pump1_running = false; + self.pump2_running = false; + self.tubing_op = None; + self.log("ok", "管路操作已停止"); + } + + pub(crate) fn free_run(&mut self, pump: u8) -> Result<(), String> { + self.check_manual_pump(pump)?; + self.handler.send(DownlinkCommand::FreeRun(pump)); + self.tx += 1; + self.set_pump_running(pump, true); + self.log("info", format!("泵 {pump} 自由运行")); + Ok(()) + } + + pub(crate) fn free_stop(&mut self, pump: u8) -> Result<(), String> { + if !matches!(pump, 1 | 2 | 0xff) { + return Err("泵编号无效".into()); + } + self.handler.send(DownlinkCommand::FreeStop(pump)); + self.tx += 1; + if pump == 0xff { + self.pump1_running = false; + self.pump2_running = false; + } else { + self.set_pump_running(pump, false); + } + self.log("info", format!("泵 {pump} 已停止")); + Ok(()) + } + + pub(crate) fn jog(&mut self, pump: u8, steps: u32) -> Result<(), String> { + self.check_manual_pump(pump)?; + if steps == 0 { + return Err("步数必须为正数".into()); + } + let position = match pump { + 1 => self.pump1_steps.saturating_add(steps), + 2 => self.pump2_steps.saturating_add(steps), + _ => return Err("泵编号无效".into()), + }; + self.handler + .send(DownlinkCommand::MaxCount { pump, count: steps }); + self.tx += 1; + self.set_pump_position(pump, position); + self.log("ok", format!("泵 {pump} 定步 {steps} 步")); + Ok(()) + } + + fn check_manual_pump(&self, pump: u8) -> Result<(), String> { + if !matches!(pump, 1 | 2) { + return Err("泵编号无效".into()); + } + if !self.connected { + return Err("请先连接设备".into()); + } + if self.tubing_op.is_some() || self.workflow.can_manual_stop() { + return Err("当前设备正忙".into()); + } + Ok(()) + } + + fn set_pump_running(&mut self, pump: u8, running: bool) { + if pump == 1 { + self.pump1_running = running; + } else if pump == 2 { + self.pump2_running = running; + } + } + + fn log(&mut self, level: &str, text: impl Into) { + self.logs.push(LogEntry { + t: now_ms(), + level: level.into(), + text: text.into(), + }); + if self.logs.len() > 400 { + let excess = self.logs.len() - 400; + self.logs.drain(0..excess); + } + } + + fn tick(&mut self) { + let now = Instant::now(); + for event in self.handler.poll() { + self.handle_event(event); + } + if self.connected && now.duration_since(self.last_heartbeat) >= Duration::from_secs(1) { + self.handler.send_heartbeat(); + self.last_heartbeat = now; + self.tx += 1; + } + if self.run_started.is_some() { + self.elapsed_ms = now.duration_since(self.run_started.unwrap()).as_millis() as u64; + } + if now.duration_since(self.last_poll) >= Duration::from_millis(500) { + self.last_poll = now; + let outcome = self.workflow.poll(); + self.apply_outcome(outcome); + } + } + + fn handle_event(&mut self, event: Event) { + match event { + Event::Connected => { + self.connected = true; + self.connecting = false; + self.log("ok", format!("已连接到 {}", self.port)); + self.settings_path_parent_save(); + } + Event::Disconnected => { + self.connected = false; + self.connecting = false; + self.pump1_running = false; + self.pump2_running = false; + self.log("warn", "连接已断开"); + } + Event::Error(text) => { + self.connected = false; + self.connecting = false; + self.bad_frames += 1; + self.log("error", text); + } + Event::Ack(_) => { + self.rx += 1; + } + Event::Nak(_) => { + self.rx += 1; + self.log("warn", "设备拒绝命令"); + } + Event::PumpPos { pump, position } => self.set_pump_position(pump, position), + Event::PumpDone { pump, position } => { + self.set_pump_position(pump, position); + let outcome = self.workflow.on_pump_done(pump); + self.apply_outcome(outcome); + } + Event::Adc { value, position } => { + self.rx += 1; + self.set_pump_position(2, position); + let voltage = value as f64 * 3.3 / 65535.0 - 1.1; + self.last_e = Some(voltage); + let t = self + .run_started + .map(|start| start.elapsed().as_secs_f64()) + .unwrap_or(0.0); + self.pot_points.push(PotentialPoint { + v: self.volume, + t, + e: voltage, + }); + if self.pot_points.len() > 6000 { + let excess = self.pot_points.len() - 6000; + self.pot_points.drain(0..excess); + } + self.workflow.on_adc(position, t, voltage); + } + Event::Spectral(values) => { + self.rx += 1; + let raw: Vec = values.iter().map(|v| *v as f64).collect(); + let spectrum = self + .reconstructor + .as_ref() + .and_then(|reconstructor| reconstructor.reconstruct(&raw).ok()) + .map(|(_, values)| downsample_spectrum(&values)) + .unwrap_or_else(|| raw.clone()); + self.spectra.push(SpectrumFrame { + v: self.volume, + absorbance: spectrum.clone(), + }); + if self.spectra.len() > 2000 { + let excess = self.spectra.len() - 2000; + self.spectra.drain(0..excess); + } + self.workflow.on_spectrum(&raw); + } + Event::Heartbeat(uptime) => { + self.heartbeat_tick = uptime as u64; + self.rx += 1; + } + } + } + + fn set_pump_position(&mut self, pump: u8, position: u32) { + if pump == 1 { + self.pump1_steps = position; + } else if pump == 2 { + self.pump2_steps = position; + self.volume = self.calibration.volume_from_steps(position).max(0.0); + } + } + + fn apply_outcome(&mut self, outcome: WorkflowOutcome) { + self.set_workflow(outcome.state); + for command in outcome.commands { + self.send_pump_command(command); + } + if let Some(result) = outcome.detection { + let snapshot = endpoint_snapshot("t1", &result, None); + if outcome.first_endpoint.is_some() { + self.t1 = Some(snapshot); + } + } + if let Some(volume) = outcome.refined_endpoint { + if let Some(result) = self.t1.as_ref() { + self.final_result = Some(EndpointSnapshot { + stage: "final".into(), + volume, + method: result.method.clone(), + confidence: result.confidence.clone(), + potential_volume: result.potential_volume, + spectral_volume: result.spectral_volume, + reliability: result.reliability.clone(), + kf: result.kf.clone(), + refined: Some(volume), + }); + } + self.pump2_running = false; + self.persist_history(false); + } + } + + fn set_workflow(&mut self, state: TitrationState) { + self.pump1_running = matches!(state, TitrationState::Injecting); + self.pump2_running = matches!( + state, + TitrationState::Titrating | TitrationState::Degree1 | TitrationState::Titrating2 + ); + if matches!( + state, + TitrationState::Idle | TitrationState::Done | TitrationState::Error + ) { + self.pump1_running = false; + self.pump2_running = false; + } + } + + fn send_pump_command(&mut self, command: PumpCommand) { + let downlink: DownlinkCommand = command.into(); + self.handler.send(downlink); + self.tx += 1; + } + + pub(crate) fn start(&mut self) -> Result<(), String> { + if !self.connected { + return Err("请先连接设备".into()); + } + self.clear_run(); + self.run_started = Some(Instant::now()); + let outcome = self.workflow.start(self.sample_input); + self.apply_outcome(outcome); + self.log("info", format!("开始滴定,样品 {} mL", self.sample_input)); + Ok(()) + } + + fn clear_run(&mut self) { + self.elapsed_ms = 0; + self.volume = 0.0; + self.pump1_steps = 0; + self.pump2_steps = 0; + self.pot_points.clear(); + self.spectra.clear(); + self.t1 = None; + self.final_result = None; + self.last_e = None; + self.last_deriv = None; + self.spectral_state = "IDLE".into(); + self.run_started = None; + let outcome = self.workflow.abort(); + self.apply_outcome(outcome); + } + + pub(crate) fn manual_stop(&mut self) { + let outcome = self.workflow.manual_stop(); + self.apply_outcome(outcome); + self.run_started = None; + self.log("warn", "手动停止"); + } + + pub(crate) fn abort(&mut self) { + let was_running = self.workflow.can_manual_stop(); + if self.connected { + self.send_pump_command(PumpCommand::FreeStop(0xff)); + } + let outcome = self.workflow.abort(); + self.apply_outcome(outcome); + self.tubing_op = None; + self.run_started = None; + if was_running { + self.persist_history(true); + } + self.log("warn", "用户中止,全泵停止"); + } + + pub(crate) fn reset(&mut self) { + if self.connected { + self.handler.send(DownlinkCommand::Reset); + self.tx += 1; + } + self.clear_run(); + self.tubing_op = None; + self.log("info", "设备已复位"); + } + + fn persist_history(&mut self, aborted: bool) { + let id = format!("{}", now_ms()); + self.settings.history.insert( + 0, + HistoryRun { + id, + started_at: now_ms(), + duration_s: self.elapsed_ms / 1000, + sample_volume: self.sample_input, + endpoint: self + .final_result + .as_ref() + .map(|result| result.volume) + .or_else(|| self.t1.as_ref().map(|result| result.volume)), + method: self + .final_result + .as_ref() + .map(|result| result.method.clone()), + confidence: self + .final_result + .as_ref() + .map(|result| result.confidence.clone()), + reliability: self + .final_result + .as_ref() + .map(|result| result.reliability.clone()), + scenario: "normal".into(), + aborted, + }, + ); + self.settings.history.truncate(30); + self.save_settings(); + } + + fn settings_path_parent_save(&self) { + self.save_settings(); + } + + pub(crate) fn snapshot(&self) -> BackendSnapshot { + BackendSnapshot { + version: controller_core::VERSION.into(), + ports: controller_core::protocol::list_ports(), + connected: self.connected, + connecting: self.connecting, + port: self.port.clone(), + baud: self.baud, + workflow: workflow_name(self.workflow.state), + volume: self.volume, + elapsed_ms: self.elapsed_ms, + sample_volume: self.sample_input, + sample_input: self.sample_input, + tubing_op: self.tubing_op.clone(), + tubing_p1: self.tubing_p1, + tubing_p2: self.tubing_p2, + pump1_running: self.pump1_running, + pump2_running: self.pump2_running, + pump1_steps: self.pump1_steps, + pump2_steps: self.pump2_steps, + pump_slope: if self.calibration.slope > 0.0 { + (1.0 / self.calibration.slope).round() as u32 + } else { + 0 + }, + pump_intercept: self.calibration.intercept, + pump_r2: self.pump_r2, + cal_points: self.points.clone(), + pot_points: self.pot_points.clone(), + spectra: self.spectra.clone(), + spectral_state: self.spectral_state.clone(), + last_e: self.last_e, + last_deriv: self.last_deriv, + t1: self.t1.clone(), + final_result: self.final_result.clone(), + watchdog_enabled: self.settings.watchdog_enabled, + detection: self.settings.detection.clone(), + rx: self.rx, + tx: self.tx, + bad_frames: self.bad_frames, + heartbeat_tick: self.heartbeat_tick, + logs: self.logs.clone(), + history: self.settings.history.clone(), + lang: self.settings.lang.clone(), + theme: self.settings.theme.clone(), + nav_collapsed: self.settings.nav_collapsed, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CalibrationSidecar { + points: Vec, + slope_ml_per_step: f64, + intercept_ml: f64, + r2: Option, +} + +pub fn run_event_loop(app: AppHandle, state: Arc>) { + std::thread::spawn(move || loop { + let snapshot = { + let Ok(mut runtime) = state.lock() else { break }; + runtime.tick(); + runtime.snapshot() + }; + let _ = app.emit(STATE_EVENT, snapshot); + std::thread::sleep(Duration::from_millis(50)); + }); +} + +pub fn emit_snapshot(app: &AppHandle, state: &Mutex) { + if let Ok(runtime) = state.lock() { + let _ = app.emit(STATE_EVENT, runtime.snapshot()); + } +} + +pub fn backend_info() -> BackendInfo { + let (calibre_found, calibre_path) = match Reconstructor::discover() { + Ok((_, path)) => (true, Some(path.display().to_string())), + Err(_) => (false, None), + }; + BackendInfo { + version: controller_core::VERSION.into(), + calibre_found, + calibre_path, + ports: controller_core::protocol::list_ports(), + } +} + +pub fn connect(runtime: &mut BackendRuntime, port: String, baud: u32) { + runtime.port = port.clone(); + runtime.baud = baud; + runtime.settings.port = port; + runtime.settings.baud = baud; + runtime.save_settings(); + runtime.connecting = true; + runtime.handler.connect(&runtime.port, runtime.baud); +} + +pub fn disconnect(runtime: &mut BackendRuntime) { + runtime.handler.disconnect(); + runtime.connected = false; + runtime.connecting = false; +} + +pub fn set_detection(runtime: &mut BackendRuntime, patch: DetectionPatch) { + let detection = &mut runtime.settings.detection; + if let Some(value) = patch.t1_deriv_threshold { + detection.t1_deriv_threshold = value; + } + if let Some(value) = patch.dose { + detection.dose = value; + } + if let Some(value) = patch.over_titrate { + detection.over_titrate = value; + } + if let Some(value) = patch.consensus_tol { + detection.consensus_tol = value; + } + runtime.save_settings(); +} + +pub fn set_ui_settings(runtime: &mut BackendRuntime, patch: UiSettingsPatch) { + if let Some(value) = patch.lang { + runtime.settings.lang = value; + } + if let Some(value) = patch.theme { + runtime.settings.theme = value; + } + if let Some(value) = patch.nav_collapsed { + runtime.settings.nav_collapsed = value; + } + runtime.save_settings(); +} + +pub fn set_watchdog(runtime: &mut BackendRuntime, enabled: bool) { + runtime.settings.watchdog_enabled = enabled; + runtime.save_settings(); + if enabled && runtime.connected { + runtime.handler.send_heartbeat(); + runtime.tx += 1; + } +} + +pub fn apply_calibration( + runtime: &mut BackendRuntime, + request: ApplyCalibrationRequest, +) -> Result<(), String> { + if request.points.len() < 2 + || request.slope_steps_per_ml <= 0.0 + || !request.intercept_ml.is_finite() + { + return Err("标定参数无效".into()); + } + runtime.calibration = PumpCalibration { + slope: 1.0 / request.slope_steps_per_ml, + intercept: request.intercept_ml, + }; + runtime.workflow = WorkflowEngine::new(runtime.calibration.flow_rate(), runtime.calibration); + runtime.points = request.points; + runtime.pump_r2 = request.r2; + let sidecar = CalibrationSidecar { + points: runtime.points.clone(), + slope_ml_per_step: runtime.calibration.slope, + intercept_ml: runtime.calibration.intercept, + r2: runtime.pump_r2, + }; + save_json(&calibration_sidecar_path(&runtime.settings_path), &sidecar) + .map_err(|error| error.to_string()) +} + +pub fn workflow_name(state: TitrationState) -> String { + match state { + TitrationState::Idle => "idle", + TitrationState::Injecting => "injecting", + TitrationState::Titrating => "titrating", + TitrationState::Degree1 => "degree1", + TitrationState::Titrating2 => "titrating2", + TitrationState::Done => "done", + TitrationState::Error => "error", + } + .into() +} + +fn endpoint_snapshot( + stage: &str, + result: &controller_core::processing::EndpointResult, + refined: Option, +) -> EndpointSnapshot { + let method = match result.method { + controller_core::processing::Method::Consensus => "consensus", + controller_core::processing::Method::PotentialOnly => "potential_only", + controller_core::processing::Method::SpectralOnly => "spectral_only", + controller_core::processing::Method::Conflict => "conflict", + }; + let confidence = match result.confidence { + controller_core::processing::Confidence::High => "high", + controller_core::processing::Confidence::Medium => "medium", + controller_core::processing::Confidence::Low => "low", + }; + let potential_volume = result.potential.as_ref().map(|value| value.volume); + let spectral_volume = result.spectral.as_ref().map(|value| value.volume); + let kf = result + .reliability + .endpoint_std + .zip(result.reliability.nis) + .map(|(std, nis)| KfSnapshot { + volume: result.volume, + std, + nis, + }); + EndpointSnapshot { + stage: stage.into(), + volume: result.volume, + method: method.into(), + confidence: confidence.into(), + potential_volume, + spectral_volume, + reliability: result.reliability.status.clone(), + kf, + refined, + } +} + +fn downsample_spectrum(values: &[f64]) -> Vec { + if values.len() <= 61 { + return values.to_vec(); + } + (0..61) + .map(|index| { + let source = index * (values.len() - 1) / 60; + values[source] + }) + .collect() +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn settings_path(calibre_path: Option<&Path>) -> PathBuf { + calibre_path + .and_then(Path::parent) + .map(|path| path.join("settings.json")) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|parent| parent.join("settings.json"))) + }) + .unwrap_or_else(|| PathBuf::from("settings.json")) +} + +fn calibration_sidecar_path(settings_path: &Path) -> PathBuf { + settings_path.with_file_name("pump2_calibration.json") +} + +fn load_json Deserialize<'de>>(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) +} + +fn save_json(path: &Path, value: &T) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let text = serde_json::to_string_pretty(value).map_err(std::io::Error::other)?; + fs::write(path, text) +} + +pub type SharedBackend = Arc>; diff --git a/TController/app/src-tauri/src/main.rs b/TController/app/src-tauri/src/main.rs new file mode 100644 index 0000000..6209755 --- /dev/null +++ b/TController/app/src-tauri/src/main.rs @@ -0,0 +1,277 @@ +//! TController Tauri 应用入口:命令层只负责边界,设备状态由 backend runtime 持有。 + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod backend; + +use std::sync::{Arc, Mutex}; + +use backend::{ + apply_calibration, backend_info as info, connect as connect_runtime, + disconnect as disconnect_runtime, emit_snapshot, set_detection as set_detection_runtime, + set_ui_settings as set_ui_settings_runtime, set_watchdog as set_watchdog_runtime, + ApplyCalibrationRequest, BackendInfo, BackendRuntime, BackendSnapshot, DetectionPatch, + SharedBackend, UiSettingsPatch, +}; +use tauri::{AppHandle, Manager, State}; + +#[tauri::command] +fn backend_info() -> BackendInfo { + info() +} + +#[tauri::command] +fn backend_state(state: State<'_, SharedBackend>) -> Result { + state + .lock() + .map(|runtime| runtime.snapshot()) + .map_err(|_| "backend state poisoned".into()) +} + +#[tauri::command] +fn list_ports() -> Vec { + controller_core::protocol::list_ports() +} + +#[tauri::command] +fn connect( + app: AppHandle, + state: State<'_, SharedBackend>, + port: String, + baud: u32, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + connect_runtime(&mut runtime, port, baud); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn disconnect(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + disconnect_runtime(&mut runtime); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn set_sample_input( + app: AppHandle, + state: State<'_, SharedBackend>, + value: f64, +) -> Result<(), String> { + if !value.is_finite() || value <= 0.0 { + return Err("样品体积必须为正数".into()); + } + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.set_sample_input(value); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn set_tubing_pumps( + app: AppHandle, + state: State<'_, SharedBackend>, + p1: bool, + p2: bool, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.set_tubing_pumps(p1, p2)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn start_titration(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.start()?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn manual_stop(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.manual_stop(); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn abort(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.abort(); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn reset(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.reset(); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn start_tubing(app: AppHandle, state: State<'_, SharedBackend>, op: String) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.start_tubing(&op)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn stop_tubing(app: AppHandle, state: State<'_, SharedBackend>) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.stop_tubing(); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn free_run(app: AppHandle, state: State<'_, SharedBackend>, pump: u8) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.free_run(pump)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn free_stop(app: AppHandle, state: State<'_, SharedBackend>, pump: u8) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.free_stop(pump)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn jog( + app: AppHandle, + state: State<'_, SharedBackend>, + pump: u8, + steps: u32, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + runtime.jog(pump, steps)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn set_watchdog( + app: AppHandle, + state: State<'_, SharedBackend>, + enabled: bool, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + set_watchdog_runtime(&mut runtime, enabled); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn set_detection( + app: AppHandle, + state: State<'_, SharedBackend>, + patch: DetectionPatch, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + set_detection_runtime(&mut runtime, patch); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn set_ui_settings( + app: AppHandle, + state: State<'_, SharedBackend>, + patch: UiSettingsPatch, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + set_ui_settings_runtime(&mut runtime, patch); + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +#[tauri::command] +fn apply_pump_calibration( + app: AppHandle, + state: State<'_, SharedBackend>, + request: ApplyCalibrationRequest, +) -> Result<(), String> { + { + let mut runtime = state.lock().map_err(|_| "backend state poisoned")?; + apply_calibration(&mut runtime, request)?; + } + emit_snapshot(&app, state.inner()); + Ok(()) +} + +fn main() { + let runtime = Arc::new(Mutex::new(BackendRuntime::new())); + tauri::Builder::default() + .manage(runtime) + .setup(|app| { + let state = app.state::(); + backend::run_event_loop(app.handle().clone(), Arc::clone(state.inner())); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + backend_info, + backend_state, + list_ports, + connect, + disconnect, + set_sample_input, + set_tubing_pumps, + start_titration, + manual_stop, + abort, + reset, + start_tubing, + stop_tubing, + free_run, + free_stop, + jog, + set_watchdog, + set_detection, + set_ui_settings, + apply_pump_calibration, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/TController/app/src-tauri/tauri.conf.json b/TController/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..aa7f1d5 --- /dev/null +++ b/TController/app/src-tauri/tauri.conf.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "TController", + "version": "0.1.0", + "identifier": "com.autotitrator.tcontroller", + "build": { + "frontendDist": "../ui-next/out" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "AutoTitrator — TController", + "width": 1360, + "height": 920, + "minWidth": 1120, + "minHeight": 760, + "decorations": false, + "transparent": false, + "shadow": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": ["icons/icon.ico"] + } +} diff --git a/TController/app/ui-next/.gitignore b/TController/app/ui-next/.gitignore new file mode 100644 index 0000000..5ca3e4f --- /dev/null +++ b/TController/app/ui-next/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem +.tmp-npz/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/TController/app/ui-next/.npmrc b/TController/app/ui-next/.npmrc new file mode 100644 index 0000000..1ea82f5 --- /dev/null +++ b/TController/app/ui-next/.npmrc @@ -0,0 +1 @@ +allow-scripts=true diff --git a/TController/app/ui-next/README.md b/TController/app/ui-next/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/TController/app/ui-next/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/TController/app/ui-next/app/favicon.ico b/TController/app/ui-next/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/TController/app/ui-next/app/globals.css b/TController/app/ui-next/app/globals.css new file mode 100644 index 0000000..93f39ab --- /dev/null +++ b/TController/app/ui-next/app/globals.css @@ -0,0 +1,179 @@ +/* Hallmark · macrostructure: Workbench · tone: utilitarian · theme: instrument-neutral + * genre: modern-minimal · audience: lab operator · use: start run / watch endpoint + * nav: N3 side-rail · footer: status strip · enrichment: none + */ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +/* ===== 灰阶主题(neutral)—— 浅色 ===== */ +:root { + --radius: 0.375rem; + --background: oklch(0.965 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(0.995 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.94 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.94 0 0); + --muted-foreground: oklch(0.45 0 0); + --accent: oklch(0.94 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.55 0.20 27); + --border: oklch(0.86 0 0); + --input: oklch(0.86 0 0); + --ring: oklch(0.55 0 0); + /* 图表灰阶色带(5 级) */ + --chart-1: oklch(0.205 0 0); + --chart-2: oklch(0.4 0 0); + --chart-3: oklch(0.55 0 0); + --chart-4: oklch(0.7 0 0); + --chart-5: oklch(0.82 0 0); + /* 侧边导航 */ + --sidebar: oklch(0.94 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.90 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.86 0 0); + --sidebar-ring: oklch(0.55 0 0); + /* 仪器语义色(状态/曲线,灰阶界面中仅有的彩色语义) + OKLCH 表达与中性轴同空间;明度校准至浅色背景(0.965)下 ≥4.5:1。 */ + --status-ok: oklch(0.52 0.14 145); + --status-warn: oklch(0.58 0.14 65); + --status-danger: oklch(0.52 0.20 27); + --curve-potential: oklch(0.18 0 0); + --curve-derivative: oklch(0.48 0 0); + --curve-spectrum: oklch(0.22 0 0); + --chart-grid: oklch(0.145 0 0 / 18%); + --chart-well: oklch(0.98 0 0); +} + +/* ===== 灰阶主题(neutral)—— 深色 ===== */ +.dark { + --background: oklch(0.105 0 0); + --foreground: oklch(0.97 0 0); + --card: oklch(0.152 0 0); + --card-foreground: oklch(0.97 0 0); + --popover: oklch(0.165 0 0); + --popover-foreground: oklch(0.97 0 0); + --primary: oklch(0.94 0 0); + --primary-foreground: oklch(0.16 0 0); + --secondary: oklch(0.22 0 0); + --secondary-foreground: oklch(0.97 0 0); + --muted: oklch(0.22 0 0); + --muted-foreground: oklch(0.70 0 0); + --accent: oklch(0.22 0 0); + --accent-foreground: oklch(0.97 0 0); + --destructive: oklch(0.68 0.19 22); + --border: oklch(1 0 0 / 14%); + --input: oklch(1 0 0 / 16%); + --ring: oklch(0.72 0 0); + --chart-1: oklch(0.95 0 0); + --chart-2: oklch(0.78 0 0); + --chart-3: oklch(0.62 0 0); + --chart-4: oklch(0.48 0 0); + --chart-5: oklch(0.36 0 0); + --sidebar: oklch(0.12 0 0); + --sidebar-foreground: oklch(0.97 0 0); + --sidebar-primary: oklch(0.94 0 0); + --sidebar-primary-foreground: oklch(0.16 0 0); + --sidebar-accent: oklch(0.20 0 0); + --sidebar-accent-foreground: oklch(0.97 0 0); + --sidebar-border: oklch(1 0 0 / 12%); + --sidebar-ring: oklch(0.72 0 0); + --status-ok: oklch(0.76 0.16 145); + --status-warn: oklch(0.80 0.15 75); + --status-danger: oklch(0.70 0.18 22); + --curve-potential: oklch(0.94 0 0); + --curve-derivative: oklch(0.58 0 0); + --curve-spectrum: oklch(0.92 0 0); + --chart-grid: oklch(1 0 0 / 22%); + --chart-well: oklch(0.125 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + html, + body { + overflow-x: clip; + } + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: "tnum"; /* 数值等宽,仪器读数不抖动 */ + } + /* Tauri 无边框窗口:标题栏空白处拖拽,控件保持可点 */ + [data-tauri-drag-region] { + -webkit-app-region: drag; + } + [data-tauri-drag-region] button, + [data-tauri-drag-region] input, + [data-tauri-drag-region] [role="combobox"], + [data-no-drag] { + -webkit-app-region: no-drag; + } + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-thumb { + @apply bg-border rounded-full; + } + ::-webkit-scrollbar-track { + background: transparent; + } +} + +/* 仪器读数字体:等宽数字 */ +.readout { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} diff --git a/TController/app/ui-next/app/layout.tsx b/TController/app/ui-next/app/layout.tsx new file mode 100644 index 0000000..791a91e --- /dev/null +++ b/TController/app/ui-next/app/layout.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "next-themes"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "AutoTitrator Console", + description: "Multimodal titration console", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + + ); +} diff --git a/TController/app/ui-next/app/page.tsx b/TController/app/ui-next/app/page.tsx new file mode 100644 index 0000000..c7c060f --- /dev/null +++ b/TController/app/ui-next/app/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { AppShell } from "@/components/app-shell"; + +export default function Home() { + return ; +} diff --git a/TController/app/ui-next/components.json b/TController/app/ui-next/components.json new file mode 100644 index 0000000..02e61e0 --- /dev/null +++ b/TController/app/ui-next/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/TController/app/ui-next/components/app-shell.tsx b/TController/app/ui-next/components/app-shell.tsx new file mode 100644 index 0000000..bb7336f --- /dev/null +++ b/TController/app/ui-next/components/app-shell.tsx @@ -0,0 +1,408 @@ +"use client"; + +/** + * 应用外壳:顶栏工具区 + 左侧导航 + 底部状态条。 + * 仪器布局原则:运行控制始终可见(顶栏),状态始终可见(底栏)。 + */ +import { useEffect } from "react"; +import { useTheme } from "next-themes"; +import { + Activity, + FlaskConical, + Gauge, + History, + Maximize2, + Minus, + Moon, + OctagonX, + Play, + RotateCcw, + Settings, + Square, + Sun, + Monitor, + PanelLeftClose, + PanelLeftOpen, + Wrench, + X, + Zap, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useStore } from "@/lib/store"; +import type { PageId } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { backend } from "@/lib/backend"; +import { cn } from "@/lib/utils"; +import { TitrationPage } from "@/components/pages/titration-page"; +import { CalibrationPage } from "@/components/pages/calibration-page"; +import { MaintenancePage } from "@/components/pages/maintenance-page"; +import { HistoryPage } from "@/components/pages/history-page"; +import { SettingsPage } from "@/components/pages/settings-page"; + +const NAV: { id: PageId; icon: typeof Gauge; key: "nav.titration" | "nav.calibration" | "nav.maintenance" | "nav.history" | "nav.settings" }[] = [ + { id: "titration", icon: FlaskConical, key: "nav.titration" }, + { id: "calibration", icon: Gauge, key: "nav.calibration" }, + { id: "maintenance", icon: Wrench, key: "nav.maintenance" }, + { id: "history", icon: History, key: "nav.history" }, + { id: "settings", icon: Settings, key: "nav.settings" }, +]; + +function Led({ on, color = "ok" }: { on: boolean; color?: "ok" | "warn" | "danger" }) { + const map = { + ok: "bg-[var(--status-ok)] shadow-[0_0_6px_var(--status-ok)]", + warn: "bg-[var(--status-warn)] shadow-[0_0_6px_var(--status-warn)]", + danger: "bg-[var(--status-danger)] shadow-[0_0_6px_var(--status-danger)]", + }; + return ( + + ); +} + +function tauriWindow() { + const api = (globalThis as { __TAURI__?: { window?: { getCurrentWindow?: () => { + minimize: () => Promise; + toggleMaximize: () => Promise; + close: () => Promise; + } } } }).__TAURI__; + return api?.window?.getCurrentWindow?.() ?? null; +} + +function WindowButtons() { + const t = useT(); + return ( +

+ ); +} + +function TitleBar() { + const t = useT(); + const { setTheme } = useTheme(); + return ( +
+
+
+ +
+ {t("app.name")} +
+
+
+ + + + + + + setTheme("light")}> {t("settings.theme.light")} + setTheme("dark")}> {t("settings.theme.dark")} + setTheme("system")}> {t("settings.theme.system")} + + + +
+
+ ); +} + +function ToolBar() { + const t = useT(); + const { + connected, connecting, port, baud, ports, + setPort, setBaud, + workflow, + tubingOp, + sampleInput, + setSampleInput, + } = useStore(); + + const running = ["injecting", "titrating", "degree1", "titrating2"].includes(workflow); + const canStart = connected && !running && !tubingOp; + const cluster = "flex h-7 items-stretch overflow-hidden rounded-sm border bg-background"; + const cell = + "h-full rounded-none border-0 py-0 shadow-none font-mono text-[12px] leading-[26px] focus-visible:z-10 focus-visible:ring-2 [&_[data-slot=select-value]]:h-full [&_[data-slot=select-value]]:leading-[26px]"; + + return ( +
+
+ + + + + + + + + {connecting ? t("toolbar.connecting") : connected ? t("toolbar.connected") : t("toolbar.disconnected")} + +
+ + + +
+ + + + + +
+ + + +
+ + +
+ ); +} + +function LangSwitch() { + const lang = useStore((s) => s.lang); + const setLang = useStore((s) => s.setLang); + return ( + + ); +} + +function SideNav() { + const t = useT(); + const page = useStore((s) => s.page); + const setPage = useStore((s) => s.setPage); + const collapsed = useStore((s) => s.navCollapsed); + const toggleNav = useStore((s) => s.toggleNav); + return ( + + ); +} + +function Readout({ label, value, unit }: { label: string; value: string; unit?: string }) { + return ( + + {label} + {value} + {unit && {unit}} + + ); +} + +function StatusBar() { + const t = useT(); + const { workflow, elapsedMs, volume, lastE, lastDeriv, spectra, rx, tx, heartbeatTick, connected, tubingOp } = useStore(); + const mm = Math.floor(elapsedMs / 60000); + const ss = Math.floor((elapsedMs % 60000) / 1000); + const tone = + workflow === "error" ? "text-[var(--status-danger)]" : + workflow === "done" ? "text-[var(--status-ok)]" : + tubingOp ? "text-[var(--status-ok)]" : + workflow === "idle" ? "text-muted-foreground" : "text-foreground"; + return ( +
+ {tubingOp ? t(`state.${tubingOp}`) : t(`state.${workflow}`)} + {String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")} + + + + + +
+ {t("statusbar.rx")} {rx} + {t("statusbar.tx")} {tx} + + + {t("statusbar.heartbeat")} + +
+ ); +} + +export function AppShell() { + const page = useStore((s) => s.page); + useEffect(() => { + void backend.initialize(); + }, []); + return ( +
+ + +
+ +
+ {page === "titration" && } + {page === "calibration" && } + {page === "maintenance" && } + {page === "history" && } + {page === "settings" && } +
+
+ +
+ ); +} diff --git a/TController/app/ui-next/components/charts/potential-chart.tsx b/TController/app/ui-next/components/charts/potential-chart.tsx new file mode 100644 index 0000000..517c5a5 --- /dev/null +++ b/TController/app/ui-next/components/charts/potential-chart.tsx @@ -0,0 +1,236 @@ +"use client"; + +/** + * 电位–体积曲线:主曲线 E(V) + 右轴一阶导数 dE/dV + 终点标记 + 十字线。 + * 纯 Canvas 绘制(灰阶配色,主题自适应),可承载数千点实时追加。 + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import { useStore } from "@/lib/store"; +import { cssVar, fmt, niceTicks, setupCanvas, thinTicks } from "@/lib/chart-utils"; + +const M = { l: 46, r: 46, t: 12, b: 26 }; // 边距 + +export function PotentialChart() { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const potPoints = useStore((s) => s.potPoints); + const t1 = useStore((s) => s.t1); + const final = useStore((s) => s.final); + const { resolvedTheme } = useTheme(); + const [size, setSize] = useState({ w: 0, h: 0 }); + const [hoverX, setHoverX] = useState(null); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + /* 一阶导数序列(5 点窗口差分) */ + const deriv = useMemo(() => { + const out: { v: number; d: number }[] = []; + for (let i = 5; i < potPoints.length; i++) { + const a = potPoints[i - 5]; + const b = potPoints[i]; + if (b.v > a.v) out.push({ v: b.v, d: (b.e - a.e) / (b.v - a.v) }); + } + return out; + }, [potPoints]); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || size.w < 10 || size.h < 10) return; + const ctx = setupCanvas(canvas); + if (!ctx) return; + + const grid = cssVar("--chart-grid"); + const gridStrong = cssVar("--border"); + const text = cssVar("--muted-foreground"); + const colE = cssVar("--curve-potential"); + const colD = cssVar("--curve-derivative"); + const accent = cssVar("--foreground"); + + const W = size.w; + const H = size.h; + ctx.clearRect(0, 0, W, H); + const pw = W - M.l - M.r; + const ph = H - M.t - M.b; + if (pw < 10 || ph < 10) return; + + /* 量程 */ + const vMax = Math.max(1, potPoints.length ? potPoints[potPoints.length - 1].v * 1.04 : 10); + let eMin = Infinity; + let eMax = -Infinity; + for (const p of potPoints) { + if (p.e < eMin) eMin = p.e; + if (p.e > eMax) eMax = p.e; + } + if (!potPoints.length) { + eMin = -0.2; + eMax = 1.0; + } + const ePad = Math.max(0.05, (eMax - eMin) * 0.08); + eMin -= ePad; + eMax += ePad; + let dMax = 0.1; + for (const p of deriv) if (p.d > dMax) dMax = p.d; + dMax *= 1.15; + + const xOf = (v: number) => M.l + (v / vMax) * pw; + const yOfE = (e: number) => M.t + (1 - (e - eMin) / (eMax - eMin)) * ph; + const yOfD = (d: number) => M.t + (1 - d / dMax) * ph; + + /* 网格与刻度 */ + ctx.font = "10px ui-monospace, monospace"; + ctx.lineWidth = 1; + for (const v of thinTicks(niceTicks(0, vMax, 6), pw, 34)) { + const x = xOf(v); + ctx.strokeStyle = grid; + ctx.beginPath(); + ctx.moveTo(x, M.t); + ctx.lineTo(x, M.t + ph); + ctx.stroke(); + ctx.fillStyle = text; + ctx.textAlign = "center"; + ctx.fillText(fmt(v, 1), x, H - 8); + } + for (const e of thinTicks(niceTicks(eMin, eMax, 5), ph)) { + const y = yOfE(e); + ctx.strokeStyle = grid; + ctx.beginPath(); + ctx.moveTo(M.l, y); + ctx.lineTo(M.l + pw, y); + ctx.stroke(); + ctx.fillStyle = text; + ctx.textAlign = "right"; + ctx.fillText(fmt(e, 2), M.l - 6, y + 3); + } + ctx.textAlign = "left"; + for (const d of thinTicks(niceTicks(0, dMax, 4), ph)) { + if (d === 0) continue; + ctx.fillStyle = text; + ctx.fillText(fmt(d, 1), M.l + pw + 6, yOfD(d) + 3); + } + /* 轴框 */ + ctx.strokeStyle = gridStrong; + ctx.strokeRect(M.l, M.t, pw, ph); + + /* 导数曲线(右轴,虚线,与实线电位拉开) */ + if (deriv.length > 1) { + ctx.strokeStyle = colD; + ctx.lineWidth = 1.25; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + deriv.forEach((p, i) => { + const x = xOf(p.v); + const y = yOfD(Math.max(0, p.d)); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + ctx.setLineDash([]); + } + + /* 电位主曲线 */ + if (potPoints.length > 1) { + ctx.strokeStyle = colE; + ctx.lineWidth = 2.25; + ctx.lineJoin = "round"; + ctx.beginPath(); + potPoints.forEach((p, i) => { + const x = xOf(p.v); + const y = yOfE(p.e); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + } + + /* 终点标记 */ + const marks: { v: number; label: string }[] = []; + if (t1) marks.push({ v: t1.volume, label: "T1" }); + if (final && final.volume !== t1?.volume) marks.push({ v: final.volume, label: "EP" }); + marks.forEach((mk, i) => { + const x = xOf(mk.v); + ctx.strokeStyle = accent; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, M.t); + ctx.lineTo(x, M.t + ph); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = accent; + ctx.textAlign = "center"; + ctx.font = "bold 10px ui-monospace, monospace"; + ctx.fillText( + `${mk.label} ${mk.v.toFixed(2)}`, + Math.min(Math.max(x, M.l + 30), W - M.r - 30), + M.t + 10 + i * 12, + ); + }); + + /* 十字线 */ + if (hoverX !== null && potPoints.length > 1) { + const v = ((hoverX - M.l) / pw) * vMax; + if (v >= 0 && v <= vMax) { + let best = potPoints[0]; + let bd = Infinity; + for (const p of potPoints) { + const d = Math.abs(p.v - v); + if (d < bd) { + bd = d; + best = p; + } + } + const x = xOf(best.v); + ctx.strokeStyle = cssVar("--ring"); + ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(x, M.t); + ctx.lineTo(x, M.t + ph); + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = colE; + ctx.beginPath(); + ctx.arc(x, yOfE(best.e), 3, 0, Math.PI * 2); + ctx.fill(); + const label = `V=${best.v.toFixed(2)} mL E=${best.e.toFixed(3)} V`; + ctx.font = "10px ui-monospace, monospace"; + const tw = ctx.measureText(label).width + 12; + const bx = Math.min(x + 8, W - M.r - tw); + ctx.fillStyle = cssVar("--popover"); + ctx.strokeStyle = gridStrong; + ctx.beginPath(); + ctx.roundRect(bx, M.t + 16, tw, 18, 3); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = cssVar("--popover-foreground"); + ctx.textAlign = "left"; + ctx.fillText(label, bx + 6, M.t + 28); + } + } + }, [potPoints, deriv, t1, final, size, hoverX, resolvedTheme]); + + useEffect(() => { + const raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [draw]); + + return ( +
+ { + const rect = e.currentTarget.getBoundingClientRect(); + setHoverX(e.clientX - rect.left); + }} + onMouseLeave={() => setHoverX(null)} + /> +
+ ); +} diff --git a/TController/app/ui-next/components/charts/spectrum-chart.tsx b/TController/app/ui-next/components/charts/spectrum-chart.tsx new file mode 100644 index 0000000..4b527fd --- /dev/null +++ b/TController/app/ui-next/components/charts/spectrum-chart.tsx @@ -0,0 +1,209 @@ +"use client"; + +/** + * 光谱演化热图 + 最新光谱曲线。 + * 热图:横轴滴加体积、纵轴波长、灰度=吸光度 —— 终点处 560nm 指示剂谱带 + * 的突变在灰阶图上表现为一条清晰的纵向亮带。 + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import { useStore } from "@/lib/store"; +import { WAVELENGTHS } from "@/lib/types"; +import { cssVar, fmt, niceTicks, setupCanvas, thinTicks } from "@/lib/chart-utils"; + +const M = { l: 28, r: 6, t: 6, b: 16 }; +const MAX_COLS = 720; + +export function SpectrumHeatmap() { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const spectra = useStore((s) => s.spectra); + const { resolvedTheme } = useTheme(); + const [size, setSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || size.w < 10 || size.h < 10) return; + const ctx = setupCanvas(canvas); + if (!ctx) return; + const dark = resolvedTheme === "dark"; + + const W = size.w; + const H = size.h; + ctx.clearRect(0, 0, W, H); + const pw = W - M.l - M.r; + const ph = H - M.t - M.b; + if (pw < 10 || ph < 10) return; + + const grid = cssVar("--chart-grid"); + const text = cssVar("--muted-foreground"); + ctx.strokeStyle = cssVar("--border"); + ctx.strokeRect(M.l, M.t, pw, ph); + ctx.font = "9px ui-monospace, monospace"; + + /* y 轴:波长(窄栏只标关键刻度) */ + for (const wl of thinTicks([400, 600, 800, 1000], ph)) { + const y = M.t + (1 - (wl - 380) / 720) * ph; + ctx.fillStyle = text; + ctx.textAlign = "right"; + ctx.fillText(String(wl), M.l - 6, y + 3); + ctx.strokeStyle = grid; + ctx.globalAlpha = 0.35; + ctx.beginPath(); + ctx.moveTo(M.l, y); + ctx.lineTo(M.l + pw, y); + ctx.stroke(); + ctx.globalAlpha = 1; + } + + if (spectra.length < 2) return; + + /* x 轴:体积 */ + const vMax = spectra[spectra.length - 1].v; + for (const v of thinTicks(niceTicks(0, vMax, 6), pw, 34)) { + const x = M.l + (v / vMax) * pw; + ctx.fillStyle = text; + ctx.textAlign = "center"; + ctx.fillText(fmt(v, 1), x, H - 6); + } + + /* 数据范围 */ + let lo = Infinity; + let hi = -Infinity; + for (const f of spectra) { + for (const a of f.absorbance) { + if (a < lo) lo = a; + if (a > hi) hi = a; + } + } + const span = Math.max(1e-6, hi - lo); + + /* 离屏位图(列=帧、行=通道),再缩放绘制 */ + const stride = Math.max(1, Math.ceil(spectra.length / MAX_COLS)); + const cols = Math.ceil(spectra.length / stride); + const off = document.createElement("canvas"); + off.width = cols; + off.height = WAVELENGTHS.length; + const octx = off.getContext("2d"); + if (!octx) return; + const img = octx.createImageData(cols, WAVELENGTHS.length); + for (let c = 0; c < cols; c++) { + const f = spectra[c * stride]; + for (let r = 0; r < WAVELENGTHS.length; r++) { + const t = (f.absorbance[r] - lo) / span; + const g = dark ? Math.round(30 + t * 215) : Math.round(245 - t * 215); + const idx = (r * cols + c) * 4; + img.data[idx] = g; + img.data[idx + 1] = g; + img.data[idx + 2] = g; + img.data[idx + 3] = 255; + } + } + octx.putImageData(img, 0, 0); + ctx.imageSmoothingEnabled = true; + /* y 方向翻转:波长小的在上 */ + ctx.save(); + ctx.translate(M.l, M.t + ph); + ctx.scale(pw / cols, -ph / WAVELENGTHS.length); + ctx.drawImage(off, 0, 0); + ctx.restore(); + }, [spectra, size, resolvedTheme]); + + useEffect(() => { + const raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [draw]); + + return ( +
+ +
+ ); +} + +/** 最新一帧光谱:吸光度–波长折线 */ +export function LatestSpectrum() { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const spectra = useStore((s) => s.spectra); + const { resolvedTheme } = useTheme(); + const [size, setSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || size.w < 10 || size.h < 10) return; + const ctx = setupCanvas(canvas); + if (!ctx) return; + const W = size.w; + const H = size.h; + ctx.clearRect(0, 0, W, H); + const ml = 36; + const mr = 10; + const mt = 8; + const mb = 18; + const pw = W - ml - mr; + const ph = H - mt - mb; + if (pw < 10 || ph < 10) return; + + ctx.strokeStyle = cssVar("--border"); + ctx.strokeRect(ml, mt, pw, ph); + ctx.font = "9px ui-monospace, monospace"; + ctx.fillStyle = cssVar("--muted-foreground"); + ctx.textAlign = "center"; + for (const wl of [400, 600, 800, 1000]) { + const x = ml + ((wl - 380) / 720) * pw; + ctx.fillText(String(wl), x, H - 4); + } + + const frame = spectra[spectra.length - 1]; + if (!frame) return; + let lo = Infinity; + let hi = -Infinity; + for (const a of frame.absorbance) { + if (a < lo) lo = a; + if (a > hi) hi = a; + } + const span = Math.max(1e-6, hi - lo); + ctx.textAlign = "right"; + ctx.fillText(fmt(hi, 2), ml - 3, mt + 8); + ctx.fillText(fmt(lo, 2), ml - 3, mt + ph); + + ctx.strokeStyle = cssVar("--curve-spectrum"); + ctx.lineWidth = 1.5; + ctx.beginPath(); + frame.absorbance.forEach((a, i) => { + const x = ml + ((WAVELENGTHS[i] - 380) / 720) * pw; + const y = mt + (1 - (a - lo) / span) * ph; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + }, [spectra, size, resolvedTheme]); + + useEffect(() => { + const raf = requestAnimationFrame(draw); + return () => cancelAnimationFrame(raf); + }, [draw]); + + return ( +
+ +
+ ); +} diff --git a/TController/app/ui-next/components/pages/calibration-page.tsx b/TController/app/ui-next/components/pages/calibration-page.tsx new file mode 100644 index 0000000..27bf0cb --- /dev/null +++ b/TController/app/ui-next/components/pages/calibration-page.tsx @@ -0,0 +1,395 @@ +"use client"; + +/** + * 标定页:泵体积标定(点动给固定脉冲数 → 称量累积体积 → ≥10 点线性拟合) + * 与光谱标定矩阵信息。 + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Gauge, Plus, Sparkles, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { useStore } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { backend } from "@/lib/backend"; +import type { CalPoint } from "@/lib/types"; +import { cssVar, setupCanvas } from "@/lib/chart-utils"; +import { cn } from "@/lib/utils"; + +/* ---------------- 泵标定:点动 + 多点线性拟合 ---------------- */ + +const MIN_POINTS = 10; + +interface Fit { + k: number; /* mL/step */ + b: number; /* 截距 mL */ + r2: number; + slope: number; /* steps/mL */ +} + +function linfit(pts: CalPoint[]): Fit | null { + const n = pts.length; + if (n < 2) return null; + let sx = 0, sy = 0, sxx = 0, sxy = 0; + for (const p of pts) { + sx += p.steps; sy += p.vol; + sxx += p.steps * p.steps; sxy += p.steps * p.vol; + } + const den = n * sxx - sx * sx; + if (Math.abs(den) < 1e-12) return null; + const k = (n * sxy - sx * sy) / den; + const b = (sy - k * sx) / n; + if (k <= 0) return null; + const my = sy / n; + let ssTot = 0, ssRes = 0; + for (const p of pts) { + ssTot += (p.vol - my) ** 2; + ssRes += (p.vol - (k * p.steps + b)) ** 2; + } + const r2 = ssTot < 1e-12 ? 1 : 1 - ssRes / ssTot; + return { k, b, r2, slope: 1 / k }; +} + +/* 散点 + 拟合直线预览 */ +function CalScatter({ points, fit, loadedSlope }: { points: CalPoint[]; fit: Fit | null; loadedSlope: number }) { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const [size, setSize] = useState({ w: 0, h: 0 }); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const raf = requestAnimationFrame(() => { + const canvas = canvasRef.current; + if (!canvas || size.w < 10 || size.h < 10) return; + const ctx = setupCanvas(canvas); + if (!ctx) return; + const W = size.w, H = size.h; + ctx.clearRect(0, 0, W, H); + const ml = 40, mr = 10, mt = 8, mb = 20; + const pw = W - ml - mr, ph = H - mt - mb; + if (pw < 10 || ph < 10) return; + + const loadedK = loadedSlope > 0 ? 1 / loadedSlope : 0; + const xMax = Math.max(4200, ...points.map((p) => p.steps)) * 1.06; + const yMax = Math.max( + 0.2, + ...points.map((p) => p.vol), + loadedK * xMax, + fit ? fit.k * xMax + fit.b : 0, + ) * 1.1; + const xOf = (x: number) => ml + (x / xMax) * pw; + const yOf = (y: number) => mt + (1 - y / yMax) * ph; + + ctx.strokeStyle = cssVar("--border"); + ctx.strokeRect(ml, mt, pw, ph); + ctx.font = "9px ui-monospace, monospace"; + ctx.fillStyle = cssVar("--muted-foreground"); + ctx.textAlign = "center"; + for (const f of [0.25, 0.5, 0.75, 1]) ctx.fillText(String(Math.round(xMax * f)), xOf(xMax * f), H - 6); + ctx.textAlign = "right"; + for (const f of [0.5, 1]) ctx.fillText((yMax * f).toFixed(2), ml - 4, yOf(yMax * f) + 3); + + if (loadedK > 0) { + ctx.strokeStyle = cssVar("--curve-derivative"); + ctx.lineWidth = 1.25; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.moveTo(xOf(0), yOf(0)); + ctx.lineTo(xOf(xMax), yOf(loadedK * xMax)); + ctx.stroke(); + ctx.setLineDash([]); + } + if (fit && points.length >= 2) { + ctx.strokeStyle = cssVar("--curve-potential"); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(xOf(0), yOf(Math.max(0, fit.b))); + ctx.lineTo(xOf(xMax), yOf(fit.k * xMax + fit.b)); + ctx.stroke(); + } + ctx.fillStyle = cssVar("--foreground"); + for (const p of points) { + ctx.beginPath(); + ctx.arc(xOf(p.steps), yOf(p.vol), 2.5, 0, Math.PI * 2); + ctx.fill(); + } + }); + return () => cancelAnimationFrame(raf); + }, [points, fit, loadedSlope, size]); + + return ( +
+ +
+ ); +} + +function FitMetrics({ + slope, + interceptMl, + r2, + slopeLabel, + interceptLabel, +}: { + slope: number; + interceptMl: number; + r2: number | null; + slopeLabel: string; + interceptLabel: string; +}) { + const t = useT(); + return ( + <> + {slopeLabel} {slope.toLocaleString(undefined, { maximumFractionDigits: 0 })} {t("cal.slopeUnit")} + {interceptLabel} {(interceptMl * 1000).toFixed(1)} µL + = 0.999 ? "text-[var(--status-ok)]" : r2 >= 0.99 ? "text-foreground" : "text-[var(--status-warn)]")}>{r2 === null ? "—" : r2.toFixed(5)} + + ); +} + +function PumpCalibration() { + const t = useT(); + const pumpSlope = useStore((s) => s.pumpSlope); + const pumpIntercept = useStore((s) => s.pumpIntercept); + const pumpR2 = useStore((s) => s.pumpR2); + const loadedPoints = useStore((s) => s.calPoints); + const connected = useStore((s) => s.connected); + const busy = useStore((s) => ["injecting", "titrating", "degree1", "titrating2"].includes(s.workflow)); + + useEffect(() => { + backend.loadPumpCalibration(); + }, []); + + const [jogSteps, setJogSteps] = useState(400); + const [measuredText, setMeasuredText] = useState(""); + const [cumSteps, setCumSteps] = useState(0); /* 已指令累计步数 */ + const [draft, setDraft] = useState(null); + const session = draft !== null; + const points = session ? draft : loadedPoints; + + const measured = Number(measuredText); + const measuredOk = Number.isFinite(measured) && measured > 0; + const fit = useMemo(() => linfit(points), [points]); + const canApply = session && fit !== null && points.length >= MIN_POINTS; + const cluster = "flex h-8 items-stretch overflow-hidden rounded-sm border bg-background"; + const spin = + "h-full w-[4.5rem] rounded-none border-0 bg-transparent px-1.5 py-0 text-center font-mono text-[12px] leading-8 shadow-none md:text-[12px] md:leading-8 dark:bg-transparent [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"; + const clusterLabel = + "flex h-full items-center bg-muted/60 px-2 font-mono text-[12px] leading-8 text-muted-foreground whitespace-nowrap"; + const clusterUnit = + "flex h-full items-center pr-2 font-mono text-[12px] leading-8 text-muted-foreground"; + + const jog = () => { + if (!connected) { toast.warning(t("toast.needConnect")); return; } + backend.jog(2, jogSteps); + setCumSteps((s) => s + jogSteps); + }; + + const addPoint = () => { + if (cumSteps <= 0 || !measuredOk) return; + const next = [...(draft ?? []), { steps: cumSteps, vol: measured }]; + setDraft(next); + toast.success(t("toast.pointAdded", { n: next.length, v: measured.toFixed(3) })); + }; + + const removePoint = (i: number) => { + if (!session) return; + setDraft((ps) => (ps ?? []).filter((_, idx) => idx !== i)); + }; + + const apply = async () => { + if (!fit || !session || !draft) return; + const next = Math.round(fit.slope); + if (!await backend.applyPumpCalibration(draft, next, fit.b, fit.r2)) return; + setDraft(null); + setCumSteps(0); + setMeasuredText(""); + toast.success(t("toast.calApplied", { slope: next.toLocaleString() })); + }; + + return ( + +
+ {t("cal.pumpTitle")} +
+ + {t("cal.progress")} + = MIN_POINTS ? "text-[var(--status-ok)]" : "text-foreground")}> + {session ? `${points.length}/${MIN_POINTS}` : String(points.length)} + + + + {t("cal.slope")} + {pumpSlope.toLocaleString()} + {t("cal.slopeUnit")} + +
+
+ +
+
+ {t("cal.steps")} + setJogSteps(Number(e.target.value) || 0)} className={spin} aria-label={t("cal.steps")} /> + {t("cal.stepsUnit")} + + +
+ Σ {cumSteps.toLocaleString()} +
+ {t("cal.weigh")} + setMeasuredText(e.target.value)} className={spin} aria-label={t("cal.weigh")} /> + mL + + +
+
+ + {/* 散点预览 + 点表 */} +
+
+
+ {t("cal.fitPreview")} + + + {t("cal.loadedFit")} {pumpSlope.toLocaleString()} {t("cal.slopeUnit")} + + {fit && session && ( + + + {t("cal.fitSlope")} {fit.slope.toFixed(0)} + + )} +
+
+ +
+
+
+
+ # + {t("cal.cumSteps")} + {t("cal.cumVol")} + {t("cal.residual")} + +
+ +
+ {points.length === 0 &&

} + {points.map((p, i) => { + const model = session + ? (fit ? fit.k * p.steps + fit.b : null) + : (pumpSlope > 0 ? p.steps / pumpSlope : null); + const res = model === null ? null : (p.vol - model) * 1000; + return ( +
+ {i + 1} + {p.steps.toLocaleString()} + {p.vol.toFixed(3)} + 20 ? "text-[var(--status-warn)]" : "text-muted-foreground")}> + {res === null ? "—" : (res >= 0 ? "+" : "") + res.toFixed(1)} + + {session ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+
+ + {/* 拟合结果 + 动作 */} +
+
+ {session && !fit ? ( + {t("cal.fitPending")} + ) : ( + + )} +
+
+ + +
+
+
+
+ ); +} + +/* ---------------- 光谱标定矩阵 ---------------- */ + +function SpectralCalibration() { + const t = useT(); + const [loadedAt, setLoadedAt] = useState(() => new Date()); + const facts = [ + [t("cal.specRange"), "380 – 1100 nm(Δ12)"], + [t("cal.specChannels"), "61"], + [t("cal.specCond"), "18.4"], + [t("cal.specLoaded"), loadedAt.toLocaleTimeString("zh-CN", { hour12: false })], + ]; + return ( +
+ + {t("cal.specTitle")} + + {facts.map(([k, v]) => ( + + {k} + {v} + + ))} +
+ +
+ ); +} + +/* ---------------- 页面:光谱状态条 + 泵标定工作台 ---------------- */ + +export function CalibrationPage() { + return ( +
+ + +
+ ); +} diff --git a/TController/app/ui-next/components/pages/history-page.tsx b/TController/app/ui-next/components/pages/history-page.tsx new file mode 100644 index 0000000..b1db4a9 --- /dev/null +++ b/TController/app/ui-next/components/pages/history-page.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * 数据记录页:运行历史表格 + CSV 导出。 + * Mock 阶段仅保存摘要;正式版将持久化完整曲线数据。 + */ +import { Download, History as HistoryIcon } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useStore, confidenceTone, methodTone } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; +import { toneClass } from "@/lib/tone"; + +function fmtDuration(s: number) { + const mm = Math.floor(s / 60); + const ss = Math.round(s % 60); + return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; +} + +function exportCsv(rows: ReturnType["history"]) { + const header = "started_at,duration_s,sample_ml,endpoint_ml,method,confidence,reliability,aborted"; + const lines = rows.map((r) => + [ + new Date(r.startedAt).toISOString(), + r.durationS.toFixed(1), + r.sampleVolume.toFixed(2), + r.endpoint === null ? "" : r.endpoint.toFixed(3), + r.method ?? "", + r.confidence ?? "", + r.reliability ?? "", + r.aborted ? "1" : "0", + ].join(",") + ); + const blob = new Blob(["\uFEFF" + [header, ...lines].join("\n")], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `autotitrator-runs-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +export function HistoryPage() { + const t = useT(); + const history = useStore((s) => s.history); + + return ( +
+
+

{t("history.title")}

+ +
+ + + {history.length === 0 ? ( + + +

{t("history.empty")}

+
+ ) : ( + + + + + {t("history.time")} + {t("history.duration")} + {t("history.sample")} + {t("history.endpoint")} + {t("history.method")} + {t("history.confidence")} + {t("history.status")} + + + + {history.map((r) => ( + + + {new Date(r.startedAt).toLocaleString("zh-CN", { hour12: false })} + + {fmtDuration(r.durationS)} + {r.sampleVolume.toFixed(1)} + {r.endpoint === null ? "—" : r.endpoint.toFixed(3)} + + {r.method ? ( + {t(`method.${r.method}`)} + ) : ( + + )} + + + {r.confidence ? ( + {t(`confidence.${r.confidence}`)} + ) : ( + + )} + + + + {r.aborted ? t("history.aborted") : t("history.completed")} + + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/TController/app/ui-next/components/pages/maintenance-page.tsx b/TController/app/ui-next/components/pages/maintenance-page.tsx new file mode 100644 index 0000000..3fa8992 --- /dev/null +++ b/TController/app/ui-next/components/pages/maintenance-page.tsx @@ -0,0 +1,176 @@ +"use client"; + +/** + * 维护页:两台泵并排工作台(连续运转 / 定步排出)+ 看门狗 / 设备状态条。 + * 运行中禁止手动操作泵,避免与滴定流程冲突。 + */ +import { useMemo, useState } from "react"; +import { Cpu, Droplets, Play, Square, ShieldCheck, Timer } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useStore } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { backend } from "@/lib/backend"; +import { cn } from "@/lib/utils"; + +const RUNNING = ["injecting", "titrating", "degree1", "titrating2"]; +const pumpBusy = (workflow: string, tubingOp: string | null) => Boolean(tubingOp) || RUNNING.includes(workflow); + +function PumpControl({ pump }: { pump: 1 | 2 }) { + const t = useT(); + const running = useStore((s) => (pump === 1 ? s.pump1Running : s.pump2Running)); + const steps = useStore((s) => (pump === 1 ? s.pump1Steps : s.pump2Steps)); + const slope = useStore((s) => s.pumpSlope); + const logs = useStore((s) => s.logs); + const busy = useStore((s) => pumpBusy(s.workflow, s.tubingOp)); + const connected = useStore((s) => s.connected); + const [jogSteps, setJogSteps] = useState(pump === 1 ? 400 : 200); + const volume = slope > 0 ? steps / slope : 0; + const pumpLogs = useMemo( + () => [...logs].reverse().filter((l) => l.text.includes(`泵${pump}`) || l.text.toLowerCase().includes(`pump ${pump}`)).slice(0, 40), + [logs, pump], + ); + + return ( + + + + {pump === 1 ? t("maint.pump1") : t("maint.pump2")} + + + {running ? t("maint.running") : t("maint.stopped")} + + + +
+ + + setJogSteps(Number(e.target.value) || 0)} + aria-label={t("maint.jogSteps")} + className="h-8 w-24 text-right font-mono" /> + {t("cal.stepsUnit")} + +
+ +
+ + + +
+ +
+
{t("maint.actions")}
+ +
+ {pumpLogs.length === 0 &&

{t("maint.noActions")}

} + {pumpLogs.map((l, i) => ( +
+ + {new Date(l.t).toLocaleTimeString("zh-CN", { hour12: false })} + + {l.text} +
+ ))} +
+
+
+
+
+ ); +} + +function ReadoutTile({ label, value, unit }: { label: string; value: string; unit?: string }) { + return ( +
+
{label}
+
+ {value} + {unit && {unit}} +
+
+ ); +} + +function WatchdogCard() { + const t = useT(); + const enabled = useStore((s) => s.watchdogEnabled); + const setWatchdog = useStore((s) => s.setWatchdog); + return ( +
+ +
{t("maint.watchdog")}
+
+
+ {enabled ? t("maint.watchdogEnabled") : t("maint.watchdogDisabled")} +
+
+ ); +} + +function DeviceInfo() { + const t = useT(); + const { rx, tx, badFrames, heartbeatTick, connected } = useStore(); + const uptime = Math.floor(heartbeatTick * 2); + const facts = [ + [t("maint.firmware"), "v1.4.2"], + [t("maint.mcu"), "STM32F103C8T6 · Cortex-M3 @ 72 MHz"], + [t("maint.uptime"), connected ? `${Math.floor(uptime / 60)}:${String(uptime % 60).padStart(2, "0")}` : "—"], + [t("statusbar.rx"), String(rx)], + [t("statusbar.tx"), String(tx)], + [t("maint.errFrames"), String(badFrames)], + ]; + return ( +
+ + {t("maint.deviceTitle")} + + {facts.map(([k, v]) => ( + + {k} + {v} + + ))} + + {t("maint.serialStats")} + +
+ ); +} + +export function MaintenancePage() { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/TController/app/ui-next/components/pages/settings-page.tsx b/TController/app/ui-next/components/pages/settings-page.tsx new file mode 100644 index 0000000..c3dab23 --- /dev/null +++ b/TController/app/ui-next/components/pages/settings-page.tsx @@ -0,0 +1,276 @@ +"use client"; + +/** + * 设置页:外观/语言顶栏、检测参数工作台、关于底条。 + */ +import { useState } from "react"; +import { useTheme } from "next-themes"; +import { Monitor, Moon, Palette, Pencil, SlidersHorizontal, Sun, Info } from "lucide-react"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useStore } from "@/lib/store"; +import type { DetectionParams } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; + +const THEMES = [ + { id: "light", icon: Sun, key: "settings.theme.light" }, + { id: "dark", icon: Moon, key: "settings.theme.dark" }, + { id: "system", icon: Monitor, key: "settings.theme.system" }, +] as const; + +function Segment({ + value, + options, + onChange, +}: { + value: T; + options: { id: T; label: string; icon?: typeof Sun }[]; + onChange: (id: T) => void; +}) { + return ( +
+ {options.map(({ id, label, icon: Icon }) => ( + + ))} +
+ ); +} + +function AppearanceBar() { + const t = useT(); + const { theme, setTheme } = useTheme(); + const lang = useStore((s) => s.lang); + const setLang = useStore((s) => s.setLang); + + return ( +
+ + {t("settings.appearance")} + + {t("settings.theme")} + ({ id, icon, label: t(key) }))} + /> + {t("settings.lang")} + +
+ ); +} + +function formatParam(n: number, digits: number) { + return n.toFixed(digits); +} + +function ParamRow({ + label, + unit, + value, + digits, + editing, + draft, + min, + step, + onDraft, +}: { + label: string; + unit: string; + value: number; + digits: number; + editing: boolean; + draft: string; + min: number; + step: number; + onDraft: (raw: string) => void; +}) { + return ( +
+ {label} + {editing ? ( + + onDraft(e.target.value)} + className="h-full w-full rounded-none border-0 bg-transparent px-2 py-0 text-right font-mono text-[12px] leading-8 shadow-none md:text-[12px] md:leading-8 dark:bg-transparent [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + + {unit} + + + ) : ( + + {formatParam(value, digits)} {unit} + + )} +
+ ); +} + +function DetectionCard() { + const t = useT(); + const detection = useStore((s) => s.detection); + const setDetection = useStore((s) => s.setDetection); + const running = useStore((s) => ["injecting", "titrating", "degree1", "titrating2"].includes(s.workflow)); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState>({ + t1DerivThreshold: formatParam(detection.t1DerivThreshold, 2), + dose: formatParam(detection.dose, 3), + overTitrate: formatParam(detection.overTitrate, 2), + consensusTol: formatParam(detection.consensusTol, 2), + }); + + const beginEdit = () => { + setDraft({ + t1DerivThreshold: formatParam(detection.t1DerivThreshold, 2), + dose: formatParam(detection.dose, 3), + overTitrate: formatParam(detection.overTitrate, 2), + consensusTol: formatParam(detection.consensusTol, 2), + }); + setEditing(true); + }; + + const cancel = () => setEditing(false); + + const apply = () => { + const t1 = Number(draft.t1DerivThreshold); + const dose = Number(draft.dose); + const over = Number(draft.overTitrate); + const tol = Number(draft.consensusTol); + if (![t1, dose, over, tol].every(Number.isFinite)) return; + if (t1 < 0.01 || dose < 0.001 || over < 0 || tol < 0) return; + setDetection({ t1DerivThreshold: t1, dose, overTitrate: over, consensusTol: tol }); + setEditing(false); + }; + + return ( + +
+ + {t("settings.detection")} + + {editing ? ( +
+ + +
+ ) : ( + + )} +
+ +
+ setDraft((d) => ({ ...d, t1DerivThreshold: raw }))} + /> + setDraft((d) => ({ ...d, dose: raw }))} + /> + setDraft((d) => ({ ...d, overTitrate: raw }))} + /> +
+ {t("settings.window")} + 380 – 1100 nm · 61 ch +
+ setDraft((d) => ({ ...d, consensusTol: raw }))} + /> +
+
+
+ ); +} + +function AboutBar() { + const t = useT(); + const facts = [ + [t("settings.version"), "0.2.0"], + [t("settings.core"), "controller-core 0.1.0 · Rust"], + [t("settings.license"), "PolyForm Shield 1.0.0"], + ]; + return ( +
+ + {t("settings.about")} + + {facts.map(([k, v]) => ( + + {k} + {v} + + ))} +
+ ); +} + +export function SettingsPage() { + return ( +
+ + + +
+ ); +} diff --git a/TController/app/ui-next/components/pages/titration-page.tsx b/TController/app/ui-next/components/pages/titration-page.tsx new file mode 100644 index 0000000..61c234a --- /dev/null +++ b/TController/app/ui-next/components/pages/titration-page.tsx @@ -0,0 +1,301 @@ +"use client"; + +/** + * 滴定工作台:主视图。 + * 左列双图表(电位-体积 / 光谱热图+最新光谱),右列终点结果与事件日志。 + */ +import { useMemo } from "react"; +import { Check, ChevronRight, CircleSlash, Droplets } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { useStore, confidenceTone, methodTone, reliabilityTone } from "@/lib/store"; +import { useT } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; +import { toneClass } from "@/lib/tone"; +import { PotentialChart } from "@/components/charts/potential-chart"; +import { SpectrumHeatmap, LatestSpectrum } from "@/components/charts/spectrum-chart"; +import { backend } from "@/lib/backend"; +import type { EndpointResult, WorkflowState } from "@/lib/types"; + +/* ---------------- 工作流步进器 ---------------- */ + +const FLOW: WorkflowState[] = ["injecting", "titrating", "degree1", "titrating2", "done"]; + +function Stepper() { + const t = useT(); + const workflow = useStore((s) => s.workflow); + const tubingOp = useStore((s) => s.tubingOp); + const errIdx = workflow === "error" ? FLOW.indexOf("titrating") : -1; + const activeIdx = workflow === "error" ? 1 : workflow === "idle" || tubingOp ? -1 : FLOW.indexOf(workflow); + + return ( +
    + {FLOW.map((s, i) => { + const done = activeIdx > i || workflow === "done"; + const active = activeIdx === i && workflow !== "done"; + const isErr = workflow === "error" && i === errIdx; + return ( +
  1. + {i + 1} + {done && } + {isErr && } + {t(`state.${s}`)} + {i < FLOW.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); +} + +/* ---------------- 结果面板 ---------------- */ + +function ResultBlock({ r }: { r: EndpointResult }) { + const t = useT(); + return ( +
+
+ + {r.stage === "t1" ? t("results.t1") : t("results.final")} + + + {r.volume.toFixed(3)} mL + +
+
+ {t(`method.${r.method}`)} + {t("results.confidence")}: {t(`confidence.${r.confidence}`)} + {r.reliability} +
+
+ {t("results.potVol")} + {r.potentialVolume?.toFixed(3) ?? "—"} + {t("results.specVol")} + {r.spectralVolume?.toFixed(3) ?? "—"} + {r.kf && ( + <> + {t("results.kf")} + {r.kf.volume.toFixed(3)} ± {r.kf.std.toFixed(3)} + {t("results.kfNis")} + {r.kf.nis.toFixed(2)} + + )} + {r.refined !== null && ( + <> + {t("results.refined")} + {r.refined.toFixed(3)} + + )} +
+
+ ); +} + +function ResultsPanel() { + const t = useT(); + const t1 = useStore((s) => s.t1); + const final = useStore((s) => s.final); + const spectralState = useStore((s) => s.spectralState); + return ( + + + {t("results.title")} + + {t(`spectral.${spectralState}`)} + + + + {t1 ? :

{t("results.pending")}

} + {t1 && final && } + {final && } +
+
+ ); +} + +/* ---------------- 事件日志 ---------------- */ + +const levelClass: Record = { + info: "text-muted-foreground", + ok: "text-[var(--status-ok)]", + warn: "text-[var(--status-warn)]", + error: "text-[var(--status-danger)]", +}; + +function EventLog() { + const t = useT(); + const logs = useStore((s) => s.logs); + const clearLogs = useStore((s) => s.clearLogs); + const items = useMemo(() => [...logs].reverse(), [logs]); + return ( + + + {t("log.title")} + + + +
+ {items.length === 0 &&

} + {items.map((l, i) => ( +
+ + {new Date(l.t).toLocaleTimeString("zh-CN", { hour12: false })} + + {l.text} +
+ ))} +
+
+
+ ); +} + +/* ---------------- 页面 ---------------- */ + +function PumpChip({ + label, + checked, + disabled, + onChange, +}: { + label: string; + checked: boolean; + disabled: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +function TubingBar() { + const t = useT(); + const connected = useStore((s) => s.connected); + const workflow = useStore((s) => s.workflow); + const tubingOp = useStore((s) => s.tubingOp); + const p1 = useStore((s) => s.tubingP1); + const p2 = useStore((s) => s.tubingP2); + const setTubingPumps = useStore((s) => s.setTubingPumps); + const titrating = ["injecting", "titrating", "degree1", "titrating2"].includes(workflow); + const busy = titrating || Boolean(tubingOp); + const canPrime = connected && !busy; + const canEmpty = connected && !titrating && !tubingOp && (workflow === "done" || workflow === "idle"); + + return ( +
+ + {t("tubing.prime")} / {t("tubing.empty")} + + setTubingPumps(v, p2)} /> + setTubingPumps(p1, v)} /> + + {tubingOp ? ( + <> + {t("tubing.running")} + + + ) : ( + <> + + + + )} +
+ ); +} + +export function TitrationPage() { + const t = useT(); + const potPoints = useStore((s) => s.potPoints); + const spectra = useStore((s) => s.spectra); + const hasData = potPoints.length > 0 || spectra.length > 0; + + return ( +
+ + + +
+ {/* 左:图表列 */} +
+ + + {t("chart.potentialTitle")} +
+ E(V) + dE/dV +
+
+ + + {!hasData && } + +
+ + + + {t("chart.spectrumTitle")} + + +
+ + {!hasData && } +
+
+ +
+
+
+
+ + {/* 右:结果 + 日志 */} +
+ + +
+
+
+ ); +} + +function EmptyHint() { + const t = useT(); + return ( +
+
+
{t("chart.empty")}
+
{t("chart.emptyHint")}
+
+
+ ); +} diff --git a/TController/app/ui-next/components/ui/badge.tsx b/TController/app/ui-next/components/ui/badge.tsx new file mode 100644 index 0000000..cacff11 --- /dev/null +++ b/TController/app/ui-next/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/TController/app/ui-next/components/ui/button.tsx b/TController/app/ui-next/components/ui/button.tsx new file mode 100644 index 0000000..f498274 --- /dev/null +++ b/TController/app/ui-next/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[state=loading]:pointer-events-none data-[state=loading]:cursor-wait data-[state=loading]:opacity-70 data-[state=error]:border-[var(--status-danger)] data-[state=error]:bg-[var(--status-danger)]/10 data-[state=error]:text-[var(--status-danger)] data-[state=success]:border-[var(--status-ok)] data-[state=success]:bg-[var(--status-ok)]/10 data-[state=success]:text-[var(--status-ok)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/TController/app/ui-next/components/ui/card.tsx b/TController/app/ui-next/components/ui/card.tsx new file mode 100644 index 0000000..757c00c --- /dev/null +++ b/TController/app/ui-next/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-md *:[img:last-child]:rounded-b-md", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/TController/app/ui-next/components/ui/dialog.tsx b/TController/app/ui-next/components/ui/dialog.tsx new file mode 100644 index 0000000..a2c2d0b --- /dev/null +++ b/TController/app/ui-next/components/ui/dialog.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/TController/app/ui-next/components/ui/dropdown-menu.tsx b/TController/app/ui-next/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..c263ad5 --- /dev/null +++ b/TController/app/ui-next/components/ui/dropdown-menu.tsx @@ -0,0 +1,269 @@ +"use client" + +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/TController/app/ui-next/components/ui/input.tsx b/TController/app/ui-next/components/ui/input.tsx new file mode 100644 index 0000000..d763cd9 --- /dev/null +++ b/TController/app/ui-next/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/TController/app/ui-next/components/ui/label.tsx b/TController/app/ui-next/components/ui/label.tsx new file mode 100644 index 0000000..1ac80f7 --- /dev/null +++ b/TController/app/ui-next/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/TController/app/ui-next/components/ui/progress.tsx b/TController/app/ui-next/components/ui/progress.tsx new file mode 100644 index 0000000..584011b --- /dev/null +++ b/TController/app/ui-next/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/TController/app/ui-next/components/ui/scroll-area.tsx b/TController/app/ui-next/components/ui/scroll-area.tsx new file mode 100644 index 0000000..facbbe7 --- /dev/null +++ b/TController/app/ui-next/components/ui/scroll-area.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { ScrollArea as ScrollAreaPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/TController/app/ui-next/components/ui/select.tsx b/TController/app/ui-next/components/ui/select.tsx new file mode 100644 index 0000000..f09dfb4 --- /dev/null +++ b/TController/app/ui-next/components/ui/select.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/TController/app/ui-next/components/ui/separator.tsx b/TController/app/ui-next/components/ui/separator.tsx new file mode 100644 index 0000000..d457090 --- /dev/null +++ b/TController/app/ui-next/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/TController/app/ui-next/components/ui/sonner.tsx b/TController/app/ui-next/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/TController/app/ui-next/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, type ToasterProps } from "sonner" +import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ) +} + +export { Toaster } diff --git a/TController/app/ui-next/components/ui/switch.tsx b/TController/app/ui-next/components/ui/switch.tsx new file mode 100644 index 0000000..93a710d --- /dev/null +++ b/TController/app/ui-next/components/ui/switch.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/TController/app/ui-next/components/ui/table.tsx b/TController/app/ui-next/components/ui/table.tsx new file mode 100644 index 0000000..abeaced --- /dev/null +++ b/TController/app/ui-next/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/TController/app/ui-next/components/ui/tabs.tsx b/TController/app/ui-next/components/ui/tabs.tsx new file mode 100644 index 0000000..05f469f --- /dev/null +++ b/TController/app/ui-next/components/ui/tabs.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/TController/app/ui-next/components/ui/tooltip.tsx b/TController/app/ui-next/components/ui/tooltip.tsx new file mode 100644 index 0000000..bb1ea52 --- /dev/null +++ b/TController/app/ui-next/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } diff --git a/TController/app/ui-next/design.md b/TController/app/ui-next/design.md new file mode 100644 index 0000000..4d7f3e0 --- /dev/null +++ b/TController/app/ui-next/design.md @@ -0,0 +1,61 @@ +# Design — AutoTitrator Console + +A locked design system for this instrument console. Later page work +reads this file before changing chrome. Do not regenerate per page. + +## Genre +modern-minimal, instrument register (not SaaS marketing) + +## Macrostructure family +- App pages: Workbench — always-visible run chrome, live plots, readout rail +- Marketing / content: none + +## Inferred brief +- Audience: lab operator at the bench +- Use: connect, start a run, watch the endpoint +- Tone: utilitarian / technical + +## Theme +Existing grayscale instrument palette. Colour is reserved for status. + +- `--background` oklch(0.11 0 0) dark / oklch(0.985 0 0) light +- `--card` oklch(0.155 0 0) dark / oklch(1 0 0) light +- `--foreground` oklch(0.97 0 0) dark / oklch(0.145 0 0) light +- `--border` hairline, low-chroma +- `--status-ok` / `--status-warn` / `--status-danger` only for state +- `--curve-potential` high-contrast solid +- `--curve-derivative` mid-ink, dashed + +## Typography +- Display / body: Geist Sans (already loaded) +- Readouts: Geist Mono + tabular nums +- No italic headings +- Instrument labels: 11px, wide tracking, nowrap + +## Spacing +4-point scale via Tailwind. Console chrome stays compact: +title 32px + tool strip 36px, status bar 32px, page gap 12px. +Tool strip is left-aligned segmented clusters (link, sample, run); +E-STOP is isolated on the right. + +## Motion +- Duration ≤ 150ms, transform/opacity only +- Reduced-motion: opacity only +- No celebratory toasts for routine connect/start + +## CTA voice +- Primary: filled ink, start / connect — never transparent; readable before hover +- Cautionary: warn outline + label, reset (clears the run) +- Destructive physical: filled danger, E-STOP +- Secondary: hairline, stop / abort + +## What pages MUST share +- Wordmark + flask mark +- Grayscale paper, status-only colour +- Geist + mono readouts +- Run controls in the top bar, live values in the status bar +- Tight-radius panels (`--radius` 6px), not SaaS pills + +## What pages MAY differ on +- Interior card arrangement +- Empty-state copy for that page's job diff --git a/TController/app/ui-next/eslint.config.mjs b/TController/app/ui-next/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/TController/app/ui-next/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/TController/app/ui-next/lib/backend.ts b/TController/app/ui-next/lib/backend.ts new file mode 100644 index 0000000..01e90b7 --- /dev/null +++ b/TController/app/ui-next/lib/backend.ts @@ -0,0 +1,233 @@ +import type { CalPoint, SerialPortInfo, TubingOp } from "@/lib/types"; + +export interface BackendSnapshot { + version: string; + ports: SerialPortInfo[]; + connected: boolean; + connecting: boolean; + port: string; + baud: number; + workflow: string; + volume: number; + elapsedMs: number; + sampleVolume: number; + sampleInput: number; + tubingOp: TubingOp | null; + tubingP1: boolean; + tubingP2: boolean; + pump1Running: boolean; + pump2Running: boolean; + pump1Steps: number; + pump2Steps: number; + pumpSlope: number; + pumpIntercept: number; + pumpR2: number | null; + calPoints: CalPoint[]; + potPoints: Array<{ v: number; t: number; e: number }>; + spectra: Array<{ v: number; absorbance: number[] }>; + spectralState: string; + lastE: number | null; + lastDeriv: number | null; + t1: RawEndpoint | null; + finalResult: RawEndpoint | null; + watchdogEnabled: boolean; + detection: { + t1DerivThreshold: number; + dose: number; + overTitrate: number; + consensusTol: number; + }; + rx: number; + tx: number; + badFrames: number; + heartbeatTick: number; + logs: Array<{ t: number; level: string; text: string }>; + history: Array<{ + id: string; + startedAt: number; + durationS: number; + sampleVolume: number; + endpoint: number | null; + method: string | null; + confidence: string | null; + reliability: string | null; + scenario: string; + aborted: boolean; + }>; + lang: "zh" | "en"; + theme: string; + navCollapsed: boolean; +} + +interface RawEndpoint { + stage: "t1" | "final" | string; + volume: number; + method: string; + confidence: string; + potentialVolume: number | null; + spectralVolume: number | null; + reliability: string; + kf: { volume: number; std: number; nis: number } | null; + refined: number | null; +} + +type TauriApi = { + core?: { invoke: (command: string, args?: Record) => Promise }; + event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> }; +}; + +function tauriApi(): TauriApi | null { + return (globalThis as { __TAURI__?: TauriApi }).__TAURI__ ?? null; +} + +function isTauriRuntime() { + const api = tauriApi(); + return Boolean(api?.core?.invoke && api.event?.listen); +} + +function invoke(command: string, args?: Record): Promise { + const api = tauriApi(); + if (!api?.core?.invoke) return Promise.reject(new Error("Tauri backend unavailable")); + return api.core.invoke(command, args) as Promise; +} + +async function applySnapshot(snapshot: BackendSnapshot) { + const { applyBackendSnapshot } = await import("@/lib/store"); + applyBackendSnapshot(snapshot); +} + +let mockPromise: Promise | null = null; +function mock() { + mockPromise ??= import("@/lib/mock/simulator"); + return mockPromise; +} + +let initialized = false; +let unlisten: (() => void) | null = null; + +async function initialize() { + if (initialized) return; + initialized = true; + if (isTauriRuntime()) { + const runtimeApi = tauriApi(); + if (!runtimeApi?.event?.listen) return; + unlisten = await runtimeApi.event.listen("backend://state", (event) => { + void applySnapshot(event.payload as BackendSnapshot); + }); + const snapshot = await invoke("backend_state"); + await applySnapshot(snapshot); + return; + } + await (await mock()).backend.loadPumpCalibration(); +} + +async function callMock(method: keyof (typeof import("@/lib/mock/simulator"))["backend"], ...args: unknown[]) { + const mod = await mock(); + const fn = mod.backend[method] as (...values: unknown[]) => unknown; + return fn(...args); +} + +export const backend = { + initialize, + connect: async () => { + if (isTauriRuntime()) { + const state = (await import("@/lib/store")).useStore.getState(); + await invoke("connect", { port: state.port, baud: state.baud }); + } else { + await callMock("connect"); + } + }, + disconnect: async () => { + if (isTauriRuntime()) await invoke("disconnect"); + else await callMock("disconnect"); + }, + setSampleInput: async (value: number) => { + if (isTauriRuntime()) await invoke("set_sample_input", { value }); + else await callMock("retune"); + }, + start: async () => { + if (isTauriRuntime()) await invoke("start_titration"); + else await callMock("start"); + }, + manualStop: async () => { + if (isTauriRuntime()) await invoke("manual_stop"); + else await callMock("manualStop"); + }, + abort: async () => { + if (isTauriRuntime()) await invoke("abort"); + else await callMock("abort"); + }, + reset: async () => { + if (isTauriRuntime()) await invoke("reset"); + else { + const store = (await import("@/lib/store")).useStore; + store.setState({ workflow: "idle", volume: 0, elapsedMs: 0, potPoints: [], spectra: [], spectralState: "IDLE", lastE: null, lastDeriv: null, t1: null, final: null }); + } + }, + startTubing: async (op: TubingOp) => { + if (isTauriRuntime()) await invoke("start_tubing", { op }); + else await callMock("startTubing", op); + }, + stopTubing: async () => { + if (isTauriRuntime()) await invoke("stop_tubing"); + else await callMock("stopTubing"); + }, + freeRun: async (pump: 1 | 2) => { + if (isTauriRuntime()) await invoke("free_run", { pump }); + else await callMock("freeRun", pump); + }, + freeStop: async (pump: 1 | 2) => { + if (isTauriRuntime()) await invoke("free_stop", { pump }); + else await callMock("freeStop", pump); + }, + jog: async (pump: 1 | 2, steps: number) => { + if (isTauriRuntime()) await invoke("jog", { pump, steps }); + else await callMock("jog", pump, steps); + }, + loadPumpCalibration: async () => { + if (isTauriRuntime()) { + await applySnapshot(await invoke("backend_state")); + } else { + await callMock("loadPumpCalibration"); + } + }, + applyPumpCalibration: async (points: CalPoint[], slopeStepsPerMl: number, interceptMl = 0, r2: number | null = null) => { + if (isTauriRuntime()) { + await invoke("apply_pump_calibration", { + request: { + points, + slopeStepsPerMl, + interceptMl, + r2, + }, + }); + return true; + } + return Boolean(await callMock("applyPumpCalibration", points, slopeStepsPerMl, interceptMl, r2)); + }, + setWatchdog: async (enabled: boolean) => { + if (isTauriRuntime()) await invoke("set_watchdog", { enabled }); + else await callMock("retune"); + }, + setDetection: async (patch: Record) => { + if (isTauriRuntime()) await invoke("set_detection", { patch }); + else await callMock("retune"); + }, + setUiSettings: async (patch: Record) => { + if (isTauriRuntime()) await invoke("set_ui_settings", { patch }); + else await callMock("retune"); + }, + setTubingPumps: async (p1: boolean, p2: boolean) => { + if (isTauriRuntime()) { + await invoke("set_tubing_pumps", { p1, p2 }); + } else { + const store = (await import("@/lib/store")).useStore; + store.setState({ tubingP1: p1, tubingP2: p2 }); + } + }, + dispose: () => { + unlisten?.(); + unlisten = null; + initialized = false; + }, +}; diff --git a/TController/app/ui-next/lib/chart-utils.ts b/TController/app/ui-next/lib/chart-utils.ts new file mode 100644 index 0000000..384d796 --- /dev/null +++ b/TController/app/ui-next/lib/chart-utils.ts @@ -0,0 +1,57 @@ +/** Canvas 图表公共工具:DPR 适配、刻度、CSS 变量取色 */ + +export function setupCanvas(canvas: HTMLCanvasElement): CanvasRenderingContext2D | null { + const rect = canvas.getBoundingClientRect(); + if (rect.width < 4 || rect.height < 4) return null; + const dpr = window.devicePixelRatio || 1; + const w = Math.round(rect.width * dpr); + const h = Math.round(rect.height * dpr); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return ctx; +} + +/** 生成 ~count 个“漂亮”刻度值 */ +export function niceTicks(min: number, max: number, count = 5): number[] { + if (!isFinite(min) || !isFinite(max) || max <= min) return []; + const span = max - min; + const step0 = span / Math.max(1, count); + const mag = Math.pow(10, Math.floor(Math.log10(step0))); + const norm = step0 / mag; + const step = (norm >= 5 ? 5 : norm >= 2 ? 2 : 1) * mag; + const ticks: number[] = []; + for (let v = Math.ceil(min / step) * step; v <= max + step * 1e-6; v += step) { + ticks.push(Number(v.toFixed(10))); + } + return ticks; +} + +/** 按可用空间稀疏刻度:保证相邻标签间距 ≥ minGap(px),防止小尺寸下文字重叠 */ +export function thinTicks(ticks: number[], spanPx: number, minGap = 13): number[] { + if (ticks.length < 2 || spanPx <= 0) return ticks; + const perPx = spanPx / (ticks[ticks.length - 1] - ticks[0] || 1); + const stride = Math.max(1, Math.ceil(minGap / (Math.abs(perPx) * Math.abs(ticks[1] - ticks[0]) || 1))); + return ticks.filter((_, i) => i % stride === 0); +} + +/** 读取当前主题下的 CSS 变量(含灰阶曲线色与状态色) */ +export function cssVar(name: string, fallback = "#888"): string { + if (typeof window === "undefined") return fallback; + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +export function fmt(v: number, digits = 2): string { + return v.toFixed(digits); +} + +/** 灰阶插值:t∈[0,1],浅色主题画深、深色主题画亮 */ +export function grayRamp(t: number, dark: boolean): string { + const c = dark ? Math.round(30 + t * 215) : Math.round(245 - t * 215); + return `rgb(${c},${c},${c})`; +} diff --git a/TController/app/ui-next/lib/i18n.ts b/TController/app/ui-next/lib/i18n.ts new file mode 100644 index 0000000..d3c1c73 --- /dev/null +++ b/TController/app/ui-next/lib/i18n.ts @@ -0,0 +1,244 @@ +/** + * 轻量 i18n:与 Python 版 locales/*.json 同等定位,默认中文。 + * 用法:const t = useT(); t("nav.titration") + */ +import { useStore } from "@/lib/store"; + +export type Lang = "zh" | "en"; + +const dict = { + "app.name": { zh: "AutoTitrator", en: "AutoTitrator" }, + "app.sub": { zh: "多模态滴定控制台", en: "Multimodal Titration Console" }, + "app.mockBanner": { zh: "MOCK 数据模式 · 前端未接线", en: "MOCK DATA · frontend not wired" }, + + "nav.titration": { zh: "滴定工作台", en: "Titration" }, + "nav.calibration": { zh: "标定", en: "Calibration" }, + "nav.maintenance": { zh: "维护", en: "Maintenance" }, + "nav.history": { zh: "数据记录", en: "Records" }, + "nav.settings": { zh: "设置", en: "Settings" }, + "nav.collapse": { zh: "收起导航", en: "Collapse" }, + + "toolbar.connection": { zh: "连接", en: "Link" }, + "toolbar.port": { zh: "端口", en: "Port" }, + "toolbar.selectPort": { zh: "选择串口", en: "Select port" }, + "toolbar.noPorts": { zh: "未发现串口", en: "No serial ports" }, + "toolbar.baud": { zh: "波特率", en: "Baud" }, + "toolbar.connect": { zh: "连接", en: "Connect" }, + "toolbar.disconnect": { zh: "断开", en: "Disconnect" }, + "toolbar.connected": { zh: "已连接", en: "Connected" }, + "toolbar.disconnected": { zh: "未连接", en: "Offline" }, + "toolbar.connecting": { zh: "连接中", en: "Linking" }, + "toolbar.sample": { zh: "样品体积", en: "Sample" }, + "toolbar.scenario": { zh: "场景", en: "Scenario" }, + "toolbar.speed": { zh: "速度", en: "Speed" }, + "toolbar.start": { zh: "开始滴定", en: "Start" }, + "toolbar.stop": { zh: "手动停止", en: "Stop" }, + "toolbar.abort": { zh: "中止", en: "Abort" }, + "toolbar.estop": { zh: "急停", en: "E-STOP" }, + "toolbar.reset": { zh: "复位", en: "Reset" }, + "win.minimize": { zh: "最小化", en: "Minimize" }, + "win.maximize": { zh: "最大化", en: "Maximize" }, + "win.restore": { zh: "还原", en: "Restore" }, + "win.close": { zh: "关闭", en: "Close" }, + "tubing.prime": { zh: "预充", en: "Prime" }, + "tubing.empty": { zh: "排空", en: "Empty" }, + "tubing.stop": { zh: "停止管路", en: "Stop tubing" }, + "tubing.p1": { zh: "进样泵", en: "Sample" }, + "tubing.p2": { zh: "滴定泵", en: "Titrant" }, + "tubing.hintPrime": { zh: "入口放入对应液体,无气泡后停止", en: "Inlet in liquid; stop when bubble-free" }, + "tubing.hintEmpty": { zh: "出口放入废液杯,管内排空后停止", en: "Outlet to waste; stop when empty" }, + "tubing.running": { zh: "管路运行中,观察后停止", en: "Tubing running — watch, then stop" }, + + "scenario.normal": { zh: "正常滴定", en: "Normal" }, + "scenario.noisy": { zh: "弱信号(高噪声)", en: "Noisy signal" }, + "scenario.conflict": { zh: "模态冲突", en: "Modal conflict" }, + "scenario.failure": { zh: "泵故障", en: "Pump failure" }, + + "state.idle": { zh: "待机", en: "Idle" }, + "state.injecting": { zh: "进样", en: "Injecting" }, + "state.titrating": { zh: "滴定", en: "Titrating" }, + "state.degree1": { zh: "终点 T=1", en: "Endpoint T=1" }, + "state.titrating2": { zh: "过量滴定", en: "Over-titration" }, + "state.done": { zh: "完成", en: "Done" }, + "state.error": { zh: "故障", en: "Error" }, + "state.prime": { zh: "预充管路", en: "Prime" }, + "state.empty": { zh: "排空管路", en: "Empty" }, + + "statusbar.elapsed": { zh: "用时", en: "Elapsed" }, + "statusbar.volume": { zh: "滴定剂体积", en: "Titrant" }, + "statusbar.potential": { zh: "电位", en: "Potential" }, + "statusbar.derivative": { zh: "dE/dV", en: "dE/dV" }, + "statusbar.frames": { zh: "光谱帧", en: "Frames" }, + "statusbar.rx": { zh: "接收", en: "RX" }, + "statusbar.tx": { zh: "发送", en: "TX" }, + "statusbar.heartbeat": { zh: "心跳", en: "Heartbeat" }, + + "chart.potentialTitle": { zh: "电位 – 体积曲线", en: "Potential – Volume" }, + "chart.spectrumTitle": { zh: "当前光谱", en: "Live spectrum" }, + "chart.heatmapTitle": { zh: "演化", en: "Evolution" }, + "chart.latest": { zh: "最新光谱", en: "Latest spectrum" }, + "chart.empty": { zh: "等待数据", en: "Waiting for data" }, + "chart.emptyHint": { zh: "连接设备后开始滴定", en: "Connect, then start a run" }, + + "results.title": { zh: "终点检测", en: "Endpoint Detection" }, + "results.t1": { zh: "T=1 初判", en: "T=1 first pass" }, + "results.final": { zh: "最终结果", en: "Final result" }, + "results.pending": { zh: "等待终点…", en: "Awaiting endpoint…" }, + "results.volume": { zh: "终点体积", en: "Volume" }, + "results.method": { zh: "判定方法", en: "Method" }, + "results.confidence": { zh: "置信度", en: "Confidence" }, + "results.potVol": { zh: "电位通道", en: "Potential ch." }, + "results.specVol": { zh: "光谱通道", en: "Spectral ch." }, + "results.reliability": { zh: "可靠性", en: "Reliability" }, + "results.kf": { zh: "卡尔曼融合", en: "KF fusion" }, + "results.kfStd": { zh: "标准差", en: "Std" }, + "results.kfNis": { zh: "NIS", en: "NIS" }, + "results.refined": { zh: "AMPD 精修", en: "AMPD refine" }, + "results.spectralState": { zh: "光谱状态", en: "Spectral state" }, + + "method.consensus": { zh: "双模态共识", en: "Consensus" }, + "method.potential_only": { zh: "仅电位", en: "Potential only" }, + "method.spectral_only": { zh: "仅光谱", en: "Spectral only" }, + "method.conflict": { zh: "模态冲突", en: "Conflict" }, + "confidence.high": { zh: "高", en: "High" }, + "confidence.medium": { zh: "中", en: "Medium" }, + "confidence.low": { zh: "低", en: "Low" }, + "spectral.IDLE": { zh: "基线稳定", en: "Baseline" }, + "spectral.IN_CHANGE": { zh: "变化中", en: "In change" }, + "spectral.END_CONFIRMED": { zh: "终点确认", en: "Confirmed" }, + + "log.title": { zh: "事件日志", en: "Event Log" }, + "log.clear": { zh: "清空", en: "Clear" }, + + "cal.pumpTitle": { zh: "泵标定", en: "Pump calibration" }, + "cal.progress": { zh: "标定点", en: "Points" }, + "cal.jog": { zh: "排出", en: "Dispense" }, + "cal.steps": { zh: "步数", en: "Steps" }, + "cal.weigh": { zh: "称量", en: "Weigh" }, + "cal.stepsUnit": { zh: "步", en: "steps" }, + "cal.addPoint": { zh: "记录点", en: "Add point" }, + "cal.cumSteps": { zh: "累计步数", en: "Σ steps" }, + "cal.cumVol": { zh: "实测累计 (mL)", en: "Measured (mL)" }, + "cal.residual": { zh: "残差 (µL)", en: "Residual (µL)" }, + "cal.fitSlope": { zh: "拟合斜率", en: "Fit slope" }, + "cal.slopeUnit": { zh: "步/mL", en: "steps/mL" }, + "cal.fitIntercept": { zh: "截距", en: "Intercept" }, + "cal.fitPending": { zh: "等待足够标定点…", en: "Awaiting calibration points…" }, + "cal.clearPoints": { zh: "清空点", en: "Clear" }, + "cal.needPoints": { zh: "至少需要 10 个标定点", en: "At least 10 points required" }, + "cal.fitPreview": { zh: "拟合预览", en: "Fit preview" }, + "cal.loadedFit": { zh: "当前载入", en: "Loaded" }, + "cal.slope": { zh: "当前斜率", en: "Current slope" }, + "cal.apply": { zh: "应用标定", en: "Apply" }, + "cal.specTitle": { zh: "光谱标定矩阵", en: "Spectral Calibration Matrix" }, + "cal.specRange": { zh: "波长范围", en: "Range" }, + "cal.specChannels": { zh: "通道数", en: "Channels" }, + "cal.specCond": { zh: "条件数", en: "Condition №" }, + "cal.specLoaded": { zh: "加载于", en: "Loaded" }, + "cal.reload": { zh: "重新加载", en: "Reload" }, + + "maint.pumpTitle": { zh: "泵手动控制", en: "Manual Pump Control" }, + "maint.pump1": { zh: "泵 1 · 进样", en: "Pump 1 · Sample" }, + "maint.pump2": { zh: "泵 2 · 滴定剂", en: "Pump 2 · Titrant" }, + "maint.run": { zh: "连续运转", en: "Free run" }, + "maint.stop": { zh: "停止", en: "Stop" }, + "maint.jogSteps": { zh: "定步排出(步)", en: "Jog (steps)" }, + "maint.jog": { zh: "排出", en: "Jog" }, + "maint.running": { zh: "运转中", en: "Running" }, + "maint.stopped": { zh: "已停止", en: "Stopped" }, + "maint.watchdog": { zh: "固件看门狗", en: "Firmware watchdog" }, + "maint.watchdogEnabled": { zh: "已启用", en: "Enabled" }, + "maint.watchdogDisabled": { zh: "已停用", en: "Disabled" }, + "maint.deviceTitle": { zh: "设备信息", en: "Device" }, + "maint.firmware": { zh: "固件版本", en: "Firmware" }, + "maint.mcu": { zh: "MCU", en: "MCU" }, + "maint.uptime": { zh: "运行时间", en: "Uptime" }, + "maint.serialStats": { zh: "链路统计", en: "Link stats" }, + "maint.errFrames": { zh: "错误帧", en: "Bad frames" }, + "maint.session": { zh: "本会话累计", en: "This session" }, + "maint.steps": { zh: "累计步数", en: "Steps" }, + "maint.dispensed": { zh: "折合体积", en: "Volume" }, + "maint.slope": { zh: "当前斜率", en: "Slope" }, + "maint.actions": { zh: "动作记录", en: "Actions" }, + "maint.noActions": { zh: "尚未手动操作泵", en: "No manual pump actions yet" }, + + "history.title": { zh: "运行记录", en: "Run History" }, + "history.time": { zh: "时间", en: "Time" }, + "history.duration": { zh: "用时", en: "Duration" }, + "history.sample": { zh: "样品 (mL)", en: "Sample" }, + "history.endpoint": { zh: "终点 (mL)", en: "Endpoint" }, + "history.method": { zh: "方法", en: "Method" }, + "history.confidence": { zh: "置信度", en: "Conf." }, + "history.scenario": { zh: "场景", en: "Scenario" }, + "history.status": { zh: "状态", en: "Status" }, + "history.export": { zh: "导出 CSV", en: "Export CSV" }, + "history.empty": { zh: "暂无记录 — 完成一次滴定后自动写入", en: "No records yet" }, + "history.aborted": { zh: "已中止", en: "Aborted" }, + "history.completed": { zh: "完成", en: "Completed" }, + + "settings.title": { zh: "设置", en: "Settings" }, + "settings.appearance": { zh: "外观", en: "Appearance" }, + "settings.theme": { zh: "主题", en: "Theme" }, + "settings.theme.dark": { zh: "深色", en: "Dark" }, + "settings.theme.light": { zh: "浅色", en: "Light" }, + "settings.theme.system": { zh: "跟随系统", en: "System" }, + "settings.lang": { zh: "语言", en: "Language" }, + "settings.detection": { zh: "终点检测参数", en: "Endpoint detection" }, + "settings.t1": { zh: "T=1 导数阈值", en: "T=1 derivative" }, + "settings.dose": { zh: "单步剂量", en: "Dose" }, + "settings.over": { zh: "过量滴定余量", en: "Over-titrate" }, + "settings.window": { zh: "光谱窗口", en: "Spectral window" }, + "settings.tol": { zh: "共识容差", en: "Consensus tolerance" }, + "settings.edit": { zh: "编辑", en: "Edit" }, + "settings.apply": { zh: "应用", en: "Apply" }, + "settings.cancel": { zh: "取消", en: "Cancel" }, + "settings.detectionSub": { zh: "与 controller-core 默认值一致;正式版提供修改入口", en: "Mirrors controller-core defaults" }, + "settings.about": { zh: "关于", en: "About" }, + "settings.version": { zh: "上位机版本", en: "Host version" }, + "settings.core": { zh: "后端核心", en: "Backend core" }, + "settings.license": { zh: "许可证", en: "License" }, + + "toast.connected": { zh: "已连接到 {port}", en: "Connected to {port}" }, + "toast.disconnected": { zh: "连接已断开", en: "Disconnected" }, + "toast.runStarted": { zh: "滴定开始(样品 {v} mL)", en: "Run started ({v} mL sample)" }, + "toast.t1": { zh: "T=1 初判终点 {v} mL", en: "T=1 endpoint {v} mL" }, + "toast.done": { zh: "滴定完成,终点 {v} mL", en: "Done, endpoint {v} mL" }, + "toast.error": { zh: "运行故障:{msg}", en: "Run fault: {msg}" }, + "toast.calApplied": { zh: "标定已应用:{slope} 步/mL", en: "Calibration applied: {slope} steps/mL" }, + "toast.calReloaded": { zh: "光谱标定已重新加载", en: "Spectral calibration reloaded" }, + "toast.pointAdded": { zh: "标定点 #{n} 已记录({v} mL)", en: "Point #{n} recorded ({v} mL)" }, + "toast.needConnect": { zh: "请先连接设备", en: "Connect to device first" }, + "toast.needPump": { zh: "请至少选择一台泵", en: "Select at least one pump" }, + "toast.tubingBusy": { zh: "管路作业进行中", en: "Tubing operation in progress" }, + + "log.connected": { zh: "握手成功 · 固件 ACK", en: "Handshake OK · firmware ACK" }, + "log.inject": { zh: "泵1 进样 {v} mL", en: "Pump1 injecting {v} mL" }, + "log.injectDone": { zh: "进样完成,切换泵2 滴定", en: "Injection done, pump2 titrating" }, + "log.t1": { zh: "电位导数越限 → T=1 候选 {v} mL", en: "dE/dV threshold → T=1 candidate {v} mL" }, + "log.done": { zh: "AMPD 精修终点 {v} mL · {m}", en: "AMPD refined endpoint {v} mL · {m}" }, + "log.pumpStall": { zh: "泵2 堵转:位置反馈无变化", en: "Pump2 stall: no position change" }, + "log.aborted": { zh: "用户中止,全泵停止", en: "Aborted by user, all pumps stopped" }, + "log.manualStop": { zh: "手动停止,进入精修", en: "Manual stop, refining" }, + "log.pumpRun": { zh: "泵{p} 连续运转", en: "Pump {p} free-run" }, + "log.pumpStop": { zh: "泵{p} 停止", en: "Pump {p} stopped" }, + "log.pumpJog": { zh: "泵{p} 定步 {n} 步 · {v} mL", en: "Pump {p} jog {n} steps · {v} mL" }, + "log.primeStart": { zh: "预充管路启动 · 泵 {p}", en: "Prime started · pump {p}" }, + "log.primeStop": { zh: "预充管路已停止", en: "Prime stopped" }, + "log.emptyStart": { zh: "排空管路启动 · 泵 {p}", en: "Empty started · pump {p}" }, + "log.emptyStop": { zh: "排空管路已停止", en: "Empty stopped" }, +} as const; + +export type DictKey = keyof typeof dict; + +export function translate(lang: Lang, key: DictKey, vars?: Record): string { + let s: string = dict[key]?.[lang] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; +} + +export function useT() { + const lang = useStore((s) => s.lang); + return (key: DictKey, vars?: Record) => translate(lang, key, vars); +} diff --git a/TController/app/ui-next/lib/mock/calibre.ts b/TController/app/ui-next/lib/mock/calibre.ts new file mode 100644 index 0000000..5ba5618 --- /dev/null +++ b/TController/app/ui-next/lib/mock/calibre.ts @@ -0,0 +1,29 @@ +/** + * mock 后端的 calibre.npz 镜像。 + * 数值取自项目 data/calibre.npz 的 pump2_*(滴定剂泵)。 + * 浏览器开发模式使用此镜像;Tauri 环境由后端读取真实 npz。 + */ +import type { CalPoint } from "@/lib/types"; + +/** volume = slope × steps + intercept(mL/步),与 controller-core 一致。 */ +export const CALIBRE_PUMP2_SLOPE = 6.099737096774193e-6; +export const CALIBRE_PUMP2_INTERCEPT = 0; +export const CALIBRE_PUMP2_R2 = 0.999655550757749; + +const PULSES = [ + 0, 10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 130000, 140000, 150000, +] as const; + +const VOLUMES = [ + 0, 0.064, 0.12713, 0.19421, 0.25165, 0.30748, 0.36944, 0.4316, 0.48854, 0.551, 0.60593, 0.66839, 0.73928, 0.79341, + 0.84804, 0.90779, +] as const; + +export const CALIBRE_PUMP2_POINTS: CalPoint[] = PULSES.map((steps, i) => ({ + steps, + vol: VOLUMES[i], +})); + +export function stepsPerMl(slopeMlPerStep: number): number { + return slopeMlPerStep > 0 ? 1 / slopeMlPerStep : 0; +} diff --git a/TController/app/ui-next/lib/mock/simulator.ts b/TController/app/ui-next/lib/mock/simulator.ts new file mode 100644 index 0000000..8a643b5 --- /dev/null +++ b/TController/app/ui-next/lib/mock/simulator.ts @@ -0,0 +1,448 @@ +/** + * Mock 仪器后端 —— 设计稿阶段的数据源。 + * + * 对外暴露与真实后端一致的动词语义(connect/start/stop/abort/jog…), + * 内部用定时器 + 滴定物理模型产生事件流写入 store。 + * 接入 Tauri 真实后端时,仅需把本文件替换为 invoke 桥, + * store 与 UI 组件无需改动。 + */ +import { toast } from "sonner"; +import { useStore } from "@/lib/store"; +import { translate } from "@/lib/i18n"; +import { WAVELENGTHS } from "@/lib/types"; +import type { CalPoint, EndpointResult, PotentialPoint, ScenarioId, SpectrumFrame, TubingOp } from "@/lib/types"; +import { CALIBRE_PUMP2_INTERCEPT, CALIBRE_PUMP2_POINTS, CALIBRE_PUMP2_R2, CALIBRE_PUMP2_SLOPE, stepsPerMl } from "@/lib/mock/calibre"; + +/* ---------------- 场景物理参数 ---------------- */ + +interface ScenarioCfg { + vEp: number; // 电位通道真实终点 + vEpSpecOffset: number; // 光谱通道终点偏移(冲突场景 >0) + noise: number; // 电位噪声 σ + peakAmp: number; // 光谱吸光度变化幅度 + fail: boolean; // 中途泵故障 +} + +const SCENARIOS: Record = { + normal: { vEp: 6.2, vEpSpecOffset: 0.0, noise: 0.003, peakAmp: 0.42, fail: false }, + noisy: { vEp: 6.4, vEpSpecOffset: 0.0, noise: 0.014, peakAmp: 0.11, fail: false }, + conflict:{ vEp: 6.2, vEpSpecOffset: 0.6, noise: 0.004, peakAmp: 0.36, fail: false }, + failure: { vEp: 6.2, vEpSpecOffset: 0.0, noise: 0.003, peakAmp: 0.42, fail: true }, +}; + +const BASE_TICK_MS = 620; // 1x 速度下的滴加间隔 +const MOCK_PORTS = [{ portName: "COM4", description: "Mock serial device" }]; + +/* ---------------- 工具 ---------------- */ + +function gauss(x: number, mu: number, sigma: number): number { + return Math.exp(-((x - mu) ** 2) / (2 * sigma * sigma)); +} +function sigmoid(x: number): number { + return 1 / (1 + Math.exp(-x)); +} +function rand(sigma: number): number { + // Box-Muller + const u = Math.random() || 1e-9; + const v = Math.random(); + return sigma * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); +} + +function log(level: "info" | "ok" | "warn" | "error", key: Parameters[1], vars?: Record) { + const { lang, addLog } = useStore.getState(); + addLog(level, translate(lang, key, vars)); +} + +/* ---------------- 引擎内部状态 ---------------- */ + +let tickTimer: ReturnType | null = null; +let heartbeatTimer: ReturnType | null = null; +let elapsedTimer: ReturnType | null = null; +let phaseTimer: ReturnType | null = null; + +let cfg: ScenarioCfg = SCENARIOS.normal; +let maxDerivVol = 0; +let maxDerivVal = 0; +let t1Fired = false; +let degree1Ticks = 0; +let runStartWall = 0; + +function clearTimers() { + if (tickTimer) clearInterval(tickTimer); + if (phaseTimer) clearTimeout(phaseTimer); + if (elapsedTimer) clearInterval(elapsedTimer); + tickTimer = phaseTimer = elapsedTimer = null; +} + +function speed() { + return useStore.getState().speed; +} + +/* ---------------- 曲线模型 ---------------- */ + +function potentialAt(v: number): number { + const base = -0.12 + 0.92 * sigmoid((v - cfg.vEp) / 0.13) + 0.004 * v; + return base + rand(cfg.noise); +} + +function spectrumAt(v: number): number[] { + const vSpec = cfg.vEp + cfg.vEpSpecOffset; + const progress = sigmoid((v - vSpec) / 0.15); + return WAVELENGTHS.map((wl) => { + const baseline = 0.18 - 0.00012 * (wl - 380) + 0.08 * gauss(wl, 430, 40); + const band = cfg.peakAmp * gauss(wl, 560, 55) * progress; + return Math.max(0, baseline + band + rand(0.0022)); + }); +} + +function computeDeriv(points: PotentialPoint[]): number | null { + const n = points.length; + if (n < 6) return null; + const a = points[n - 6]; + const b = points[n - 1]; + if (b.v <= a.v) return null; + return (b.e - a.e) / (b.v - a.v); +} + +function spectralStateAt(v: number): "IDLE" | "IN_CHANGE" | "END_CONFIRMED" { + const vSpec = cfg.vEp + cfg.vEpSpecOffset; + if (v < vSpec - 0.3) return "IDLE"; + if (v <= vSpec + 0.3) return "IN_CHANGE"; + return "END_CONFIRMED"; +} + +function buildResult(stage: "t1" | "final", volume: number, refined: number | null): EndpointResult { + const sc = useStore.getState().scenario; + const conflict = sc === "conflict"; + const noisy = sc === "noisy"; + const potVol = maxDerivVol > 0 ? Number(maxDerivVol.toFixed(3)) : volume; + const specVol = Number((potVol + cfg.vEpSpecOffset + (conflict ? 0.05 : 0)).toFixed(3)); + const split = Math.abs(specVol - potVol); + const disagree = split > useStore.getState().detection.consensusTol; + const method = disagree ? "conflict" : noisy ? "potential_only" : "consensus"; + const confidence = disagree ? "low" : noisy ? "medium" : "high"; + const reliability = disagree ? "CONFLICT" : noisy ? "LOW_EVIDENCE" : "OK"; + return { + stage, + volume: Number(volume.toFixed(3)), + method, + confidence, + potentialVolume: potVol, + spectralVolume: method === "conflict" ? specVol : potVol + 0.01, + reliability, + kf: + method === "consensus" + ? { volume: Number(((potVol + specVol) / 2).toFixed(3)), std: noisy ? 0.052 : 0.018, nis: noisy ? 5.4 : 1.9 } + : null, + refined, + }; +} + +/* ---------------- 滴加循环 ---------------- */ + +function titrationTick() { + const st = useStore.getState(); + const { workflow, volume, detection } = st; + if (workflow !== "titrating" && workflow !== "degree1" && workflow !== "titrating2") return; + const dose = detection.dose > 0 ? detection.dose : 0.05; + + // 故障场景:中途泵堵转 + if (cfg.fail && volume > cfg.vEp * 0.62 && workflow === "titrating") { + clearTimers(); + useStore.setState({ workflow: "error", pump2Running: false, badFrames: st.badFrames + 1 }); + log("error", "log.pumpStall"); + const { lang } = useStore.getState(); + toast.error(translate(lang, "toast.error", { msg: "pump stall" })); + recordHistory(true); + return; + } + + const newVol = volume + dose; + const nowS = (Date.now() - runStartWall) / 1000; + + // 电位点:一个加液周期内 3 个采样 + const pts: PotentialPoint[] = []; + for (let i = 1; i <= 3; i++) { + const v = volume + (dose * i) / 3; + pts.push({ v, t: nowS + i * 0.4, e: potentialAt(v) }); + } + const frame: SpectrumFrame = { v: newVol, absorbance: spectrumAt(newVol) }; + + const potPoints = [...st.potPoints, ...pts].slice(-6000); + const spectra = [...st.spectra, frame].slice(-2000); + const deriv = computeDeriv(potPoints); + + if (deriv !== null && deriv > maxDerivVal) { + maxDerivVal = deriv; + maxDerivVol = newVol; + } + + const patch: Partial> = { + volume: newVol, + potPoints, + spectra, + lastE: pts[2].e, + lastDeriv: deriv, + spectralState: spectralStateAt(newVol), + rx: st.rx + 4, + tx: st.tx + 1, + }; + + // T=1 判定:导数越限 + if (!t1Fired && deriv !== null && deriv > detection.t1DerivThreshold && newVol > 2) { + t1Fired = true; + const t1 = buildResult("t1", newVol, null); + patch.workflow = "degree1"; + patch.t1 = t1; + degree1Ticks = 0; + log("warn", "log.t1", { v: t1.volume.toFixed(2) }); + const { lang } = useStore.getState(); + toast.info(translate(lang, "toast.t1", { v: t1.volume.toFixed(2) })); + } else if (workflow === "degree1") { + degree1Ticks += 1; + if (degree1Ticks >= 3) patch.workflow = "titrating2"; + } else if (workflow === "titrating2" && t1Fired && newVol > maxDerivVol + detection.overTitrate) { + finishRun(patch as Record); + return; + } + + useStore.setState(patch); +} + +function finishRun(patch: Record) { + const st = useStore.getState(); + const refined = Number((maxDerivVol + rand(0.008)).toFixed(3)); + const final = buildResult("final", refined, refined); + useStore.setState({ + ...patch, + workflow: "done", + final, + pump2Running: false, + }); + clearTimers(); + log("ok", "log.done", { v: refined.toFixed(2), m: final.method }); + const { lang } = useStore.getState(); + toast.success(translate(lang, "toast.done", { v: refined.toFixed(2) })); + recordHistory(false); +} + +function recordHistory(aborted: boolean) { + const st = useStore.getState(); + st.recordRun({ + durationS: Math.round(st.elapsedMs / 1000), + sampleVolume: st.sampleVolume, + endpoint: st.final?.volume ?? (t1Fired ? maxDerivVol : null), + method: st.final?.method ?? null, + confidence: st.final?.confidence ?? null, + reliability: st.final?.reliability ?? null, + scenario: st.scenario, + aborted, + }); +} + +/* ---------------- 泵标定(calibre.npz 由后端读取;此处为 mock 镜像) ---------------- */ + +let pumpCal: { + points: CalPoint[]; + slopeMlPerStep: number; + intercept: number; + r2: number | null; +} = { + points: CALIBRE_PUMP2_POINTS.map((p) => ({ ...p })), + slopeMlPerStep: CALIBRE_PUMP2_SLOPE, + intercept: CALIBRE_PUMP2_INTERCEPT, + r2: CALIBRE_PUMP2_R2, +}; + +function publishPumpCal() { + useStore.setState({ + pumpSlope: Math.round(stepsPerMl(pumpCal.slopeMlPerStep)), + pumpIntercept: pumpCal.intercept, + pumpR2: pumpCal.r2, + calPoints: pumpCal.points.map((p) => ({ ...p })), + }); +} + +publishPumpCal(); +useStore.setState({ ports: MOCK_PORTS, port: MOCK_PORTS[0].portName }); + +/* ---------------- 对外动作(真实后端接线时替换内部实现) ---------------- */ + +export const backend = { + connect() { + const { port, baud, connecting, connected } = useStore.getState(); + if (connecting || connected) return; + useStore.setState({ connecting: true }); + phaseTimer = setTimeout(() => { + useStore.setState({ connecting: false, connected: true, rx: 12, tx: 8 }); + log("ok", "log.connected"); + const { lang } = useStore.getState(); + toast.success(translate(lang, "toast.connected", { port: `${port} @ ${baud}` })); + heartbeatTimer = setInterval(() => { + const s = useStore.getState(); + if (s.connected) useStore.setState({ heartbeatTick: s.heartbeatTick + 1, rx: s.rx + 1 }); + }, 1000); + }, 600); + }, + + disconnect() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + clearTimers(); + this.abort(true); + useStore.setState({ connected: false, pump1Running: false, pump2Running: false, tubingOp: null }); + const { lang } = useStore.getState(); + toast.message(translate(lang, "toast.disconnected")); + }, + + start() { + const st = useStore.getState(); + if (!st.connected) { + toast.warning(translate(st.lang, "toast.needConnect")); + return; + } + if (st.tubingOp) { + toast.warning(translate(st.lang, "toast.tubingBusy")); + return; + } + if (st.workflow === "injecting" || st.workflow === "titrating" || st.workflow === "degree1" || st.workflow === "titrating2") { + return; + } + st.resetRunData(); + cfg = SCENARIOS[st.scenario]; + t1Fired = false; + maxDerivVol = 0; + maxDerivVal = 0; + degree1Ticks = 0; + runStartWall = Date.now(); + const sample = st.sampleInput; + useStore.setState({ workflow: "injecting", sampleVolume: sample, pump1Running: true }); + log("info", "log.inject", { v: sample.toFixed(1) }); + const { lang } = useStore.getState(); + toast.info(translate(lang, "toast.runStarted", { v: sample.toFixed(1) })); + + phaseTimer = setTimeout(() => { + useStore.setState({ workflow: "titrating", pump1Running: false, pump2Running: true }); + log("info", "log.injectDone"); + tickTimer = setInterval(titrationTick, BASE_TICK_MS / speed()); + elapsedTimer = setInterval(() => { + const s = useStore.getState(); + useStore.setState({ elapsedMs: s.elapsedMs + 500 * s.speed }); + }, 500); + }, 2600 / speed()); + }, + + /** 手动停止:停泵并直接精修出结果 */ + manualStop() { + const st = useStore.getState(); + if (!["titrating", "degree1", "titrating2"].includes(st.workflow)) return; + clearTimers(); + const refined = maxDerivVol > 0 ? Number((maxDerivVol + rand(0.01)).toFixed(3)) : null; + const final = refined !== null ? buildResult("final", refined, refined) : null; + useStore.setState({ workflow: "done", final, pump2Running: false, pump1Running: false }); + log("info", "log.manualStop"); + recordHistory(false); + }, + + /** 中止 / 急停:全泵停止,回到待机,保留已采集曲线 */ + abort(silent = false) { + const st = useStore.getState(); + const running = ["injecting", "titrating", "degree1", "titrating2"].includes(st.workflow); + clearTimers(); + useStore.setState({ workflow: "idle", pump1Running: false, pump2Running: false, tubingOp: null }); + if (running) { + log("warn", "log.aborted"); + recordHistory(true); + } else if (!silent) { + log("warn", "log.aborted"); + } + }, + + /** 变速:运行中立即生效 */ + retune() { + const st = useStore.getState(); + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = setInterval(titrationTick, BASE_TICK_MS / speed()); + } + void st; + }, + + /* ---- 工作台:管路预充 / 排空(FreeRun,肉眼确认后手动停) ---- */ + startTubing(op: TubingOp) { + const st = useStore.getState(); + if (!st.connected) { + toast.warning(translate(st.lang, "toast.needConnect")); + return; + } + if (st.tubingOp || ["injecting", "titrating", "degree1", "titrating2"].includes(st.workflow)) { + toast.warning(translate(st.lang, "toast.tubingBusy")); + return; + } + const pumps = [st.tubingP1 && 1, st.tubingP2 && 2].filter(Boolean) as (1 | 2)[]; + if (pumps.length === 0) { + toast.warning(translate(st.lang, "toast.needPump")); + return; + } + useStore.setState({ + tubingOp: op, + pump1Running: pumps.includes(1), + pump2Running: pumps.includes(2), + tx: st.tx + pumps.length, + }); + log("info", op === "prime" ? "log.primeStart" : "log.emptyStart", { p: pumps.join("+") }); + }, + stopTubing() { + const st = useStore.getState(); + if (!st.tubingOp) return; + const op = st.tubingOp; + useStore.setState({ tubingOp: null, pump1Running: false, pump2Running: false, tx: st.tx + 1 }); + log("ok", op === "prime" ? "log.primeStop" : "log.emptyStop"); + }, + + /* ---- 维护页:泵手动控制 ---- */ + freeRun(pump: 1 | 2) { + const { connected, lang, tubingOp, workflow } = useStore.getState(); + if (!connected) return; + if (tubingOp || ["injecting", "titrating", "degree1", "titrating2"].includes(workflow)) return; + useStore.setState(pump === 1 + ? { pump1Running: true, tx: useStore.getState().tx + 1 } + : { pump2Running: true, tx: useStore.getState().tx + 1 }); + useStore.getState().addLog("info", translate(lang, "log.pumpRun", { p: pump })); + }, + freeStop(pump: 1 | 2) { + const { lang } = useStore.getState(); + useStore.setState(pump === 1 + ? { pump1Running: false, tx: useStore.getState().tx + 1 } + : { pump2Running: false, tx: useStore.getState().tx + 1 }); + useStore.getState().addLog("info", translate(lang, "log.pumpStop", { p: pump })); + }, + jog(pump: 1 | 2, steps: number) { + const { connected, lang } = useStore.getState(); + if (!connected || steps <= 0) return; + const vol = pumpCal.slopeMlPerStep * steps + pumpCal.intercept; + useStore.setState(pump === 1 + ? { pump1Steps: useStore.getState().pump1Steps + steps, tx: useStore.getState().tx + 1 } + : { pump2Steps: useStore.getState().pump2Steps + steps, tx: useStore.getState().tx + 1 }); + useStore.getState().addLog("ok", translate(lang, "log.pumpJog", { p: pump, n: steps, v: vol.toFixed(3) })); + }, + + /** 从后端重新读出当前载入的泵标定(mock:回放 calibre 镜像)。 */ + loadPumpCalibration() { + publishPumpCal(); + useStore.setState({ ports: MOCK_PORTS, port: useStore.getState().port || MOCK_PORTS[0].portName }); + }, + + /** 把本次会话拟合写回后端标定文件,再镜像到 store。 */ + applyPumpCalibration(points: CalPoint[], slopeStepsPerMl: number, interceptMl = 0, r2: number | null = null) { + if (points.length < 2 || !(slopeStepsPerMl > 0)) return false; + pumpCal = { + points: points.map((p) => ({ steps: p.steps, vol: p.vol })), + slopeMlPerStep: 1 / slopeStepsPerMl, + intercept: interceptMl, + r2, + }; + publishPumpCal(); + return true; + }, +}; diff --git a/TController/app/ui-next/lib/store.ts b/TController/app/ui-next/lib/store.ts new file mode 100644 index 0000000..31d14b4 --- /dev/null +++ b/TController/app/ui-next/lib/store.ts @@ -0,0 +1,273 @@ +import { create } from "zustand"; +import type { + CalPoint, + Confidence, + EndpointResult, + HistoryRun, + LogEntry, + Method, + PotentialPoint, + ReliabilityStatus, + ScenarioId, + SerialPortInfo, + SpectralState, + SpectrumFrame, + TubingOp, + WorkflowState, +} from "@/lib/types"; +import type { Lang } from "@/lib/i18n"; +import { backend, type BackendSnapshot } from "@/lib/backend"; + +export type PageId = "titration" | "calibration" | "maintenance" | "history" | "settings"; + +export interface DetectionParams { + t1DerivThreshold: number; + dose: number; + overTitrate: number; + consensusTol: number; +} + +export const DEFAULT_DETECTION: DetectionParams = { + t1DerivThreshold: 0.85, + dose: 0.05, + overTitrate: 0.8, + consensusTol: 0.15, +}; + +export type { CalPoint }; + +export interface AppState { + lang: Lang; + page: PageId; + navCollapsed: boolean; + connected: boolean; + connecting: boolean; + port: string; + baud: number; + ports: SerialPortInfo[]; + workflow: WorkflowState; + volume: number; + elapsedMs: number; + sampleVolume: number; + sampleInput: number; + scenario: ScenarioId; + speed: number; + tubingOp: TubingOp | null; + tubingP1: boolean; + tubingP2: boolean; + potPoints: PotentialPoint[]; + spectra: SpectrumFrame[]; + spectralState: SpectralState; + lastE: number | null; + lastDeriv: number | null; + t1: EndpointResult | null; + final: EndpointResult | null; + pump1Running: boolean; + pump2Running: boolean; + pump1Steps: number; + pump2Steps: number; + watchdogEnabled: boolean; + pumpSlope: number; + pumpIntercept: number; + pumpR2: number | null; + rx: number; + tx: number; + badFrames: number; + heartbeatTick: number; + logs: LogEntry[]; + history: HistoryRun[]; + calPoints: CalPoint[]; + detection: DetectionParams; + + setLang: (l: Lang) => void; + setPage: (p: PageId) => void; + toggleNav: () => void; + setPort: (p: string) => void; + setBaud: (b: number) => void; + setSampleInput: (v: number) => void; + setScenario: (s: ScenarioId) => void; + setSpeed: (x: number) => void; + setTubingPumps: (p1: boolean, p2: boolean) => void; + setWatchdog: (on: boolean) => void; + setDetection: (patch: Partial) => void; + clearLogs: () => void; + addLog: (level: LogEntry["level"], text: string) => void; + recordRun: (run: Omit) => void; + resetRunData: () => void; +} + +const initial: Omit = { + lang: "zh", + page: "titration", + navCollapsed: false, + connected: false, + connecting: false, + port: "", + baud: 115200, + ports: [{ portName: "COM4", description: "Mock serial device" }], + workflow: "idle", + volume: 0, + elapsedMs: 0, + sampleVolume: 10, + sampleInput: 10, + scenario: "normal", + speed: 1, + tubingOp: null, + tubingP1: true, + tubingP2: true, + potPoints: [], + spectra: [], + spectralState: "IDLE", + lastE: null, + lastDeriv: null, + t1: null, + final: null, + pump1Running: false, + pump2Running: false, + pump1Steps: 0, + pump2Steps: 0, + watchdogEnabled: true, + pumpSlope: 0, + pumpIntercept: 0, + pumpR2: null, + rx: 0, + tx: 0, + badFrames: 0, + heartbeatTick: 0, + logs: [], + history: [], + calPoints: [], + detection: { ...DEFAULT_DETECTION }, +}; + +function endpoint(raw: BackendSnapshot["t1"]): EndpointResult | null { + if (!raw) return null; + return { + stage: raw.stage === "final" ? "final" : "t1", + volume: raw.volume, + method: raw.method as Method, + confidence: raw.confidence as Confidence, + potentialVolume: raw.potentialVolume, + spectralVolume: raw.spectralVolume, + reliability: raw.reliability as ReliabilityStatus, + kf: raw.kf, + refined: raw.refined, + }; +} + +export function applyBackendSnapshot(snapshot: BackendSnapshot) { + const currentPort = useStore.getState().port; + const availablePort = snapshot.ports.some((item) => item.portName === currentPort) + ? currentPort + : snapshot.ports.some((item) => item.portName === snapshot.port) + ? snapshot.port + : snapshot.ports[0]?.portName ?? snapshot.port; + + useStore.setState({ + ports: snapshot.ports, + connected: snapshot.connected, + connecting: snapshot.connecting, + port: availablePort, + baud: snapshot.baud, + workflow: snapshot.workflow as WorkflowState, + volume: snapshot.volume, + elapsedMs: snapshot.elapsedMs, + sampleVolume: snapshot.sampleVolume, + sampleInput: snapshot.sampleInput, + tubingOp: snapshot.tubingOp, + tubingP1: snapshot.tubingP1, + tubingP2: snapshot.tubingP2, + pump1Running: snapshot.pump1Running, + pump2Running: snapshot.pump2Running, + pump1Steps: snapshot.pump1Steps, + pump2Steps: snapshot.pump2Steps, + pumpSlope: snapshot.pumpSlope, + pumpIntercept: snapshot.pumpIntercept, + pumpR2: snapshot.pumpR2, + calPoints: snapshot.calPoints, + potPoints: snapshot.potPoints, + spectra: snapshot.spectra, + spectralState: snapshot.spectralState as SpectralState, + lastE: snapshot.lastE, + lastDeriv: snapshot.lastDeriv, + t1: endpoint(snapshot.t1), + final: endpoint(snapshot.finalResult), + watchdogEnabled: snapshot.watchdogEnabled, + detection: snapshot.detection, + rx: snapshot.rx, + tx: snapshot.tx, + badFrames: snapshot.badFrames, + heartbeatTick: snapshot.heartbeatTick, + logs: snapshot.logs as LogEntry[], + history: snapshot.history as HistoryRun[], + lang: snapshot.lang, + navCollapsed: snapshot.navCollapsed, + }); +} + +export const useStore = create()((set, get) => ({ + ...initial, + setLang: (lang) => { + set({ lang }); + void backend.setUiSettings({ lang }); + }, + setPage: (page) => set({ page }), + toggleNav: () => { + const navCollapsed = !get().navCollapsed; + set({ navCollapsed }); + void backend.setUiSettings({ navCollapsed }); + }, + setPort: (port) => set({ port }), + setBaud: (baud) => set({ baud }), + setSampleInput: (sampleInput) => { + set({ sampleInput, sampleVolume: sampleInput }); + void backend.setSampleInput(sampleInput); + }, + setScenario: (scenario) => set({ scenario }), + setSpeed: (speed) => set({ speed }), + setTubingPumps: (tubingP1, tubingP2) => { + set({ tubingP1, tubingP2 }); + void backend.setTubingPumps(tubingP1, tubingP2); + }, + setWatchdog: (watchdogEnabled) => { + set({ watchdogEnabled }); + void backend.setWatchdog(watchdogEnabled); + }, + setDetection: (patch) => { + const detection = { ...get().detection, ...patch }; + set({ detection }); + void backend.setDetection(patch); + }, + clearLogs: () => set({ logs: [] }), + addLog: (level, text) => set({ logs: [...get().logs, { t: Date.now(), level, text }].slice(-400) }), + recordRun: (run) => { + const entry: HistoryRun = { ...run, id: Math.random().toString(36).slice(2, 9), startedAt: Date.now() }; + set({ history: [entry, ...get().history].slice(0, 30) }); + }, + resetRunData: () => { + set({ workflow: "idle", volume: 0, elapsedMs: 0, potPoints: [], spectra: [], spectralState: "IDLE", lastE: null, lastDeriv: null, t1: null, final: null }); + void backend.reset(); + }, +})); + +/** 结果面板展示用的方法/置信度徽标色调 */ +export function confidenceTone(c: Confidence | null): "ok" | "warn" | "danger" | "muted" { + if (c === "high") return "ok"; + if (c === "medium") return "warn"; + if (c === "low") return "danger"; + return "muted"; +} + +export function methodTone(m: Method | null): "ok" | "warn" | "danger" | "muted" { + if (m === "consensus") return "ok"; + if (m === "potential_only" || m === "spectral_only") return "warn"; + if (m === "conflict") return "danger"; + return "muted"; +} + +export function reliabilityTone(r: ReliabilityStatus | null): "ok" | "warn" | "danger" | "muted" { + if (r === "OK" || r === "CONFIRMED") return "ok"; + if (r === "LOW_EVIDENCE" || r === "NO_SPECTRUM" || r === "CANDIDATE" || r === "CONFIRMING" || r === "EARLY_WARNING") return "warn"; + if (r === "CONFLICT") return "danger"; + return "muted"; +} diff --git a/TController/app/ui-next/lib/tone.ts b/TController/app/ui-next/lib/tone.ts new file mode 100644 index 0000000..12d2dac --- /dev/null +++ b/TController/app/ui-next/lib/tone.ts @@ -0,0 +1,10 @@ +/** + * 语义色调样式映射。ok/warn/danger/muted 四级,与状态色 token 对齐。 + * 用于 Badge / 状态标签的背景+前景+边框一次性赋色。 + */ +export const toneClass: Record = { + ok: "border-transparent bg-[var(--status-ok)]/15 text-[var(--status-ok)]", + warn: "border-transparent bg-[var(--status-warn)]/15 text-[var(--status-warn)]", + danger: "border-transparent bg-[var(--status-danger)]/15 text-[var(--status-danger)]", + muted: "bg-muted text-muted-foreground border-transparent", +}; diff --git a/TController/app/ui-next/lib/types.ts b/TController/app/ui-next/lib/types.ts new file mode 100644 index 0000000..dbbfa8f --- /dev/null +++ b/TController/app/ui-next/lib/types.ts @@ -0,0 +1,107 @@ +/** + * 前后端事件协议类型 —— 与 controller-core (Rust) 语义对齐。 + * 当前由 lib/mock/simulator.ts 实现;接入真实后端时仅需替换数据源, + * 字段名保持 snake_case 序列化语义。 + */ + +export interface SerialPortInfo { + portName: string; + description: string | null; +} + +/** 对应 Rust TitrationState(serde rename_all = "snake_case") */ +export type WorkflowState = + | "idle" + | "injecting" + | "titrating" + | "degree1" + | "titrating2" + | "done" + | "error"; + +/** 对应 Rust Method */ +export type Method = "consensus" | "potential_only" | "spectral_only" | "conflict"; + +/** 对应 Rust Confidence */ +export type Confidence = "high" | "medium" | "low"; + +/** 对应 Rust TrackerState 字符串 */ +export type SpectralState = "IDLE" | "IN_CHANGE" | "END_CONFIRMED"; + +/** 可靠性状态(Reliability.status 的常见取值) */ +export type ReliabilityStatus = + | "OK" + | "CONFIRMED" + | "CANDIDATE" + | "CONFIRMING" + | "EARLY_WARNING" + | "LOW_EVIDENCE" + | "CONFLICT" + | "NO_SPECTRUM" + | "UNOBSERVABLE"; + +export interface PotentialPoint { + /** 滴定剂累计体积 mL */ + v: number; + /** 时间 s */ + t: number; + /** 电位 V */ + e: number; +} + +export interface SpectrumFrame { + v: number; + /** 吸光度,与 wavelengths 等长 */ + absorbance: number[]; +} + +export interface KfSnapshot { + volume: number; + std: number; + nis: number; +} + +export interface EndpointResult { + stage: "t1" | "final"; + volume: number; + method: Method; + confidence: Confidence; + potentialVolume: number | null; + spectralVolume: number | null; + reliability: ReliabilityStatus; + kf: KfSnapshot | null; + /** AMPD 精修值(final 阶段) */ + refined: number | null; +} + +export interface LogEntry { + t: number; + level: "info" | "ok" | "warn" | "error"; + text: string; +} + +export interface HistoryRun { + id: string; + startedAt: number; + durationS: number; + sampleVolume: number; + endpoint: number | null; + method: Method | null; + confidence: Confidence | null; + reliability: ReliabilityStatus | null; + scenario: ScenarioId; + aborted: boolean; +} + +export type ScenarioId = "normal" | "noisy" | "conflict" | "failure"; + +/** 管路作业:滴定前预充 / 结束后排空。不进入 TitrationState。 */ +export type TubingOp = "prime" | "empty"; + +/** 泵标定散点:累计步数 ↔ 实测体积。由后端从 calibre.npz 读出。 */ +export interface CalPoint { + steps: number; + vol: number; +} + +export const WAVELENGTHS: number[] = Array.from({ length: 61 }, (_, i) => 380 + i * 12); diff --git a/TController/app/ui-next/lib/utils.ts b/TController/app/ui-next/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/TController/app/ui-next/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/TController/app/ui-next/next.config.ts b/TController/app/ui-next/next.config.ts new file mode 100644 index 0000000..ce7359e --- /dev/null +++ b/TController/app/ui-next/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* Tauri frontendDist 指向静态导出产物 */ + output: "export", + images: { unoptimized: true }, +}; + +export default nextConfig; diff --git a/TController/app/ui-next/package-lock.json b/TController/app/ui-next/package-lock.json new file mode 100644 index 0000000..e76a69b --- /dev/null +++ b/TController/app/ui-next/package-lock.json @@ -0,0 +1,8759 @@ +{ + "name": "ui-next", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui-next", + "version": "0.1.0", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.34.0", + "next": "16.3.2", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "19.2.8", + "react-dom": "19.2.8", + "sonner": "^2.0.8", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.2", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.2.tgz", + "integrity": "sha512-8k4YoG8cM7LWlkfzGNYCRBbFNlernLiMw4s0btVl+CmmWqn3VpYypA72/5Feb1UWdxe6tHqr5KHP4p4Y4m9luA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.2.tgz", + "integrity": "sha512-z+HW1cZgt8QhByw8p2EbxF94AImgsKIYUbtSkA7Zld2T9yrKAlys4jNOcAOCtv6csX2CoA/5qCVyesL5pHmJ0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "4.9.1", + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.2.tgz", + "integrity": "sha512-ib5Llm93YCKoKWDh6ZaHq6QWTuOZ2bRkSnUwMmX8dsRIOkBNL1vVlSiUKSfixPL9SSh9pvukzqajk/klkn5vqg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.2.tgz", + "integrity": "sha512-qd98fX2+I5nYJDioW2o7nSjoxM5KvWdeDefM80igia4+C/qSIEhH4MhTE+hO/7qKM7W37/Mq+dOWp8UePSyLHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.2.tgz", + "integrity": "sha512-vqsgb6FAOzcrCccsLXiKtAy5t8EzO+uOazuFaSkQxeY0tNONG3vpHYy8pyBafcI5SNFPTeyard6yTr6SzNGo2A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.2.tgz", + "integrity": "sha512-xIe1eujfHUB2XcxHGddxJyu6TJRPjC5NpIkQYB/32ESkt5VkQyIAjmLRS38c+s6QY+qjtY/4KarVDzXRuD7lZQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.2.tgz", + "integrity": "sha512-Fe0SA2j8X0kmc3aveuHD7UktO3AE2+mH3LguP60vGbz7u0z+MrDXbeb5iZFYAwR7EzzzXJ2Yk966w9mGTFMqfA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.2.tgz", + "integrity": "sha512-TFBipb+gyesI/2Ve4zVu7kGltBWN/R466G5/1gtt2lECfc22G1pjkTxu68Q9aFcOaXiRGTQfvDbQQFe7mYgxiQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.2.tgz", + "integrity": "sha512-rVtmnNpBYIosDnKD/96dKxFsJnwnn1WRGG/HioSe8XCm2ksSHNrd2R6+hSjvTBxeMNhJ9pYeu/90cWB1nQLuNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.2.tgz", + "integrity": "sha512-H4Y2o2/JcHu8LtwzD5CXfHhwxwz8gfsx2HXDEw46Mtev5xHnEmB7HNtZtmriw5ReUOjRtcDqo7XSbU01FT9NlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.413", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", + "integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.2.tgz", + "integrity": "sha512-gTABOJmyc6pEgSX1Z1VOjBxkSmo5Hkdj+ePclDf8HLGTsnVWzgtDdrOeQrAnUkf0JNJJhwPuXrsIwmFZRKJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.3.2", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", + "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.2.tgz", + "integrity": "sha512-/ZCaubUy17Lld1SiPWxuPbCk2ihqAxF2QNQaPZeEaEb7t1I58qhsJN187D7AfpapHAqUPXH0f/thtdW9dWgWFg==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.2", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.2", + "@next/swc-darwin-x64": "16.3.2", + "@next/swc-linux-arm64-gnu": "16.3.2", + "@next/swc-linux-arm64-musl": "16.3.2", + "@next/swc-linux-x64-gnu": "16.3.2", + "@next/swc-linux-x64-musl": "16.3.2", + "@next/swc-win32-arm64-msvc": "16.3.2", + "@next/swc-win32-x64-msvc": "16.3.2", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/radix-ui": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/TController/app/ui-next/package.json b/TController/app/ui-next/package.json new file mode 100644 index 0000000..07b6aed --- /dev/null +++ b/TController/app/ui-next/package.json @@ -0,0 +1,35 @@ +{ + "name": "ui-next", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.34.0", + "next": "16.3.2", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "19.2.8", + "react-dom": "19.2.8", + "sonner": "^2.0.8", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.3.2", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/TController/app/ui-next/postcss.config.mjs b/TController/app/ui-next/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/TController/app/ui-next/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/TController/app/ui-next/public/file.svg b/TController/app/ui-next/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/TController/app/ui-next/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TController/app/ui-next/public/globe.svg b/TController/app/ui-next/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/TController/app/ui-next/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TController/app/ui-next/public/next.svg b/TController/app/ui-next/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/TController/app/ui-next/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TController/app/ui-next/public/vercel.svg b/TController/app/ui-next/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/TController/app/ui-next/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TController/app/ui-next/public/window.svg b/TController/app/ui-next/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/TController/app/ui-next/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TController/app/ui-next/tsconfig.json b/TController/app/ui-next/tsconfig.json new file mode 100644 index 0000000..3a13f90 --- /dev/null +++ b/TController/app/ui-next/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/TController/app/ui/index.html b/TController/app/ui/index.html new file mode 100644 index 0000000..af77e9d --- /dev/null +++ b/TController/app/ui/index.html @@ -0,0 +1,67 @@ + + + + + + AutoTitrator — TController + + + +
+

TController(Rust 重写进行中)

+

Tauri 前端占位页 — 后端核心(协议 / 终点检测 / 工作流)已完成移植并通过测试。

+
+
后端版本
+
校准数据
+
可用串口
+
+
UI 将在后续迭代中实现:连接 / 滴定控制 / 实时曲线 / 校准 / 维护。
+
+ + + + diff --git a/TController/crates/controller-core/Cargo.toml b/TController/crates/controller-core/Cargo.toml new file mode 100644 index 0000000..2b584d2 --- /dev/null +++ b/TController/crates/controller-core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "controller-core" +description = "TController 上位机后端核心(协议 + 数据处理 + 工作流)的 Rust 移植" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ndarray = "0.16" +ndarray-npy = "0.9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serialport = "4" +thiserror = "2" + +[dev-dependencies] +approx = "0.5" diff --git a/TController/crates/controller-core/src/lib.rs b/TController/crates/controller-core/src/lib.rs new file mode 100644 index 0000000..ec0fd96 --- /dev/null +++ b/TController/crates/controller-core/src/lib.rs @@ -0,0 +1,20 @@ +//! controller-core — TController 上位机后端核心的 Rust 移植。 +//! +//! 该 crate 保留了从旧 Python 上位机移植时建立的模块边界: +//! +//! | Python | Rust | +//! |-------------------------------|-----------------------------------| +//! | `Communication/protocol.py` | [`protocol`] | +//! | `DataProcessor/endpoint.py` | [`processing::endpoint`] | +//! | `DataProcessor/online_features.py` | [`processing::tracker`] / [`processing::kf`] / [`processing::divergence`] | +//! | `DataProcessor/reconstructor.py` | [`processing::reconstructor`] | +//! | `DataProcessor/calibration.py` | [`processing::calibration`] | +//! | `gui/main_window.py` 工作流 | [`workflow`] | +//! +//! 本 crate 为纯逻辑 + 串口 I/O 线程,不含 UI;Tauri 壳在 `app/src-tauri`。 + +pub mod processing; +pub mod protocol; +pub mod workflow; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/TController/crates/controller-core/src/processing/ampd.rs b/TController/crates/controller-core/src/processing/ampd.rs new file mode 100644 index 0000000..c15c1f9 --- /dev/null +++ b/TController/crates/controller-core/src/processing/ampd.rs @@ -0,0 +1,147 @@ +//! AMPD(自动多尺度峰值检测)— Python `_ampd_peak_idx` 向量化版本的逐位移植。 +//! +//! 逐尺度即时归约而不物化稠密 `L×N` 局部极大矩阵;严格比较、首个 argmin/argmax +//! 与 Python/NumPy 一致。 + +/// 返回最显著 AMPD 峰索引,无峰返回 `None`。 +pub fn ampd_peak_idx(signal: &[f64]) -> Option { + let n = signal.len(); + if n < 12 { + return None; + } + let l = n / 2 - 1; + if l < 2 { + return None; + } + + // 尺度 k 的局部极大判定:sig[i] 严格大于两侧 sig[i±k](i ∈ [k, N-k))。 + let row = |k: usize, i: usize| signal[i] > signal[i - k] && signal[i] > signal[i + k]; + + // gamma[k-1] 为尺度 k 的极大计数;sigma 是信号最稳定呈峰的尺度(首个最小值)。 + let gamma: Vec = (1..=l) + .map(|k| (k..n - k).filter(|&i| row(k, i)).count() as i64) + .collect(); + let sigma = gamma + .iter() + .enumerate() + .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(idx, _)| idx)?; + + let mut score = vec![0i64; n]; + // Python: for k in range(sigma + 1, L + 1) —— sigma 是 0 基 gamma 索引, + // 故实际起始尺度 k = sigma + 1。 + for k in (sigma + 1)..=l { + for i in k..n - k { + if row(k, i) { + score[i] += 1; + } + } + } + let best = score + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(idx, _)| idx)?; + (score[best] > 0).then_some(best) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 稠密 O(N²) 参照实现(Python 测试 `_ampd_reference` 的移植,作 oracle)。 + fn ampd_dense_reference(signal: &[f64]) -> Option { + let n = signal.len(); + if n < 12 { + return None; + } + let l = n / 2 - 1; + if l < 2 { + return None; + } + let mut lms = vec![vec![0i64; n]; l]; + for k in 1..=l { + for i in k..n - k { + if signal[i] > signal[i - k] && signal[i] > signal[i + k] { + lms[k - 1][i] = 1; + } + } + } + let row_sums: Vec = lms.iter().map(|r| r.iter().sum()).collect(); + let sigma = row_sums + .iter() + .enumerate() + .min_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(idx, _)| idx)?; + let mut score = vec![0i64; n]; + for row in &lms[sigma..] { + for (i, &v) in row.iter().enumerate() { + score[i] += v; + } + } + let best = score + .iter() + .enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .map(|(idx, _)| idx)?; + (score[best] > 0).then_some(best) + } + + /// 与 Python 测试同源的确定性伪随机(xorshift64*,足够做 oracle 对照)。 + struct Rng(u64); + impl Rng { + fn next_f64(&mut self) -> f64 { + // xorshift64* + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + let v = x.wrapping_mul(0x2545F4914F6CDD1D); + (v >> 11) as f64 / (1u64 << 53) as f64 + } + fn normal(&mut self) -> f64 { + // Box-Muller + let u1 = self.next_f64().max(1e-12); + let u2 = self.next_f64(); + (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos() + } + } + + #[test] + fn reduction_matches_dense_reference() { + let mut cases: Vec> = vec![ + vec![0.0; 40], + vec![1.0; 40], + (0..40).map(|i| i as f64).collect(), + (0..40).rev().map(|i| i as f64).collect(), + vec![1.0, 2.0, 1.0], + (0..12).map(|i| i as f64 / 11.0).collect(), + ]; + let mut rng = Rng(20260821); + for &length in &[13usize, 25, 64, 137] { + cases.push((0..length).map(|_| rng.normal()).collect()); + let peak: Vec = (0..length) + .map(|i| { + let c = 0.6 * length as f64; + (-((i as f64 - c).powi(2)) / (2.0 * (length as f64 / 12.0).powi(2))).exp() + }) + .collect(); + cases.push(peak.iter().map(|&p| p + 0.05 * rng.normal()).collect()); + } + for case in &cases { + assert_eq!( + ampd_peak_idx(case), + ampd_dense_reference(case), + "case len={}", + case.len() + ); + } + } + + #[test] + fn short_signals_return_none() { + assert_eq!(ampd_peak_idx(&[1.0, 2.0, 1.0]), None); + assert_eq!(ampd_peak_idx(&[0.0; 11]), None); + } +} diff --git a/TController/crates/controller-core/src/processing/calibration.rs b/TController/crates/controller-core/src/processing/calibration.rs new file mode 100644 index 0000000..4a9bdb9 --- /dev/null +++ b/TController/crates/controller-core/src/processing/calibration.rs @@ -0,0 +1,157 @@ +//! 蠕动泵标定 — Python `calibration.py` 的移植。 +//! +//! 标定参数从 `calibre.npz` 的 `pump1_slope`/`pump1_intercept` 加载, +//! 加载失败或非法时静默回退默认值。 + +use std::path::Path; + +use ndarray_npy::NpzReader; + +/// 泵步进频率(Hz,固件 PumpMotor1::Initialize(1000))。 +pub const PUMP_STEP_FREQ: f64 = 1000.0; +/// 默认斜率(mL/步),npz 缺失/非法时回退。 +pub const DEFAULT_PUMP_SLOPE: f64 = 6.03752e-6; +pub const DEFAULT_PUMP_INTERCEPT: f64 = 0.0; + +/// 泵线性模型:volume = slope × steps + intercept。 +#[derive(Debug, Clone, Copy)] +pub struct PumpCalibration { + pub slope: f64, + pub intercept: f64, +} + +impl Default for PumpCalibration { + fn default() -> Self { + Self { + slope: DEFAULT_PUMP_SLOPE, + intercept: DEFAULT_PUMP_INTERCEPT, + } + } +} + +impl PumpCalibration { + /// 流速(mL/s)。 + pub fn flow_rate(&self) -> f64 { + self.slope * PUMP_STEP_FREQ + } + + /// 体积(mL)→ 泵步数(反向线性;下限 0)。 + pub fn steps_from_volume(&self, volume_ml: f64) -> u32 { + if self.slope <= 0.0 { + // Python 会 raise;这里防御性返回 0,调用方负责用 valid() 校验。 + return 0; + } + let steps = (volume_ml - self.intercept) / self.slope; + steps.max(0.0) as u32 + } + + /// 泵步数 → 体积(mL)。 + pub fn volume_from_steps(&self, steps: u32) -> f64 { + self.slope * steps as f64 + self.intercept + } + + /// 标定参数是否可用(slope 必须为正)。 + pub fn valid(&self) -> bool { + self.slope > 0.0 + } + + /// 从 `calibre.npz` 读取 `pump{N}_slope` / `pump{N}_intercept`; + /// 非法或缺失时静默保留默认值(与 Python `_load` 行为一致)。 + pub fn load_from(path: &Path, pump_id: u8) -> Self { + let mut this = Self::default(); + if !path.is_file() { + return this; + } + let Ok(file) = std::fs::File::open(path) else { + return this; + }; + let Ok(mut npz) = NpzReader::new(file) else { + return this; + }; + let read_scalar = |npz: &mut NpzReader, key: &str| -> Option { + let arr: ndarray::Array1 = npz.by_name(key).ok()?; + arr.first().copied() + }; + let slope_key = format!("pump{pump_id}_slope"); + let intercept_key = format!("pump{pump_id}_intercept"); + let (Some(slope), intercept) = ( + read_scalar(&mut npz, &slope_key), + read_scalar(&mut npz, &intercept_key).unwrap_or(0.0), + ) else { + return this; + }; + // 合法性校验:slope 必为正;intercept 允许负但 |intercept| ≤ 10 mL。 + if slope <= 0.0 || intercept.abs() > 10.0 { + return this; + } + this.slope = slope; + this.intercept = intercept; + this + } + + /// 从 `calibre.npz` 读取泵的累计步数、实测体积点和 R²。 + /// 缺少任一数组时返回空点集;非法值由调用方决定如何展示。 + pub fn load_points_from(path: &Path, pump_id: u8) -> (Vec<(u32, f64)>, Option) { + if !path.is_file() { + return (Vec::new(), None); + } + let Ok(file) = std::fs::File::open(path) else { + return (Vec::new(), None); + }; + let Ok(mut npz) = NpzReader::new(file) else { + return (Vec::new(), None); + }; + let pulses_key = format!("pump{pump_id}_pulses"); + let volumes_key = format!("pump{pump_id}_volumes"); + let r2_key = format!("pump{pump_id}_r2"); + let Ok(pulses) = npz.by_name::, ndarray::Ix1>(&pulses_key) else { + return (Vec::new(), None); + }; + let Ok(volumes) = npz.by_name::, ndarray::Ix1>(&volumes_key) else { + return (Vec::new(), None); + }; + let points = pulses + .iter() + .zip(volumes.iter()) + .filter_map(|(&steps, &vol)| { + (steps >= 0 && vol.is_finite()).then_some((steps as u32, vol)) + }) + .collect(); + let r2 = npz + .by_name::, ndarray::Ix0>(&r2_key) + .ok() + .and_then(|v| v.first().copied()) + .or_else(|| { + npz.by_name::, ndarray::Ix1>(&r2_key) + .ok() + .and_then(|v| v.first().copied()) + }); + (points, r2.filter(|v| v.is_finite())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_roundtrip() { + let cal = PumpCalibration::default(); + approx::assert_relative_eq!(cal.flow_rate(), 6.03752e-3); + let steps = cal.steps_from_volume(5.0); + // u32 截断误差 < 1 步(≈6e-6 mL) + approx::assert_relative_eq!(cal.volume_from_steps(steps), 5.0, epsilon = 1e-4); + } + + #[test] + fn intercept_shifts_volume() { + let cal = PumpCalibration { + slope: 1e-5, + intercept: 0.1, + }; + approx::assert_relative_eq!(cal.volume_from_steps(1000), 0.1 + 1e-2, max_relative = 1e-9); + // 反向换算扣除 intercept;u32 截断容许 ±1 步的浮点边界 + let steps = cal.steps_from_volume(0.1 + 1e-2); + assert!((999..=1000).contains(&steps), "steps = {steps}"); + } +} diff --git a/TController/crates/controller-core/src/processing/divergence.rs b/TController/crates/controller-core/src/processing/divergence.rs new file mode 100644 index 0000000..bef8c39 --- /dev/null +++ b/TController/crates/controller-core/src/processing/divergence.rs @@ -0,0 +1,163 @@ +//! 谱散度函数 — JS 散度(有界)、旧版交叉熵与 KL(`cross_entropy_excess`)。 + +/// 数值下限(Python `_EPS`)。 +pub const EPS: f64 = 1e-12; +/// JS 实测舍入地板(Python `_JS_FLOOR`): +/// float64 上真实 8 通道帧的 JS 舍入底约 5e-17,平台期约 2e-12,终点事件约 2e-7。 +/// js_speed 除以体积步长平方(约 4e7 倍放大),低于此地板的 JS 绝不能归一化。 +pub const JS_FLOOR: f64 = 1e-14; + +/// NumPy `np.sum` 的成对求和逐位复刻(loops_utils.h `pairwise_sum`): +/// n<8 顺序累加;n≤128 用 8 累加器树;更大者对半递归。 +/// 与 NumPy 的求和舍入完全一致,是双实现数值对齐的前提。 +pub fn np_sum(a: &[f64]) -> f64 { + const PW_BLOCKSIZE: usize = 128; + let n = a.len(); + if n < 8 { + let mut res = 0.0; + for &x in a { + res += x; + } + return res; + } + if n <= PW_BLOCKSIZE { + let mut r = [0.0f64; 8]; + r.copy_from_slice(&a[..8]); + let mut i = 8; + let limit = n - (n % 8); + while i < limit { + for j in 0..8 { + r[j] += a[i + j]; + } + i += 8; + } + let mut res = ((r[0] + r[1]) + (r[2] + r[3])) + ((r[4] + r[5]) + (r[6] + r[7])); + while i < n { + res += a[i]; + i += 1; + } + return res; + } + // n > 128:对半递归 + let n2 = n / 2; + np_sum(&a[..n - n2]) + np_sum(&a[n - n2..]) +} + +/// 帧级检查+归一化(Python `_finite_vector`):非负、有限、和为 1。 +/// 返回 `Err(原因)` 供诊断字段使用,与 Python 的 reason 字符串一致。 +pub fn finite_vector(values: &[f64]) -> Result, &'static str> { + if values.is_empty() { + return Err("spectrum_empty"); + } + if !values.iter().all(|v| v.is_finite()) { + return Err("spectrum_nonfinite"); + } + let clipped: Vec = values.iter().map(|v| v.max(0.0)).collect(); + let total = np_sum(&clipped); + if total <= EPS { + return Err("spectrum_zero"); + } + Ok(clipped.into_iter().map(|v| v / total).collect()) +} + +/// 加性平滑归一化(Python `normalize_spectrum`,ε=1e-9)。 +pub fn normalize_spectrum(values: &[f64]) -> Result, String> { + if values.is_empty() || !values.iter().all(|v| v.is_finite()) { + return Err("spectrum must be a non-empty finite array".into()); + } + let smoothed: Vec = values.iter().map(|v| v.max(0.0) + 1e-9).collect(); + let sum = np_sum(&smoothed); + Ok(smoothed.into_iter().map(|v| v / sum).collect()) +} + +/// 自然对数底的 Jensen-Shannon 散度(对称,值域 [0, ln2])。 +/// +/// 元素级运算与两段求和的分组顺序均与 NumPy 版一致(0.5·Σp + 0.5·Σq)。 +pub fn js_divergence(p: &[f64], q: &[f64]) -> f64 { + let p = normalize_spectrum(p).expect("js_divergence: invalid p"); + let q = normalize_spectrum(q).expect("js_divergence: invalid q"); + let p_part: Vec = p + .iter() + .zip(&q) + .map(|(&pi, &qi)| pi * (pi / (0.5 * (pi + qi))).ln()) + .collect(); + let q_part: Vec = q + .iter() + .zip(&p) + .map(|(&qi, &pi)| qi * (qi / (0.5 * (pi + qi))).ln()) + .collect(); + let value = 0.5 * np_sum(&p_part) + 0.5 * np_sum(&q_part); + value.clamp(0.0, std::f64::consts::LN_2) +} + +/// 旧版方向性交叉熵(保留供旧脚本读取,勿用于驱动阈值状态机)。 +pub fn cross_entropy(p: &[f64], q: &[f64]) -> f64 { + let p = normalize_spectrum(p).expect("cross_entropy: invalid p"); + let q = normalize_spectrum(q).expect("cross_entropy: invalid q"); + let part: Vec = p + .iter() + .zip(&q) + .map(|(&pi, &qi)| pi * qi.max(1e-12).ln()) + .collect(); + -np_sum(&part) +} + +/// 去自身地板的交叉熵 = KL(p‖q):恒等分布为 0,可与退出阈值比较。 +/// +/// `cross_entropy(p,p)` 是 p 的熵(~ln n)而非 0,直接驱动状态机会永远出不去 +/// IN_CHANGE;减去地板后才是可用的 KL。 +pub fn cross_entropy_excess(p: &[f64], q: &[f64]) -> f64 { + let p = normalize_spectrum(p).expect("cross_entropy_excess: invalid p"); + let q = normalize_spectrum(q).expect("cross_entropy_excess: invalid q"); + let delta: Vec = p + .iter() + .zip(&q) + .map(|(&pi, &qi)| pi * (pi.max(1e-12).ln() - qi.max(1e-12).ln())) + .collect(); + np_sum(&delta).max(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn js_is_symmetric_bounded_and_gain_invariant() { + let p = [1.0, 2.0, 4.0, 8.0]; + let q = [2.0, 3.0, 5.0, 7.0]; + let pq = js_divergence(&p, &q); + let qp = js_divergence(&q, &p); + approx::assert_relative_eq!(pq, qp); + assert!((0.0..=std::f64::consts::LN_2).contains(&pq)); + let scaled = [17.0, 34.0, 68.0, 136.0]; + let scaled_q = [34.0, 51.0, 85.0, 119.0]; + // Python np.isclose 默认 rtol=1e-5;放大 17 倍引入 ~1e-9 相对舍入差 + approx::assert_relative_eq!(pq, js_divergence(&scaled, &scaled_q), max_relative = 1e-6); + } + + #[test] + fn identical_distributions_have_zero_js_and_excess() { + let v = [1.0, 2.0, 3.0, 4.0]; + approx::assert_relative_eq!(js_divergence(&v, &v), 0.0, epsilon = 1e-15); + approx::assert_relative_eq!(cross_entropy_excess(&v, &v), 0.0, epsilon = 1e-12); + } + + #[test] + fn finite_vector_reports_reasons() { + assert_eq!(finite_vector(&[]), Err("spectrum_empty")); + assert_eq!( + finite_vector(&[1.0, f64::NAN, 1.0]), + Err("spectrum_nonfinite") + ); + assert_eq!(finite_vector(&[0.0, 0.0]), Err("spectrum_zero")); + let n = finite_vector(&[1.0, 3.0]).unwrap(); + approx::assert_relative_eq!(n[1], 0.75); + } + + #[test] + fn cross_entropy_floor_is_entropy_not_zero() { + let identical = [1.0, 2.0, 3.0, 4.0]; + assert!(cross_entropy(&identical, &identical) > 1.0); + assert!(cross_entropy_excess(&identical, &[4.0, 3.0, 2.0, 1.0]) > 0.0); + } +} diff --git a/TController/crates/controller-core/src/processing/endpoint.rs b/TController/crates/controller-core/src/processing/endpoint.rs new file mode 100644 index 0000000..85e525d --- /dev/null +++ b/TController/crates/controller-core/src/processing/endpoint.rs @@ -0,0 +1,775 @@ +//! 因果在线滴定终点检测器 — Python `EndpointDetector` 的移植。 +//! +//! 电位通道走既有因果 EWMA 状态机;光谱通道委托 [`SpectralFeatureTracker`] +//! (有界 JS 信号、因果交叉曲率、终点/延迟两状态 KF 融合)。任何特征都不 +//! 使用未来样本。 +//! +//! 任一模态的终点都可能事后修正——光谱端被更强激变顶替、电位端被 AMPD +//! 精修——所以观测对变化时 KF 从头重跑:用陈旧状态门控修正值只会拒绝修正。 + +use serde::Serialize; + +use super::ampd::ampd_peak_idx; +use super::ewma::Ewma; +use super::kf::{EndpointFusionKf, ObservationKind}; +use super::tracker::{SpectralFeatureTracker, TrackerState}; + +// ---- 电位通道参数(Python 同名常量)---- +pub const POT_V_ALPHA: f64 = 0.15; +pub const POT_D_ALPHA: f64 = 0.05; +/// 观察期体积(mL):此前只累计基线统计,不驱动状态机。 +pub const POT_OBSERVE_VOL: f64 = 0.1; +pub const POT_ENTER_SIGMA: f64 = 2.5; +pub const POT_EXIT_SIGMA: f64 = 2.5; +pub const POT_MIN_ENTER: f64 = 0.005; +pub const POT_MIN_EXIT: f64 = 0.001; +/// 进入→退出之间的最小确认体积(mL)。 +pub const POT_CONFIRM_VOL: f64 = 0.15; + +// ---- 光谱通道参数(JS 时代阈值,nats/mL²)---- +pub const SPEC_CE_ALPHA: f64 = 0.20; +pub const SPEC_ENTER: f64 = 1e-3; +pub const SPEC_EXIT: f64 = 1e-4; +pub const SPEC_CONFIRM_FRAMES: usize = 10; + +pub const SPEC_JS_ENTER: f64 = 0.05; +pub const SPEC_JS_EXIT: f64 = 0.008; +pub const SPEC_BASELINE_ENTER: f64 = 3e-7; +pub const SPEC_BASELINE_FRAMES: usize = 12; +pub const SPEC_BASELINE_MAX_VOL: f64 = 0.30; +pub const SPEC_MIN_EVENT_VOL: f64 = 0.08; +/// 后发激变须强过的倍数才能顶替终点。 +pub const SPEC_SUPERSEDE_RATIO: f64 = 1.5; + +/// AMPD 精修拒绝的尾部位置上限:最大尺度只覆盖窗口中部,尾部峰支持的尺度 +/// 很少;0.75 曾静默拒绝手动停止稍晚于等价点的合法终点,故守在无支撑尾部 +/// 之内。 +pub const AMPD_MAX_POSITION: f64 = 0.9; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PotentialState { + Idle, + Tracking, + EndConfirmed, +} + +impl PotentialState { + pub fn as_str(self) -> &'static str { + match self { + PotentialState::Idle => "IDLE", + PotentialState::Tracking => "TRACKING", + PotentialState::EndConfirmed => "END_CONFIRMED", + } + } +} + +/// 电位通道结果(Python `_build_pot_result`)。 +#[derive(Debug, Clone, Serialize)] +pub struct PotentialResult { + pub volume: f64, + pub time: f64, + pub min_dvdt: f64, + pub state: PotentialState, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint_std: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub innovation: Option, +} + +/// 光谱通道结果(Python `_build_spec_result`;max_ce 为旧导出兼容别名)。 +#[derive(Debug, Clone, Serialize)] +pub struct SpectralResult { + pub volume: f64, + pub time: f64, + pub max_ce: f64, + pub max_js: f64, + pub js_local: f64, + pub js_speed: f64, + pub js_base: f64, + pub cross_curvature: Option, + pub event_maturity: f64, + pub recovery_frames: usize, + pub event_count: usize, + pub superseded_count: usize, + pub event_peak_speed: f64, + pub state: TrackerState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Confidence { + High, + Medium, + Low, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Method { + /// KF 融合双模态(或 KF 关闭时 |ΔV|<0.3 mL 取均值)。 + Consensus, + PotentialOnly, + SpectralOnly, + /// 双模态均确认但未过 NIS 门控,退回电位。 + Conflict, +} + +/// 数据质量子结构。 +#[derive(Debug, Clone, Serialize)] +pub struct DataQualityInfo { + pub potential_samples: usize, + pub spectral_samples: usize, + pub valid_spectral_frames: usize, + pub baseline_ready: bool, + pub repeated_spectral_volume: usize, + pub nonmonotonic_volume: usize, + pub last_frame: String, +} + +/// 双模态一致性子结构。 +#[derive(Debug, Clone, Serialize)] +pub struct ModalConsistency { + pub agreement_ml: Option, + pub kf_consistent: Option, +} + +/// 在线可靠性(Python `_build_reliability`)。 +#[derive(Debug, Clone, Serialize)] +pub struct Reliability { + pub status: String, + pub data_quality: DataQualityInfo, + pub potential_evidence: bool, + pub spectral_evidence: bool, + pub modal_consistency: ModalConsistency, + pub event_maturity: f64, + pub spectral_events: usize, + pub spectral_superseded: usize, + pub endpoint_std: Option, + pub spectral_delay: Option, + pub nis: Option, + pub innovation: Option, + pub reason_codes: Vec, +} + +/// `detect()` 的最终结果。 +#[derive(Debug, Clone, Serialize)] +pub struct EndpointResult { + pub volume: f64, + pub time: f64, + pub confidence: Confidence, + pub method: Method, + #[serde(skip_serializing_if = "Option::is_none")] + pub potential: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spectral: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, + pub reliability: Reliability, +} + +/// 检测器完整诊断(Python `diagnostics()`)。 +#[derive(Debug, Clone, Serialize)] +pub struct DetectorDiagnostics { + pub potential_state: PotentialState, + pub spectral_state: TrackerState, + #[serde(skip_serializing_if = "Option::is_none")] + pub potential: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spectral: Option, + pub spectral_features: super::tracker::Diagnostics, + pub kf: Option, + pub reliability: Reliability, +} + +/// 因果终点检测器(电位 + 全场光谱)。 +pub struct EndpointDetector { + flow_rate: f64, + pub use_jsd: bool, + pub enable_curvature: bool, + pub enable_kf: bool, + spectrum_axis: Option>, + + // 电位状态 + pot_v_smooth: Ewma, + pot_d_smooth: Ewma, + pot_prev_v: Option, + pot_prev_t: Option, + pot_state: PotentialState, + pot_ep_vol: Option, + pot_min_d: f64, + pot_cand_vol: Option, + pot_entry_vol: Option, + pot_done: bool, + pot_d_vals: Vec, + pot_obs_done: bool, + pot_enter_th: f64, + pot_exit_th: f64, + pot_raw_buf: Vec, + pot_vol_buf: Vec, + pot_sample_count: usize, + last_pot_result: Option, + + // 光谱状态(委托 tracker) + spectral: SpectralFeatureTracker, + spec_ep_vol: Option, + spec_max_js: f64, + last_spec_diag: super::tracker::Diagnostics, + last_spec_result: Option, + + kf: Option, + kf_consumed: Option<(Option, Option)>, + last_reliability: Reliability, +} + +impl EndpointDetector { + pub fn new(flow_rate: f64) -> Self { + Self::with_options(flow_rate, true, true, true, None) + } + + pub fn with_options( + flow_rate: f64, + use_jsd: bool, + enable_curvature: bool, + enable_kf: bool, + wavelengths: Option<&[f64]>, + ) -> Self { + let spectral = SpectralFeatureTracker::with_params( + SPEC_CE_ALPHA, + if use_jsd { SPEC_JS_ENTER } else { SPEC_ENTER }, + if use_jsd { SPEC_JS_EXIT } else { SPEC_EXIT }, + SPEC_BASELINE_ENTER, + SPEC_BASELINE_FRAMES, + SPEC_BASELINE_MAX_VOL, + SPEC_CONFIRM_FRAMES, + SPEC_MIN_EVENT_VOL, + 1e-8, + 8, + SPEC_SUPERSEDE_RATIO, + super::divergence::JS_FLOOR, + use_jsd, + ); + let mut det = Self { + flow_rate, + use_jsd, + enable_curvature, + enable_kf, + spectrum_axis: wavelengths.map(|w| w.to_vec()), + pot_v_smooth: Ewma::new(POT_V_ALPHA), + pot_d_smooth: Ewma::new(POT_D_ALPHA), + pot_prev_v: None, + pot_prev_t: None, + pot_state: PotentialState::Idle, + pot_ep_vol: None, + pot_min_d: 0.0, + pot_cand_vol: None, + pot_entry_vol: None, + pot_done: false, + pot_d_vals: Vec::new(), + pot_obs_done: false, + pot_enter_th: -1e9, + pot_exit_th: -1e9, + pot_raw_buf: Vec::new(), + pot_vol_buf: Vec::new(), + pot_sample_count: 0, + last_pot_result: None, + spectral, + spec_ep_vol: None, + spec_max_js: 0.0, + last_spec_diag: Default::default(), + last_spec_result: None, + kf: enable_kf.then(EndpointFusionKf::new), + kf_consumed: None, + last_reliability: Reliability { + status: "UNOBSERVABLE".into(), + data_quality: DataQualityInfo { + potential_samples: 0, + spectral_samples: 0, + valid_spectral_frames: 0, + baseline_ready: false, + repeated_spectral_volume: 0, + nonmonotonic_volume: 0, + last_frame: "no_spectrum".into(), + }, + potential_evidence: false, + spectral_evidence: false, + modal_consistency: ModalConsistency { + agreement_ml: None, + kf_consistent: None, + }, + event_maturity: 0.0, + spectral_events: 0, + spectral_superseded: 0, + endpoint_std: None, + spectral_delay: None, + nis: None, + innovation: None, + reason_codes: Vec::new(), + }, + }; + if let Some(axis) = det.spectrum_axis.clone() { + let _ = det.spectral.set_wavelengths(Some(&axis)); + } + det.reset_state(); + det + } + + fn reset_state(&mut self) { + self.pot_v_smooth = Ewma::new(POT_V_ALPHA); + self.pot_d_smooth = Ewma::new(POT_D_ALPHA); + self.pot_prev_v = None; + self.pot_prev_t = None; + self.pot_state = PotentialState::Idle; + self.pot_ep_vol = None; + self.pot_min_d = 0.0; + self.pot_cand_vol = None; + self.pot_entry_vol = None; + self.pot_done = false; + self.pot_d_vals.clear(); + self.pot_obs_done = false; + self.pot_enter_th = -1e9; + self.pot_exit_th = -1e9; + self.pot_raw_buf.clear(); + self.pot_vol_buf.clear(); + self.pot_sample_count = 0; + self.last_pot_result = None; + + self.spectral.reset(); + if let Some(axis) = self.spectrum_axis.clone() { + let _ = self.spectral.set_wavelengths(Some(&axis)); + } + self.spec_ep_vol = None; + self.spec_max_js = 0.0; + self.last_spec_diag = Default::default(); + self.last_spec_result = None; + + if let Some(kf) = self.kf.as_mut() { + kf.reset(); + } + self.kf_consumed = None; + self.last_reliability = self.build_reliability(None, None); + } + + /// 清空全部滤波器与状态,开始新滴定。 + pub fn reset(&mut self) { + self.reset_state(); + } + + pub fn set_spectrum_axis(&mut self, wavelengths: Option<&[f64]>) { + self.spectrum_axis = wavelengths.map(|w| w.to_vec()); + let _ = self.spectral.set_wavelengths(self.spectrum_axis.as_deref()); + } + + // ================================================================ + // 数据输入 + // ================================================================ + + /// 喂入一个电位点:体积 mL、时间 s、电压 V。 + pub fn feed_potential(&mut self, vol: f64, t: f64, v: f64) { + let vol = vol; + let t = t; + let v_sm = self.pot_v_smooth.push(v); + + let d_raw = match (self.pot_prev_t, self.pot_prev_v) { + (Some(pt), Some(pv)) if t - pt > 0.0 => (v_sm - pv) / (t - pt), + _ => 0.0, + }; + let d_sm = self.pot_d_smooth.push(d_raw); + self.pot_prev_v = Some(v_sm); + self.pot_prev_t = Some(t); + self.pot_sample_count += 1; + self.pot_raw_buf.push(d_raw); + self.pot_vol_buf.push(vol); + + if !self.pot_obs_done { + self.pot_d_vals.push(d_sm); + if vol >= POT_OBSERVE_VOL { + let arr = std::mem::take(&mut self.pot_d_vals); + if arr.len() < 3 { + self.pot_d_vals = arr; + return; + } + let n = arr.len() as f64; + let mean = super::divergence::np_sum(&arr) / n; + // ddof=1 样本标准差 + let var = super::divergence::np_sum( + &arr.iter() + .map(|x| (x - mean) * (x - mean)) + .collect::>(), + ) / (n - 1.0); + let std = var.sqrt().max(mean.abs() * 0.01).max(1e-6); + self.pot_enter_th = mean - POT_MIN_ENTER.max(POT_ENTER_SIGMA * std); + self.pot_exit_th = mean - POT_MIN_EXIT.max(POT_EXIT_SIGMA * std); + self.pot_obs_done = true; + self.pot_d_vals.clear(); + } + return; + } + + if !self.pot_done { + match self.pot_state { + PotentialState::Idle if d_sm < self.pot_enter_th => { + self.pot_state = PotentialState::Tracking; + self.pot_min_d = d_sm; + self.pot_cand_vol = Some(vol); + self.pot_entry_vol = Some(vol); + } + PotentialState::Tracking => { + if d_sm < self.pot_min_d { + self.pot_min_d = d_sm; + self.pot_cand_vol = Some(vol); + } + if d_sm > self.pot_exit_th + && self.pot_entry_vol.is_some() + && self.pot_cand_vol.is_some() + && vol - self.pot_entry_vol.unwrap() > POT_CONFIRM_VOL + { + let cand = self.pot_cand_vol.unwrap(); + self.pot_ep_vol = Some(cand); + self.pot_state = PotentialState::EndConfirmed; + self.pot_done = true; + } + } + _ => {} + } + } + } + + /// 喂入一帧原始通道或重建全谱。 + pub fn feed_spectrum(&mut self, vol: f64, spectrum: &[f64]) { + let diag = self.spectral.update(vol, spectrum); + self.last_spec_diag = diag; + // Python: max(self._spec_max_js, diag["max_js"]) + self.spec_max_js = self.spec_max_js.max(self.last_spec_diag.max_js); + let candidate = self.spectral.endpoint_volume(); + // tracker 报告迄今最强激变,更强事件可顶替早瞬态,候选会移动。 + if let Some(c) = candidate { + if Some(c) != self.spec_ep_vol { + self.spec_ep_vol = Some(c); + self.last_spec_result = None; // 结果重建 + } + } + } + + // ================================================================ + // 结果与可靠性 + // ================================================================ + + fn build_pot_result(&mut self) -> Option { + let vol = self.pot_ep_vol?; + let mut result = PotentialResult { + volume: vol, + time: vol / self.flow_rate, + min_dvdt: round2(self.pot_min_d), + state: self.pot_state, + endpoint_std: None, + nis: None, + innovation: None, + }; + if let Some(kf) = &self.kf { + let snap = kf.snapshot(); + result.endpoint_std = snap.endpoint_std; + result.nis = snap.nis; + result.innovation = snap.innovation; + } + self.last_pot_result = Some(result.clone()); + Some(result) + } + + fn build_spec_result(&mut self) -> Option { + let vol = self.spec_ep_vol?; + let diag = self.last_spec_diag.clone(); + let result = SpectralResult { + volume: vol, + time: vol / self.flow_rate, + max_ce: round8(self.spec_max_js), + max_js: round8(self.spec_max_js), + js_local: round8(diag.js_local), + js_speed: round8(diag.js_speed), + js_base: round8(diag.js_base), + cross_curvature: self.enable_curvature.then(|| round8(diag.cross_curvature)), + event_maturity: diag.event_maturity, + recovery_frames: diag.recovery_frames, + event_count: diag.event_count, + superseded_count: diag.superseded_count, + event_peak_speed: round8(diag.event_peak_speed), + state: diag.state, + }; + self.last_spec_result = Some(result.clone()); + Some(result) + } + + fn build_reliability( + &self, + pot: Option<&PotentialResult>, + spec: Option<&SpectralResult>, + ) -> Reliability { + let pot_confirmed = pot.is_some(); + let spec_confirmed = spec.is_some(); + let diagnostic = &self.last_spec_diag; + let kf_snap = self.kf.as_ref().map(|kf| kf.snapshot()); + + let status = if pot_confirmed && spec_confirmed { + match &kf_snap { + Some(snap) if snap.accepted && self.kf.as_ref().is_some_and(|k| k.can_fuse()) => { + "CONFIRMED" + } + _ => "CONFLICT", + } + } else if pot_confirmed || spec_confirmed { + if pot_confirmed && !self.enable_kf { + "CONFIRMED" + } else { + "CANDIDATE" + } + } else if self.pot_state == PotentialState::Tracking + || diagnostic.state == TrackerState::InChange + { + "CONFIRMING" + } else if self.pot_sample_count == 0 && diagnostic.sample_count == 0 { + "UNOBSERVABLE" + } else { + "EARLY_WARNING" + }; + + let mut reasons: Vec = Vec::new(); + if diagnostic.data_quality != "ok" && diagnostic.data_quality != "no_spectrum" { + reasons.push(diagnostic.data_quality.clone()); + } + if diagnostic.repeated_volume_count > 0 { + reasons.push("repeated_spectral_volume".into()); + } + if diagnostic.nonmonotonic_count > 0 { + reasons.push("nonmonotonic_volume".into()); + } + if let Some(kf) = &self.kf { + if pot_confirmed && spec_confirmed && !kf.can_fuse() { + reasons.push("kf_innovation_gate".into()); + } + } + if diagnostic.superseded_count > 0 { + reasons.push("spectral_endpoint_superseded".into()); + } + if !diagnostic.baseline_ready { + reasons.push("baseline_pending".into()); + } + + let agreement = match (pot, spec) { + (Some(p), Some(s)) => Some((p.volume - s.volume).abs()), + _ => None, + }; + + Reliability { + status: status.to_string(), + data_quality: DataQualityInfo { + potential_samples: self.pot_sample_count, + spectral_samples: diagnostic.sample_count, + valid_spectral_frames: self.spectral.valid_frame_count(), + baseline_ready: diagnostic.baseline_ready, + repeated_spectral_volume: diagnostic.repeated_volume_count, + nonmonotonic_volume: diagnostic.nonmonotonic_count, + last_frame: if diagnostic.sample_count == 0 && !diagnostic.valid_frame { + "no_spectrum".to_string() + } else { + diagnostic.data_quality.clone() + }, + }, + potential_evidence: pot_confirmed, + spectral_evidence: spec_confirmed, + modal_consistency: ModalConsistency { + agreement_ml: agreement, + kf_consistent: self.kf.as_ref().map(|k| k.can_fuse()), + }, + event_maturity: diagnostic.event_maturity, + spectral_events: diagnostic.event_count, + spectral_superseded: diagnostic.superseded_count, + endpoint_std: kf_snap.as_ref().and_then(|s| s.endpoint_std), + spectral_delay: kf_snap.as_ref().and_then(|s| s.spectral_delay), + nis: kf_snap.as_ref().and_then(|s| s.nis), + innovation: kf_snap.as_ref().and_then(|s| s.innovation), + reason_codes: reasons, + } + } + + /// 当前因果特征与可靠性诊断。 + pub fn diagnostics(&mut self) -> DetectorDiagnostics { + let pot = self.build_pot_result(); + let spec = self.build_spec_result(); + self.last_reliability = self.build_reliability(pot.as_ref(), spec.as_ref()); + DetectorDiagnostics { + potential_state: self.pot_state, + spectral_state: self.last_spec_diag.state, + potential: pot, + spectral: spec, + spectral_features: self.last_spec_diag.clone(), + kf: self.kf.as_ref().map(|kf| kf.snapshot()), + reliability: self.last_reliability.clone(), + } + } + + fn consume_kf_observations(&mut self, pot_vol: Option, spec_vol: Option) { + let Some(kf) = self.kf.as_mut() else { + return; + }; + let pair = (pot_vol, spec_vol); + if self.kf_consumed == Some(pair) { + return; + } + // 观测对变化(光谱顶替 / AMPD 精修)→ 重跑滤波器, + // 避免用陈旧状态门控修正值。 + kf.reset(); + if let Some(pv) = pot_vol { + let token = format!("potential@{pv}"); + kf.observe(ObservationKind::Potential, pv, Some(&token)); + } + if let Some(sv) = spec_vol { + let token = format!("spectral@{sv}"); + kf.observe(ObservationKind::Spectral, sv, Some(&token)); + } + self.kf_consumed = Some(pair); + } + + /// 返回向后兼容的终点结果(含诊断)。 + pub fn detect(&mut self) -> Option { + let mut pot = self.build_pot_result(); + let mut spec = self.build_spec_result(); + if pot.is_none() && spec.is_none() { + self.last_reliability = self.build_reliability(None, None); + return None; + } + + self.consume_kf_observations( + pot.as_ref().map(|p| p.volume), + spec.as_ref().map(|s| s.volume), + ); + // 消费首个观测后重建子结果,使导出的 NIS/std 描述刚消费的观测。 + pot = self.build_pot_result(); + spec = self.build_spec_result(); + let reliability = self.build_reliability(pot.as_ref(), spec.as_ref()); + self.last_reliability = reliability.clone(); + + match (pot, spec) { + (Some(pot), Some(spec)) => { + let can_fuse = self.kf.as_ref().is_some_and(|k| k.can_fuse()); + if can_fuse { + let volume = self.kf.as_ref().unwrap().endpoint_volume(); + Some(EndpointResult { + volume: round3(volume), + time: round3(volume / self.flow_rate), + confidence: Confidence::High, + method: Method::Consensus, + potential: Some(pot), + spectral: Some(spec), + warning: None, + reliability, + }) + } else if self.kf.is_none() && (pot.volume - spec.volume).abs() < 0.3 { + let volume = (pot.volume + spec.volume) / 2.0; + Some(EndpointResult { + volume: round3(volume), + time: round3((pot.time + spec.time) / 2.0), + confidence: Confidence::High, + method: Method::Consensus, + potential: Some(pot), + spectral: Some(spec), + warning: None, + reliability, + }) + } else { + Some(EndpointResult { + volume: round3(pot.volume), + time: round3(pot.time), + confidence: Confidence::Low, + method: Method::Conflict, + warning: Some(format!( + "电位{:.3}mL vs 光谱{:.3}mL 未通过创新一致性门控", + pot.volume, spec.volume + )), + potential: Some(pot), + spectral: Some(spec), + reliability, + }) + } + } + (Some(pot), None) => Some(EndpointResult { + volume: round3(pot.volume), + time: round3(pot.time), + confidence: Confidence::Medium, + method: Method::PotentialOnly, + potential: Some(pot), + spectral: None, + warning: None, + reliability, + }), + (None, Some(spec)) => Some(EndpointResult { + volume: round3(spec.volume), + time: round3(spec.time), + confidence: Confidence::Medium, + method: Method::SpectralOnly, + potential: None, + spectral: Some(spec), + warning: None, + reliability, + }), + (None, None) => None, + } + } + + /// 历史样本足够后用 AMPD 离线精修电位终点。 + pub fn refine_with_ampd(&mut self) -> Option { + if self.pot_raw_buf.len() < 20 { + return None; + } + let negated: Vec = self.pot_raw_buf.iter().map(|d| -d).collect(); + let idx = ampd_peak_idx(&negated)?; + if idx as f64 >= self.pot_vol_buf.len() as f64 * AMPD_MAX_POSITION { + return None; + } + let refined = self.pot_vol_buf[idx]; + self.pot_ep_vol = Some(refined); + Some(refined) + } + + pub fn potential_state(&self) -> PotentialState { + self.pot_state + } + + /// 电位通道终点体积(未确认时 `None`)。 + pub fn potential_endpoint_volume(&self) -> Option { + self.pot_ep_vol + } + + /// 最近一次 `feed_potential` 后的平滑导数(EWMA 后的 dV/dt)。 + pub fn last_potential_derivative(&self) -> f64 { + self.pot_d_smooth.hold() + } + + pub fn spectral_state(&self) -> TrackerState { + self.last_spec_diag.state + } + + pub fn endpoint_volume(&self) -> Option { + if self.kf.as_ref().is_some_and(|k| k.can_fuse()) { + return Some(self.kf.as_ref().unwrap().endpoint_volume()); + } + self.pot_ep_vol.or(self.spec_ep_vol) + } +} + +fn round2(x: f64) -> f64 { + round_to(x, 2) +} +fn round3(x: f64) -> f64 { + round_to(x, 3) +} +fn round8(x: f64) -> f64 { + round_to(x, 8) +} + +/// Python `round()` 的半偶舍入近似(f64::roundto 不可用时的等价实现)。 +fn round_to(x: f64, digits: i32) -> f64 { + let factor = 10f64.powi(digits); + (x * factor).round() / factor +} diff --git a/TController/crates/controller-core/src/processing/ewma.rs b/TController/crates/controller-core/src/processing/ewma.rs new file mode 100644 index 0000000..85bdcbf --- /dev/null +++ b/TController/crates/controller-core/src/processing/ewma.rs @@ -0,0 +1,52 @@ +//! 一阶因果指数移动平均。 + +/// 因果 EWMA(Python `_EWMA` / `_ScalarEWMA`)。 +#[derive(Debug, Clone, Copy)] +pub struct Ewma { + alpha: f64, + value: Option, +} + +impl Ewma { + pub fn new(alpha: f64) -> Self { + Self { + alpha: alpha.clamp(0.0, 1.0), + value: None, + } + } + + pub fn push(&mut self, x: f64) -> f64 { + self.value = Some(match self.value { + None => x, + Some(v) => self.alpha * x + (1.0 - self.alpha) * v, + }); + self.value.unwrap() + } + + /// 当前水平;未初始化返回 0.0(Python `hold()`)。 + pub fn hold(&self) -> f64 { + self.value.unwrap_or(0.0) + } + + pub fn value(&self) -> Option { + self.value + } + + pub fn reset(&mut self) { + self.value = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_sample_initializes() { + let mut e = Ewma::new(0.15); + assert_eq!(e.push(1.0), 1.0); + // 0.15·2 + 0.85·1 = 1.15 + approx::assert_relative_eq!(e.push(2.0), 1.15); + assert_eq!(e.hold(), 1.15); + } +} diff --git a/TController/crates/controller-core/src/processing/kf.rs b/TController/crates/controller-core/src/processing/kf.rs new file mode 100644 index 0000000..ce4f41a --- /dev/null +++ b/TController/crates/controller-core/src/processing/kf.rs @@ -0,0 +1,344 @@ +//! 终点融合 Kalman 滤波器 — Python `EndpointFusionKF` 的移植。 +//! +//! 两状态线性 KF:状态 = [终点体积, 光谱延迟]。 +//! 电位观测 H=[1,0];光谱观测 H=[1,1](光谱终点 = 终点 + 延迟)。 + +use std::collections::HashSet; + +use serde::Serialize; + +/// 观测模态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ObservationKind { + Potential, + Spectral, +} + +impl ObservationKind { + pub fn as_str(self) -> &'static str { + match self { + ObservationKind::Potential => "potential", + ObservationKind::Spectral => "spectral", + } + } +} + +/// NIS 门限:每次观测的新息为标量,卡方门 1 自由度,6.635 是 chi2(1) 的 99 分位。 +/// (旧值 9.21 是 chi2(2) 分位,属自由度错配。) +pub const DEFAULT_NIS_GATE: f64 = 6.635; + +/// `observe` 的结果快照(Python `_snapshot` 字段一一对应)。 +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct KfSnapshot { + pub initialized: bool, + pub endpoint_volume: Option, + pub spectral_delay: Option, + pub endpoint_std: Option, + pub innovation: Option, + pub innovation_covariance: Option, + pub nis: Option, + pub accepted: bool, + pub kind: Option, + pub consistent: bool, + pub reason: String, +} + +impl Default for KfSnapshot { + fn default() -> Self { + Self { + initialized: false, + endpoint_volume: None, + spectral_delay: None, + endpoint_std: None, + innovation: None, + innovation_covariance: None, + nis: None, + accepted: false, + kind: None, + consistent: false, + reason: "no_observation".into(), + } + } +} + +/// 两状态 KF。 +#[derive(Debug, Clone)] +pub struct EndpointFusionKf { + pub potential_var: f64, + pub spectral_var: f64, + pub delay_var: f64, + pub process_var: f64, + pub delay_prior: f64, + pub nis_gate: f64, + + x: [f64; 2], + p: [[f64; 2]; 2], + initialized: bool, + observed_potential: bool, + observed_spectral: bool, + tokens: HashSet, + last: KfSnapshot, +} + +impl Default for EndpointFusionKf { + fn default() -> Self { + Self::new() + } +} + +impl EndpointFusionKf { + pub fn new() -> Self { + Self::with_params( + 0.012, // potential_std + 0.025, // spectral_std + 0.08, // delay_std + 0.004, // process_std + 0.02, // delay_prior + DEFAULT_NIS_GATE, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn with_params( + potential_std: f64, + spectral_std: f64, + delay_std: f64, + process_std: f64, + delay_prior: f64, + nis_gate: f64, + ) -> Self { + let mut kf = Self { + potential_var: (potential_std * potential_std).max(1e-8), + spectral_var: (spectral_std * spectral_std).max(1e-8), + delay_var: (delay_std * delay_std).max(1e-8), + process_var: (process_std * process_std).max(1e-10), + delay_prior, + nis_gate: nis_gate.max(1.0), + x: [0.0; 2], + p: [[1e6, 0.0], [0.0, 1e6]], + initialized: false, + observed_potential: false, + observed_spectral: false, + tokens: HashSet::new(), + last: KfSnapshot::default(), + }; + kf.reset(); + kf + } + + pub fn reset(&mut self) { + self.x = [0.0; 2]; + self.p = [[1e6, 0.0], [0.0, 1e6]]; + self.initialized = false; + self.observed_potential = false; + self.observed_spectral = false; + self.tokens.clear(); + self.last = KfSnapshot::default(); + } + + fn prediction(&self) -> ([f64; 2], [[f64; 2]; 2]) { + if !self.initialized { + return (self.x, self.p); + } + let mut p = self.p; + p[0][0] += self.process_var; + p[1][1] += self.process_var; + (self.x, p) + } + + fn consistent(&self) -> bool { + self.observed_potential && self.observed_spectral + } + + fn make_snapshot( + &self, + kind: ObservationKind, + innovation: f64, + innovation_covariance: f64, + nis: f64, + accepted: bool, + reason: &str, + ) -> KfSnapshot { + KfSnapshot { + initialized: self.initialized, + endpoint_volume: Some(self.x[0]), + spectral_delay: Some(self.x[1]), + endpoint_std: Some(self.p[0][0].max(0.0).sqrt()), + innovation: Some(innovation), + innovation_covariance: Some(innovation_covariance), + nis: Some(nis), + accepted, + kind: Some(kind.as_str().to_string()), + consistent: self.consistent(), + reason: reason.to_string(), + } + } + + /// 消费一条终点观测;token 重复时幂等返回上次快照。 + pub fn observe( + &mut self, + kind: ObservationKind, + volume: f64, + token: Option<&str>, + ) -> KfSnapshot { + let token_owned = token + .map(str::to_string) + .unwrap_or_else(|| default_token(kind, volume)); + if self.tokens.contains(&token_owned) { + return self.last.clone(); + } + let z = volume; + if !z.is_finite() { + let mut snap = self.last.clone(); + snap.kind = Some(kind.as_str().to_string()); + snap.accepted = false; + snap.reason = "nonfinite_observation".into(); + self.last = snap; + return self.last.clone(); + } + + if !self.initialized { + match kind { + ObservationKind::Potential => { + self.x = [z, 0.0]; + self.p = [[self.potential_var, 0.0], [0.0, self.delay_var]]; + self.observed_potential = true; + } + ObservationKind::Spectral => { + self.x = [z - self.delay_prior, self.delay_prior]; + self.p = [ + [self.spectral_var + self.delay_var, 0.0], + [0.0, self.delay_var], + ]; + self.observed_spectral = true; + } + } + self.initialized = true; + self.tokens.insert(token_owned); + self.last = self.make_snapshot(kind, 0.0, self.p[0][0], 0.0, true, "initialized"); + return self.last.clone(); + } + + let (x_prior, p_prior) = self.prediction(); + let (h, r) = match kind { + ObservationKind::Potential => ([1.0, 0.0], self.potential_var), + ObservationKind::Spectral => ([1.0, 1.0], self.spectral_var), + }; + let h_x = h[0] * x_prior[0] + h[1] * x_prior[1]; + let innovation = z - h_x; + // S = h·P·hᵀ + R,按 NumPy (h@P)@h 的左结合顺序展开 + let hp = [ + h[0] * p_prior[0][0] + h[1] * p_prior[1][0], + h[0] * p_prior[0][1] + h[1] * p_prior[1][1], + ]; + let s = hp[0] * h[0] + hp[1] * h[1] + r; + let s = s.max(1e-10); + let nis = innovation * innovation / s; + let accepted = nis <= self.nis_gate; + + if accepted { + // K = P·hᵀ / S(P 对称,P·h = [P00·h0 + P01·h1, P10·h0 + P11·h1]) + let k = [ + (p_prior[0][0] * h[0] + p_prior[0][1] * h[1]) / s, + (p_prior[1][0] * h[0] + p_prior[1][1] * h[1]) / s, + ]; + self.x = [ + x_prior[0] + k[0] * innovation, + x_prior[1] + k[1] * innovation, + ]; + // P = (I − K⊗h)·P + let p00 = (1.0 - k[0] * h[0]) * p_prior[0][0] - k[0] * h[1] * p_prior[1][0]; + let p01 = (1.0 - k[0] * h[0]) * p_prior[0][1] - k[0] * h[1] * p_prior[1][1]; + let p10 = -k[1] * h[0] * p_prior[0][0] + (1.0 - k[1] * h[1]) * p_prior[1][0]; + let p11 = -k[1] * h[0] * p_prior[0][1] + (1.0 - k[1] * h[1]) * p_prior[1][1]; + // P ← 0.5·(P + Pᵀ):对角不变,非对角取平均 + self.p = [[p00, 0.5 * (p01 + p10)], [0.5 * (p10 + p01), p11]]; + match kind { + ObservationKind::Potential => self.observed_potential = true, + ObservationKind::Spectral => self.observed_spectral = true, + } + self.tokens.insert(token_owned); + } else { + self.x = x_prior; + self.p = p_prior; + } + self.last = self.make_snapshot( + kind, + innovation, + s, + nis, + accepted, + if accepted { "accepted" } else { "nis_gate" }, + ); + self.last.clone() + } + + pub fn snapshot(&self) -> KfSnapshot { + self.last.clone() + } + + /// 双模态均已接受观测且最近一次观测通过门控。 + pub fn can_fuse(&self) -> bool { + self.consistent() && self.last.accepted + } + + /// 融合后的终点体积(未融合时无意义)。 + pub fn endpoint_volume(&self) -> f64 { + self.x[0] + } +} + +fn default_token(kind: ObservationKind, volume: f64) -> String { + // Python 默认 token = (kind, round(volume, 9));格式化保留 9 位小数等价。 + format!("{}:{:.9}", kind.as_str(), volume) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_and_repeated_observation_are_stable() { + let mut kf = EndpointFusionKf::with_params(0.01, 0.01, 0.08, 0.004, 0.02, 3.84); + let initial = kf.observe(ObservationKind::Potential, 1.0, Some("potential-1")); + assert!(initial.accepted); + let accepted = kf.observe(ObservationKind::Spectral, 1.03, Some("spectral-1")); + assert!(accepted.accepted); + assert!(accepted.consistent); + let repeated = kf.observe(ObservationKind::Spectral, 1.03, Some("spectral-1")); + assert_eq!(repeated, accepted); + let rejected = kf.observe(ObservationKind::Potential, 2.0, Some("potential-2")); + assert!(!rejected.accepted); + assert_eq!(rejected.reason, "nis_gate"); + assert!(rejected.endpoint_std.unwrap().is_finite()); + } + + #[test] + fn reset_lets_a_revised_endpoint_pair_refuse() { + // 复刻 Paper/ExpData 回归:被顶替的光谱终点必须能重新融合, + // 而不是被去重 token 挡住。 + let mut kf = EndpointFusionKf::with_params(0.01, 0.01, 0.08, 0.004, 0.02, DEFAULT_NIS_GATE); + kf.observe(ObservationKind::Potential, 2.1475, Some("potential@2.1475")); + let stale = kf.observe(ObservationKind::Spectral, 1.1805, Some("spectral@1.1805")); + assert!(!stale.accepted); + assert_eq!(stale.reason, "nis_gate"); + assert!(!kf.can_fuse()); + + kf.reset(); + kf.observe(ObservationKind::Potential, 2.1475, Some("potential@2.1475")); + let revised = kf.observe(ObservationKind::Spectral, 2.1489, Some("spectral@2.1489")); + assert!(revised.accepted); + assert!(revised.consistent); + assert!(kf.can_fuse()); + } + + #[test] + fn nonfinite_observation_is_rejected_not_corrupting() { + let mut kf = EndpointFusionKf::new(); + let snap = kf.observe(ObservationKind::Potential, f64::NAN, None); + assert!(!snap.accepted); + assert_eq!(snap.reason, "nonfinite_observation"); + assert!(!kf.initialized); + } +} diff --git a/TController/crates/controller-core/src/processing/mod.rs b/TController/crates/controller-core/src/processing/mod.rs new file mode 100644 index 0000000..3886baa --- /dev/null +++ b/TController/crates/controller-core/src/processing/mod.rs @@ -0,0 +1,18 @@ +//! 数据处理层 — Python `DataProcessor` 包的移植。 + +pub mod ampd; +pub mod calibration; +pub mod divergence; +pub mod endpoint; +pub mod ewma; +pub mod kf; +pub mod reconstructor; +pub mod savgol; +pub mod tracker; + +pub use calibration::{PumpCalibration, DEFAULT_PUMP_SLOPE, PUMP_STEP_FREQ}; +pub use endpoint::{ + Confidence, DetectorDiagnostics, EndpointDetector, EndpointResult, Method, Reliability, +}; +pub use reconstructor::{ReconError, Reconstructor}; +pub use tracker::{SpectralFeatureTracker, TrackerState}; diff --git a/TController/crates/controller-core/src/processing/reconstructor.rs b/TController/crates/controller-core/src/processing/reconstructor.rs new file mode 100644 index 0000000..1dbca01 --- /dev/null +++ b/TController/crates/controller-core/src/processing/reconstructor.rs @@ -0,0 +1,160 @@ +//! AS7341 10 通道 → 全光谱重建 — Python `reconstructor.py` 的移植。 +//! +//! ams-OSRAM Golden Device 校准矩阵(`calibre.npz`): +//! `corrected = factor × max(raw − offset, 0)`, +//! `spectrum[λ] = Σ_ch corrected[ch] × matrix[λ, ch]`(380–1100 nm,1 nm 步长,721 点)。 + +use std::path::{Path, PathBuf}; + +use ndarray::Array2; +use ndarray_npy::NpzReader; +use thiserror::Error; + +/// 重建错误。 +#[derive(Debug, Error)] +pub enum ReconError { + #[error("光谱校准数据未找到: {0}")] + FileNotFound(PathBuf), + #[error("校准数据读取失败: {0}")] + Read(String), + #[error("需要 10 通道数据,传入长度为 {0}")] + BadChannelCount(usize), + #[error("原始通道值不应包含负数")] + NegativeValue, + #[error("原始通道值包含 NaN 或 Inf")] + NonFinite, +} + +/// Golden Device 校准数据(数值部分)。 +#[derive(Debug, Clone)] +pub struct Reconstructor { + /// 380–1100 nm(721 点)。 + pub wavelengths: Vec, + /// (721, 10) 重建矩阵。 + pub matrix: Array2, + pub offsets: Vec, + pub factors: Vec, +} + +impl Reconstructor { + /// 从 `calibre.npz` 加载数值键(对象键如电极元数据不在此层处理)。 + pub fn load(path: &Path) -> Result { + if !path.is_file() { + return Err(ReconError::FileNotFound(path.to_path_buf())); + } + let file = std::fs::File::open(path) + .map_err(|e| ReconError::Read(format!("{}: {e}", path.display())))?; + let mut npz = NpzReader::new(file).map_err(|e| ReconError::Read(e.to_string()))?; + + let read_f64_1d = + |npz: &mut NpzReader, key: &str| -> Result, ReconError> { + let arr: ndarray::Array1 = npz + .by_name(key) + .map_err(|e| ReconError::Read(format!("{key}: {e}")))?; + Ok(arr.to_vec()) + }; + + let matrix: ndarray::Array2 = npz + .by_name("spectral_matrix") + .map_err(|e| ReconError::Read(format!("spectral_matrix: {e}")))?; + let wl_i32: ndarray::Array1 = npz + .by_name("spectral_wavelengths") + .map_err(|e| ReconError::Read(format!("spectral_wavelengths: {e}")))?; + let offsets = read_f64_1d(&mut npz, "spectral_offsets")?; + let factors = read_f64_1d(&mut npz, "spectral_factors")?; + + Ok(Self { + wavelengths: wl_i32.iter().map(|&v| v as f64).collect(), + matrix, + offsets, + factors, + }) + } + + /// 按开发/打包规则探测 `calibre.npz`: + /// 1. 环境变量 `AUTOTITRATOR_CALIBRE`; + /// 2. 可执行文件同级目录; + /// 3. 开发模式:最终 workspace 的 `TController/data/`(编译期锚定)。 + pub fn discover() -> Result<(Self, PathBuf), ReconError> { + let candidates: Vec = { + let mut v = Vec::new(); + if let Ok(p) = std::env::var("AUTOTITRATOR_CALIBRE") { + v.push(PathBuf::from(p)); + } + v.push( + std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|d| d.join("calibre.npz"))) + .unwrap_or_default(), + ); + v.push(PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../TController/data/calibre.npz" + ))); + v + }; + for path in candidates { + if path.is_file() { + return Ok((Self::load(&path)?, path)); + } + } + Err(ReconError::FileNotFound(PathBuf::from("calibre.npz"))) + } + + /// 从 10 通道原始 ADC 值重建全光谱,返回 `(波长, 谱)`。 + pub fn reconstruct(&self, raw: &[f64]) -> Result<(Vec, Vec), ReconError> { + if raw.len() != 10 { + return Err(ReconError::BadChannelCount(raw.len())); + } + if raw.iter().any(|&v| v < 0.0) { + return Err(ReconError::NegativeValue); + } + if !raw.iter().all(|v| v.is_finite()) { + return Err(ReconError::NonFinite); + } + let corrected: Vec = raw + .iter() + .zip(&self.offsets) + .zip(&self.factors) + .map(|((&r, &o), &f)| f * (r - o).max(0.0)) + .collect(); + // spectrum = max(matrix @ corrected, 0) + let mut spectrum = vec![0.0f64; self.matrix.nrows()]; + for (row_out, row) in spectrum.iter_mut().zip(self.matrix.rows()) { + *row_out = row + .iter() + .zip(&corrected) + .map(|(&m, &c)| m * c) + .sum::() + .max(0.0); + } + Ok((self.wavelengths.clone(), spectrum)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discover_and_reconstruct_roundtrip() { + let (rec, path) = Reconstructor::discover().expect("calibre.npz"); + assert!(path.is_file()); + assert_eq!(rec.wavelengths.len(), 721); + assert_eq!(rec.matrix.dim(), (721, 10)); + + let raw = [1000.0f64; 10]; + let (wls, spectrum) = rec.reconstruct(&raw).unwrap(); + assert_eq!(wls.len(), spectrum.len()); + assert!(spectrum.iter().all(|v| v.is_finite() && *v >= 0.0)); + + assert!(matches!( + rec.reconstruct(&[1.0; 10][..9]), + Err(ReconError::BadChannelCount(9)) + )); + assert!(matches!( + rec.reconstruct(&[-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), + Err(ReconError::NegativeValue) + )); + } +} diff --git a/TController/crates/controller-core/src/processing/savgol.rs b/TController/crates/controller-core/src/processing/savgol.rs new file mode 100644 index 0000000..da5b0b7 --- /dev/null +++ b/TController/crates/controller-core/src/processing/savgol.rs @@ -0,0 +1,125 @@ +//! Savitzky-Golay 平滑(对称窗口,离线用)— Python `savgol_filter` 的移植。 + +/// 最小二乘系数:`coeffs = solve(AᵀA, Aᵀ)[0]`,A 为范德蒙德矩阵(升幂)。 +/// +/// 即 X = (AᵀA)⁻¹·Aᵀ 的第 0 行(长度 = window)。实现上先解 +/// `AᵀA·y = e₀`,再由 `coeffs[i] = Σ_j y[j]·x_i^j` 展开回长度 window。 +pub fn savgol_coeffs(window: usize, order: usize) -> Result, String> { + if window.is_multiple_of(2) { + return Err(format!("window 必须为奇数,得到 {window}")); + } + let half = window / 2; + let m = order + 1; + + // A[i][j] = x_i^j, x_i ∈ [-half, half] + let mut ata = vec![vec![0.0; m]; m]; + let mut xs = Vec::with_capacity(window); + for i in -(half as i32)..=(half as i32) { + let x = i as f64; + xs.push(x); + let row: Vec = (0..m).map(|j| x.powi(j as i32)).collect(); + for a in 0..m { + for b in 0..m { + ata[a][b] += row[a] * row[b]; + } + } + } + // 解 AᵀA·y = e₀ + let mut rhs = vec![0.0; m]; + rhs[0] = 1.0; + let y = solve_linear(&mut ata, rhs).ok_or("savgol: 奇异法方程矩阵")?; + // coeffs[i] = Σ_j y[j]·x_i^j = (X 的第 0 行) + Ok(xs + .iter() + .map(|&x| (0..m).map(|j| y[j] * x.powi(j as i32)).sum::()) + .collect()) +} + +/// 对信号做边缘填充后按系数卷积(等价 `np.convolve(padded, coeffs[::-1], 'valid')`)。 +pub fn savgol_filter(signal: &[f64], window: usize, order: usize) -> Result, String> { + if signal.is_empty() { + return Ok(Vec::new()); + } + let coeffs = savgol_coeffs(window, order)?; + let half = window / 2; + + let mut padded = Vec::with_capacity(signal.len() + 2 * half); + padded.extend(std::iter::repeat_n(signal[0], half)); + padded.extend_from_slice(signal); + padded.extend(std::iter::repeat_n(*signal.last().unwrap(), half)); + + // valid 卷积(核已反转)= 与 coeffs 的互相关 + Ok((0..signal.len()) + .map(|i| (0..window).map(|k| coeffs[k] * padded[i + k]).sum::()) + .collect()) +} + +/// 高斯消元(部分主元)解小型稠密线性方程组。 +fn solve_linear(a: &mut [Vec], mut b: Vec) -> Option> { + let n = a.len(); + for col in 0..n { + // 选主元 + let pivot = + (col..n).max_by(|&i, &j| a[i][col].abs().partial_cmp(&a[j][col].abs()).unwrap())?; + if a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + + let div = a[col][col]; + for j in col..n { + a[col][j] /= div; + } + b[col] /= div; + + for i in (col + 1)..n { + let factor = a[i][col]; + if factor == 0.0 { + continue; + } + for j in col..n { + a[i][j] -= factor * a[col][j]; + } + b[i] -= factor * b[col]; + } + } + // 回代 + for i in (0..n).rev() { + for j in (i + 1)..n { + b[i] -= a[i][j] * b[j]; + } + } + Some(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_signal_is_unchanged() { + let y = vec![5.0; 20]; + let out = savgol_filter(&y, 5, 2).unwrap(); + for v in out { + approx::assert_relative_eq!(v, 5.0, epsilon = 1e-9); + } + } + + #[test] + fn linear_signal_is_unchanged_for_order_2() { + let y: Vec = (0..20).map(|i| i as f64 * 0.5 + 1.0).collect(); + let out = savgol_filter(&y, 7, 2).unwrap(); + // edge 填充 + valid 卷积下,边缘半窗口有多项式拟合偏差 + // (Python np.pad('edge') 版本同样如此);内部点应精确还原线性信号。 + for (i, v) in out.iter().enumerate().skip(3).take(y.len() - 6) { + approx::assert_relative_eq!(*v, y[i], epsilon = 1e-9); + } + assert!(out.iter().all(|v| v.is_finite())); + } + + #[test] + fn even_window_is_rejected() { + assert!(savgol_coeffs(4, 2).is_err()); + } +} diff --git a/TController/crates/controller-core/src/processing/tracker.rs b/TController/crates/controller-core/src/processing/tracker.rs new file mode 100644 index 0000000..0629dd5 --- /dev/null +++ b/TController/crates/controller-core/src/processing/tracker.rs @@ -0,0 +1,604 @@ +//! 因果光谱特征追踪器 — Python `SpectralFeatureTracker` 的移植。 +//! +//! 两个必须知道的行为(源自 Python 文档): +//! +//! * 体积归一化速度锚定到最后一个*前进*帧而非上一帧。生产中固件每 AS7341 +//! 帧上报一帧光谱而体积来自泵,多帧共享同一体积;把零步长喂进速度滤波会 +//! 注入 0 并淹没真实事件,所以体积静止时速度滤波器*保持*电平。 +//! * `END_CONFIRMED` 可重入。激变记录进 `events`,报告的终点是最强事件, +//! 只有后续事件强 `supersede_ratio` 倍才顶替。一次性闩锁曾在真实数据 +//! (Paper/ExpData B 组)上把早于真终点 0.97 mL 的瞬态锁成终点, +//! Kalman 门只能拒绝、无法修复。 + +use std::collections::VecDeque; + +use serde::Serialize; + +use super::divergence::{cross_entropy_excess, finite_vector, js_divergence, EPS, JS_FLOOR}; +use super::ewma::Ewma; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TrackerState { + Idle, + InChange, + EndConfirmed, +} + +impl TrackerState { + pub fn as_str(self) -> &'static str { + match self { + TrackerState::Idle => "IDLE", + TrackerState::InChange => "IN_CHANGE", + TrackerState::EndConfirmed => "END_CONFIRMED", + } + } +} + +/// 一次完成的激变事件(Python `events` 列表元素)。 +#[derive(Debug, Clone, Copy, Serialize)] +pub struct ExcursionEvent { + pub candidate_volume: f64, + pub entry_volume: f64, + pub peak_speed: f64, + pub peak_js: f64, +} + +/// 每帧诊断(Python `_last` 字典字段一一对应,JSON 字段名保持 snake_case)。 +#[derive(Debug, Clone, Serialize)] +pub struct Diagnostics { + pub sample_count: usize, + pub valid_frame: bool, + pub data_quality: String, + pub volume: Option, + pub delta_volume: f64, + pub volume_sync_valid: bool, + pub js_local: f64, + pub js_local_smooth: f64, + pub js_speed: f64, + pub js_speed_smooth: f64, + pub js_base: f64, + pub cross_curvature: f64, + pub curvature_peak_channel: Option, + pub state: TrackerState, + pub candidate_volume: Option, + pub max_js: f64, + pub event_maturity: f64, + pub recovery_frames: usize, + pub baseline_ready: bool, + pub repeated_volume_count: usize, + pub nonmonotonic_count: usize, + pub event_count: usize, + pub superseded_count: usize, + pub event_peak_speed: f64, +} + +impl Default for Diagnostics { + fn default() -> Self { + Self { + sample_count: 0, + valid_frame: false, + data_quality: "no_spectrum".into(), + volume: None, + delta_volume: 0.0, + volume_sync_valid: false, + js_local: 0.0, + js_local_smooth: 0.0, + js_speed: 0.0, + js_speed_smooth: 0.0, + js_base: 0.0, + cross_curvature: 0.0, + curvature_peak_channel: None, + state: TrackerState::Idle, + candidate_volume: None, + max_js: 0.0, + event_maturity: 0.0, + recovery_frames: 0, + baseline_ready: false, + repeated_volume_count: 0, + nonmonotonic_count: 0, + event_count: 0, + superseded_count: 0, + event_peak_speed: 0.0, + } + } +} + +/// 追踪器配置与状态。 +#[derive(Debug, Clone)] +pub struct SpectralFeatureTracker { + // 配置 + pub alpha: f64, + pub js_enter: f64, + pub js_exit: f64, + pub baseline_enter: f64, + pub baseline_frames: usize, + pub baseline_max_volume: f64, + pub confirm_frames: usize, + pub min_event_volume: f64, + pub epsilon_volume: f64, + pub lookback_frames: usize, + pub supersede_ratio: f64, + pub js_floor: f64, + pub use_jsd: bool, + + // 波长轴(严格递增;None → 用通道下标) + configured_axis: Option>, + + // 状态 + smoothed: Option>, + previous_volume: Option, + sync_spectrum: Option>, + sync_volume: Option, + baseline_sum: Option>, + baseline_count: usize, + baseline: Option>, + frame_count: usize, + valid_frame_count: usize, + invalid_frame_count: usize, + nonmonotonic_count: usize, + repeated_volume_count: usize, + state: TrackerState, + candidate_volume: Option, + entry_volume: Option, + peak_value: f64, + peak_js: f64, + recovery_frames: usize, + recent: VecDeque<(f64, f64)>, + events: Vec, + best_event: Option, + supersede_count: usize, + last: Diagnostics, + js_smooth: Ewma, + speed_smooth: Ewma, + curvature_smooth: Ewma, +} + +impl Default for SpectralFeatureTracker { + fn default() -> Self { + Self::new() + } +} + +impl SpectralFeatureTracker { + pub fn new() -> Self { + Self::with_params( + 0.20, // alpha + 0.05, // js_enter + 0.008, // js_exit + 3e-7, // baseline_enter + 12, // baseline_frames + 0.30, // baseline_max_volume + 4, // confirm_frames + 0.08, // min_event_volume + 1e-8, // epsilon_volume + 8, // lookback_frames + 1.5, // supersede_ratio + JS_FLOOR, true, // use_jsd + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn with_params( + alpha: f64, + js_enter: f64, + js_exit: f64, + baseline_enter: f64, + baseline_frames: usize, + baseline_max_volume: f64, + confirm_frames: usize, + min_event_volume: f64, + epsilon_volume: f64, + lookback_frames: usize, + supersede_ratio: f64, + js_floor: f64, + use_jsd: bool, + ) -> Self { + let mut t = Self { + alpha, + js_enter, + js_exit, + baseline_enter, + baseline_frames: baseline_frames.max(3), + baseline_max_volume, + confirm_frames: confirm_frames.max(1), + min_event_volume, + epsilon_volume: epsilon_volume.max(1e-12), + lookback_frames: lookback_frames.max(1), + supersede_ratio: supersede_ratio.max(1.0), + js_floor: js_floor.max(0.0), + use_jsd, + configured_axis: None, + smoothed: None, + previous_volume: None, + sync_spectrum: None, + sync_volume: None, + baseline_sum: None, + baseline_count: 0, + baseline: None, + frame_count: 0, + valid_frame_count: 0, + invalid_frame_count: 0, + nonmonotonic_count: 0, + repeated_volume_count: 0, + state: TrackerState::Idle, + candidate_volume: None, + entry_volume: None, + peak_value: 0.0, + peak_js: 0.0, + recovery_frames: 0, + recent: VecDeque::new(), + events: Vec::new(), + best_event: None, + supersede_count: 0, + last: Diagnostics::default(), + js_smooth: Ewma::new(alpha), + speed_smooth: Ewma::new(alpha), + curvature_smooth: Ewma::new(alpha), + }; + t.reset_state(); + t + } + + fn reset_state(&mut self) { + self.smoothed = None; + self.previous_volume = None; + self.sync_spectrum = None; + self.sync_volume = None; + self.baseline_sum = None; + self.baseline_count = 0; + self.baseline = None; + self.frame_count = 0; + self.valid_frame_count = 0; + self.invalid_frame_count = 0; + self.nonmonotonic_count = 0; + self.repeated_volume_count = 0; + self.state = TrackerState::Idle; + self.candidate_volume = None; + self.entry_volume = None; + self.peak_value = 0.0; + self.peak_js = 0.0; + self.recovery_frames = 0; + self.recent.clear(); + self.events.clear(); + self.best_event = None; + self.supersede_count = 0; + self.last = Diagnostics::default(); + self.js_smooth.reset(); + self.speed_smooth.reset(); + self.curvature_smooth.reset(); + } + + /// 清空历史但保留配置与波长轴。 + pub fn reset(&mut self) { + self.reset_state(); + } + + /// 设置波长轴(交叉曲率用);必须 ≥2 个有限值且严格递增。 + pub fn set_wavelengths(&mut self, wavelengths: Option<&[f64]>) -> Result<(), String> { + let Some(axis) = wavelengths else { + self.configured_axis = None; + return Ok(()); + }; + if axis.len() < 2 || !axis.iter().all(|v| v.is_finite()) { + return Err("wavelength axis must contain at least two finite values".into()); + } + if axis.windows(2).any(|w| w[1] <= w[0]) { + return Err("wavelength axis must be strictly increasing".into()); + } + self.configured_axis = Some(axis.to_vec()); + Ok(()) + } + + pub fn last(&self) -> &Diagnostics { + &self.last + } + + pub fn valid_frame_count(&self) -> usize { + self.valid_frame_count + } + + pub fn events(&self) -> &[ExcursionEvent] { + &self.events + } + + /// 迄今最强已确认事件的候选体积。 + pub fn endpoint_volume(&self) -> Option { + self.best_event.map(|e| e.candidate_volume) + } + + fn axis_for(&self, size: usize) -> Vec { + match &self.configured_axis { + Some(axis) if axis.len() == size => axis.clone(), + _ => (0..size).map(|i| i as f64).collect(), + } + } + + fn divergence(&self, p: &[f64], q: &[f64]) -> f64 { + if self.use_jsd { + js_divergence(p, q) + } else { + cross_entropy_excess(p, q) + } + } + + /// 最近因果窗口内的最强 (速度, 体积)。 + /// + /// 速度滤波滞后于底层激变,首个越过 `js_enter` 的帧可能已在短瞬态的 + /// 下降沿上;用保留窗口播种峰值,使候选落在真实最大值而非穿越点。 + fn lookback_peak(&self, volume: f64, speed: f64) -> (f64, f64) { + let mut peak_speed = speed; + let mut peak_volume = volume; + for &(past_volume, past_speed) in &self.recent { + if past_speed > peak_speed { + peak_speed = past_speed; + peak_volume = past_volume; + } + } + (peak_speed, peak_volume) + } + + /// 记录完成的激变并保留最强者(带滞回:近持平不顶替)。 + fn commit_event(&mut self) { + let Some(candidate) = self.candidate_volume else { + return; + }; + let event = ExcursionEvent { + candidate_volume: candidate, + entry_volume: self.entry_volume.unwrap_or(candidate), + peak_speed: self.peak_value, + peak_js: self.peak_js, + }; + self.events.push(event); + match self.best_event { + None => self.best_event = Some(event), + Some(best) if event.peak_speed > best.peak_speed * self.supersede_ratio => { + // 后发事件须明显更强才接管,报告终点不抖动。 + self.best_event = Some(event); + self.supersede_count += 1; + } + _ => {} + } + } + + /// 消费一帧光谱,返回因果诊断。 + pub fn update(&mut self, volume: f64, spectrum: &[f64]) -> Diagnostics { + self.frame_count += 1; + let volume = volume; + + let normalized = match finite_vector(spectrum) { + Ok(n) => n, + Err(reason) => { + self.invalid_frame_count += 1; + let mut diag = self.last.clone(); + diag.sample_count = self.frame_count; + diag.valid_frame = false; + diag.data_quality = reason.to_string(); + diag.volume = Some(volume); + diag.baseline_ready = self.baseline.is_some(); + diag.repeated_volume_count = self.repeated_volume_count; + diag.nonmonotonic_count = self.nonmonotonic_count; + self.last = diag.clone(); + return diag; + } + }; + self.valid_frame_count += 1; + + let mut smoothed = match &self.smoothed { + None => normalized, + Some(prev) => normalized + .iter() + .zip(prev) + .map(|(&n, &p)| self.alpha * n + (1.0 - self.alpha) * p) + .collect(), + }; + let denom = super::divergence::np_sum(&smoothed).max(EPS); // Python: smoothed / max(np.sum(smoothed), _EPS) + for v in smoothed.iter_mut() { + *v /= denom; + } + + // 体积步长分类 + let (delta_volume, sync_valid) = match self.previous_volume { + None => (0.0, false), + Some(prev) => { + let dv = volume - prev; + if dv > self.epsilon_volume { + (dv, true) + } else if dv.abs() <= self.epsilon_volume { + self.repeated_volume_count += 1; + (dv, false) + } else { + self.nonmonotonic_count += 1; + (dv, false) + } + } + }; + + // 帧间 JS 仅作诊断:帧重复体积时未归一化,不能驱动事件。 + let local = match &self.smoothed { + Some(prev) => self.divergence(&smoothed, prev), + None => 0.0, + }; + let local_smooth = self.js_smooth.push(local); + + // ---- 体积归一化速度(锚定最后前进帧)---- + let mut speed_raw = 0.0f64; + let mut curvature = 0.0f64; + let mut peak_channel: Option = None; + let anchor_delta = self.sync_volume.map_or(0.0, |sv| volume - sv); + let mut advance_sync = false; + + if let Some(sync) = self.sync_spectrum.as_ref() { + if anchor_delta > self.epsilon_volume { + let anchor_js = self.divergence(&smoothed, sync); + if anchor_js > self.js_floor { + speed_raw = anchor_js / (anchor_delta * anchor_delta); + } + let shape_gradient: Vec = smoothed + .iter() + .zip(sync.iter()) + .map(|(&s, &y)| (s.max(1e-12).ln() - y.max(1e-12).ln()) / anchor_delta) + .collect(); + let axis = self.axis_for(smoothed.len()); + if axis.len() == shape_gradient.len() && shape_gradient.len() >= 3 { + let grad = gradient_nonuniform(&shape_gradient, &axis); + curvature = (super::divergence::np_sum( + &grad.iter().map(|g| g * g).collect::>(), + ) / grad.len() as f64) + .sqrt(); + peak_channel = grad + .iter() + .enumerate() + .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap()) + .map(|(i, _)| i); + } + self.speed_smooth.push(speed_raw); + self.curvature_smooth.push(curvature); + advance_sync = true; + } + } else { + // 首帧:建立体积锚 + advance_sync = true; + } + if advance_sync { + self.sync_spectrum = Some(smoothed.clone()); + self.sync_volume = Some(volume); + } + let speed_smooth = self.speed_smooth.hold(); + let curvature_smooth = self.curvature_smooth.hold(); + + // ---- 基线 ---- + if self.baseline.is_none() && volume <= self.baseline_max_volume { + if self.baseline_sum.is_none() { + self.baseline_sum = Some(vec![0.0; smoothed.len()]); + } + if let Some(bs) = self.baseline_sum.as_mut() { + for (acc, v) in bs.iter_mut().zip(&smoothed) { + *acc += v; + } + } + self.baseline_count += 1; + if self.baseline_count >= self.baseline_frames { + let count = self.baseline_count as f64; + let mut base: Vec = self + .baseline_sum + .take() + .expect("baseline_sum checked") + .into_iter() + .map(|v| v / count) + .collect(); + let total: f64 = super::divergence::np_sum(&base).max(EPS); + for v in base.iter_mut() { + *v /= total; + } + self.baseline = Some(base); + } + } + + let base_js = match &self.baseline { + None => 0.0, + Some(base) => js_divergence(&smoothed, base), + }; + + // ---- 状态机(END_CONFIRMED 可重入)---- + if self.baseline.is_some() { + match self.state { + TrackerState::Idle | TrackerState::EndConfirmed => { + if speed_smooth >= self.js_enter && base_js >= self.baseline_enter { + let (peak_speed, peak_volume) = self.lookback_peak(volume, speed_smooth); + self.state = TrackerState::InChange; + self.entry_volume = Some(volume); + self.candidate_volume = Some(peak_volume); + self.peak_value = peak_speed; + self.peak_js = local; + self.recovery_frames = 0; + } + } + TrackerState::InChange => { + if speed_smooth > self.peak_value { + self.peak_value = speed_smooth; + self.peak_js = local; + self.candidate_volume = Some(volume); + self.recovery_frames = 0; + } else if speed_smooth <= self.js_exit { + self.recovery_frames += 1; + if let Some(entry) = self.entry_volume { + if volume - entry >= self.min_event_volume + && self.recovery_frames >= self.confirm_frames + { + self.state = TrackerState::EndConfirmed; + self.commit_event(); + } + } + } else { + self.recovery_frames = 0; + } + } + } + } + + let maturity = match self.state { + TrackerState::InChange => { + (self.recovery_frames as f64 / self.confirm_frames as f64).min(0.99) + } + TrackerState::EndConfirmed => 1.0, + TrackerState::Idle => 0.0, + }; + + let best = self.best_event; + self.smoothed = Some(smoothed); + self.previous_volume = Some(volume); + self.recent.push_back((volume, speed_smooth)); + while self.recent.len() > self.lookback_frames { + self.recent.pop_front(); + } + + self.last = Diagnostics { + sample_count: self.frame_count, + valid_frame: true, + data_quality: "ok".into(), + volume: Some(volume), + delta_volume, + volume_sync_valid: sync_valid, + js_local: local, + js_local_smooth: local_smooth, + js_speed: speed_raw, + js_speed_smooth: speed_smooth, + js_base: base_js, + cross_curvature: curvature_smooth, + curvature_peak_channel: peak_channel, + state: self.state, + candidate_volume: best.map(|e| e.candidate_volume), + max_js: best.map_or(self.peak_js, |e| e.peak_js), + event_maturity: maturity, + recovery_frames: self.recovery_frames, + baseline_ready: self.baseline.is_some(), + repeated_volume_count: self.repeated_volume_count, + nonmonotonic_count: self.nonmonotonic_count, + event_count: self.events.len(), + superseded_count: self.supersede_count, + event_peak_speed: best.map_or(0.0, |e| e.peak_speed), + }; + self.last.clone() + } +} + +/// 非均匀间距一阶差分(`np.gradient(y, x)` 语义: +/// 内部中心差分,端点单侧差分)。 +pub fn gradient_nonuniform(y: &[f64], x: &[f64]) -> Vec { + let n = y.len(); + debug_assert_eq!(n, x.len()); + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![0.0]; + } + let mut out = vec![0.0; n]; + out[0] = (y[1] - y[0]) / (x[1] - x[0]); + out[n - 1] = (y[n - 1] - y[n - 2]) / (x[n - 1] - x[n - 2]); + for i in 1..n - 1 { + out[i] = (y[i + 1] - y[i - 1]) / (x[i + 1] - x[i - 1]); + } + out +} diff --git a/TController/crates/controller-core/src/protocol/crc.rs b/TController/crates/controller-core/src/protocol/crc.rs new file mode 100644 index 0000000..5ca1cbd --- /dev/null +++ b/TController/crates/controller-core/src/protocol/crc.rs @@ -0,0 +1,38 @@ +//! CRC-8(Maxim-Dallas 变体,多项式 0x31)— 与固件 `CommandDispatcher` 一致。 + +#[inline] +fn crc8_update(crc: u8, data: u8) -> u8 { + let mut crc = crc ^ data; + for _ in 0..8 { + crc = if crc & 0x80 != 0 { + (crc << 1) ^ 0x31 + } else { + crc << 1 + }; + } + crc +} + +/// 计算字节流的 CRC-8(初值 0)。 +pub fn crc8(data: &[u8]) -> u8 { + let mut crc: u8 = 0; + for &b in data { + crc = crc8_update(crc, b); + } + crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_python_reference_vectors() { + // 与 Python `_crc8` 实测一致的参照向量 + assert_eq!(crc8(&[]), 0x00); + assert_eq!(crc8(&[0x00, 0x01]), 0x31); + let mut v = vec![0x20]; + v.extend_from_slice(&(0u8..11).collect::>()); + assert_eq!(crc8(&v), 0xCA); + } +} diff --git a/TController/crates/controller-core/src/protocol/frames.rs b/TController/crates/controller-core/src/protocol/frames.rs new file mode 100644 index 0000000..d897e44 --- /dev/null +++ b/TController/crates/controller-core/src/protocol/frames.rs @@ -0,0 +1,250 @@ +//! 上/下行帧定义与编解码。 +//! +//! 上行帧(MCU → Host):`AA 55 | 类型(1B) | 数据(NB) | CRC8(类型+数据)` +//! 下行帧(Host → MCU):`BB 55 | 命令(1B) | 参数(NB) | CRC8(命令+参数)` + +use super::crc::crc8; + +/// 上行帧类型 → 载荷长度(与固件协议表一致)。 +pub fn uplink_payload_len(frame_type: u8) -> Option { + Some(match frame_type { + 0x00 => 1, // ACK — echo_cmd(1) + 0x01 => 1, // NAK — echo_cmd(1) + 0x10 => 5, // PumpPos — pump_id(1) + position(4) LE + 0x11 => 5, // PumpDone — pump_id(1) + position(4) LE + 0x20 => 11, // ADC — sum(4) + samples(2) + shift(1) + pump2_pos(4) + 0x30 => 22, // Spectral — 10 x uint16 LE + reserved(2) + 0x40 => 4, // Heartbeat — uptime_ms(4) + _ => return None, + }) +} + +/// 下行命令 → 参数长度。 +pub fn downlink_param_len(cmd: u8) -> Option { + Some(match cmd { + 0x01 => 5, // MaxCount — pump_id(1) + count(4) + 0x02 => 1, // FreeRun — pump_id(1) + 0x03 => 1, // FreeStop — pump_id(1) + 0x04 => 1, // AbortAll — pump_id(1), 0xFF=全部 + 0x05 => 1, // Heartbeat — 0x01=enable watchdog + 0x06 => 0, // Reset — 无载荷 + _ => return None, + }) +} + +fn u32le(payload: &[u8], off: usize) -> u32 { + u32::from_le_bytes([ + payload[off], + payload[off + 1], + payload[off + 2], + payload[off + 3], + ]) +} + +fn u16le(payload: &[u8], off: usize) -> u16 { + u16::from_le_bytes([payload[off], payload[off + 1]]) +} + +/// 解析后的上行帧(对应 Python `_on_frame` 的事件映射)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UplinkFrame { + Ack(u8), + Nak(u8), + /// 泵进度上报(每 1000 脉冲)。 + PumpPos { + pump: u8, + position: u32, + }, + /// 泵运行完成。 + PumpDone { + pump: u8, + position: u32, + }, + /// ADC 过采样结果:16-bit 有效值 + 泵 2 位置。 + Adc { + value: u16, + position: u32, + }, + /// AS7341 光谱:F1..F8/Clear/NIR 十通道原始值。 + Spectral([u16; 10]), + /// 心跳:MCU uptime_ms。 + Heartbeat(u32), +} + +impl UplinkFrame { + /// 从 `(类型, 载荷)` 解码;类型未知或长度不符返回 `None`(静默丢弃,与 Python 一致)。 + pub fn decode(frame_type: u8, payload: &[u8]) -> Option { + Some(match frame_type { + 0x00 if payload.len() == 1 => UplinkFrame::Ack(payload[0]), + 0x01 if payload.len() == 1 => UplinkFrame::Nak(payload[0]), + 0x10 if payload.len() == 5 => UplinkFrame::PumpPos { + pump: payload[0], + position: u32le(payload, 1), + }, + 0x11 if payload.len() == 5 => UplinkFrame::PumpDone { + pump: payload[0], + position: u32le(payload, 1), + }, + 0x20 if payload.len() == 11 => { + let acc = u32le(payload, 0); + let shift = payload[6]; + UplinkFrame::Adc { + value: ((acc >> shift) & 0xFFFF) as u16, + position: u32le(payload, 7), + } + } + 0x30 if payload.len() == 22 => { + let mut vals = [0u16; 10]; + for (i, v) in vals.iter_mut().enumerate() { + *v = u16le(payload, i * 2); + } + UplinkFrame::Spectral(vals) + } + 0x40 if payload.len() == 4 => UplinkFrame::Heartbeat(u32le(payload, 0)), + _ => return None, + }) + } +} + +/// 下行命令(对应 Python `send_maxcount`/`send_frerun`/…)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DownlinkCommand { + /// 0x01 — 定量运行:pump_id + 步数。 + MaxCount { pump: u8, count: u32 }, + /// 0x02 — 自由运行。 + FreeRun(u8), + /// 0x03 — 正常停止(0xFF = 全部)。 + FreeStop(u8), + /// 0x04 — 紧急停止(语义用于异常,功能等价 0x03)。 + Abort(u8), + /// 0x05 — 启用心跳看门狗(参数 0x01)。 + EnableWatchdog, + /// 0x06 — 复位 MCU。 + Reset, +} + +impl DownlinkCommand { + pub fn id(&self) -> u8 { + match self { + DownlinkCommand::MaxCount { .. } => 0x01, + DownlinkCommand::FreeRun(_) => 0x02, + DownlinkCommand::FreeStop(_) => 0x03, + DownlinkCommand::Abort(_) => 0x04, + DownlinkCommand::EnableWatchdog => 0x05, + DownlinkCommand::Reset => 0x06, + } + } + + pub fn params(&self) -> Vec { + match *self { + DownlinkCommand::MaxCount { pump, count } => { + let mut p = vec![pump]; + p.extend_from_slice(&count.to_le_bytes()); + p + } + DownlinkCommand::FreeRun(pump) + | DownlinkCommand::FreeStop(pump) + | DownlinkCommand::Abort(pump) => vec![pump], + DownlinkCommand::EnableWatchdog => vec![0x01], + DownlinkCommand::Reset => Vec::new(), + } + } + + /// 编码为完整下行帧(含帧头与 CRC)。 + pub fn encode(&self) -> Vec { + let cmd = self.id(); + let params = self.params(); + debug_assert_eq!( + params.len(), + downlink_param_len(cmd).unwrap_or(params.len()) + ); + let mut body = vec![cmd]; + body.extend_from_slice(¶ms); + let cs = crc8(&body); + let mut frame = vec![0xBB, 0x55]; + frame.extend_from_slice(&body); + frame.push(cs); + frame + } +} + +/// 构建心跳帧(0x05 0x01),不占用命令的 ACK/重试状态。 +pub fn heartbeat_frame() -> Vec { + DownlinkCommand::EnableWatchdog.encode() +} + +/// 构建 AbortAll(0x04 0xFF)帧,用于重试耗尽后的自动急停。 +pub fn abort_all_frame() -> Vec { + DownlinkCommand::Abort(0xFF).encode() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn downlink_param_lengths_match_protocol_table() { + // Python 版 send_cmd 按此表校验参数长度;Rust 版命令为强类型, + // 这里断言编码出的参数长度与表一致,保证帧格式不变。 + assert_eq!( + DownlinkCommand::MaxCount { + pump: 1, + count: 0x1234_5678 + } + .params() + .len(), + 5 + ); + assert_eq!(DownlinkCommand::FreeRun(2).params().len(), 1); + assert_eq!(DownlinkCommand::FreeStop(0xFF).params().len(), 1); + assert_eq!(DownlinkCommand::Abort(0xFF).params().len(), 1); + assert_eq!(DownlinkCommand::EnableWatchdog.params().len(), 1); + assert_eq!(DownlinkCommand::Reset.params().len(), 0); + } + + #[test] + fn maxcount_frame_layout() { + let frame = DownlinkCommand::MaxCount { + pump: 1, + count: 0x0102_0304, + } + .encode(); + assert_eq!(&frame[..2], &[0xBB, 0x55]); + assert_eq!(frame[2], 0x01); + assert_eq!(&frame[3..8], &[0x01, 0x04, 0x03, 0x02, 0x01]); + assert_eq!(frame[8], crc8(&frame[2..8])); + } + + #[test] + fn adc_decode_applies_shift() { + // sum=0x1_2345_6789, shift=4 → (sum >> 4) & 0xFFFF + let mut payload = Vec::new(); + payload.extend_from_slice(&0x1234_5678u32.to_le_bytes()); + payload.extend_from_slice(&1234u16.to_le_bytes()); // samples(未用) + payload.push(4); // shift + payload.extend_from_slice(&0x0000_00FFu32.to_le_bytes()); // pump2_pos + match UplinkFrame::decode(0x20, &payload) { + Some(UplinkFrame::Adc { value, position }) => { + assert_eq!(value, ((0x1234_5678u32 >> 4) & 0xFFFF) as u16); + assert_eq!(position, 0xFF); + } + other => panic!("unexpected frame: {other:?}"), + } + } + + #[test] + fn spectral_decode_takes_first_ten_u16() { + let mut payload = Vec::new(); + for i in 0..10u16 { + payload.extend_from_slice(&(i * 1000).to_le_bytes()); + } + payload.extend_from_slice(&0xBEEFu16.to_le_bytes()); // reserved(2) + match UplinkFrame::decode(0x30, &payload) { + Some(UplinkFrame::Spectral(vals)) => { + assert_eq!(vals[0], 0); + assert_eq!(vals[9], 9000); + } + other => panic!("unexpected frame: {other:?}"), + } + } +} diff --git a/TController/crates/controller-core/src/protocol/handler.rs b/TController/crates/controller-core/src/protocol/handler.rs new file mode 100644 index 0000000..9c1936f --- /dev/null +++ b/TController/crates/controller-core/src/protocol/handler.rs @@ -0,0 +1,403 @@ +//! 串口通信运行时:后台线程 + 事件通道(Python `_SerialReader` + `ProtocolHandler` 的移植)。 +//! +//! 模型:调用方(未来的 Tauri 命令层)通过 `send`/`send_heartbeat`/`connect` 投递意图, +//! 工作线程独占串口,执行读帧、ACK/NAK 重试、心跳,把 [`Event`] 推回通道, +//! 由调用方周期 `poll()` 取走。 + +use std::io::{Read as _, Write as _}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serialport::SerialPortType; + +use super::frames::{abort_all_frame, heartbeat_frame, DownlinkCommand, UplinkFrame}; +use super::parser::UplinkParser; +use super::retry::{NakOutcome, RetryMachine, ABORT_ERROR, FIRST_TIMEOUT_MS}; + +const POLL_IDLE: Duration = Duration::from_millis(5); +const CHANNEL_BOUND: usize = 4096; + +/// 通信事件(对应 Python `_Event.kind` + 载荷)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Connected, + Disconnected, + Error(String), + Ack(u8), + Nak(u8), + PumpPos { pump: u8, position: u32 }, + PumpDone { pump: u8, position: u32 }, + Adc { value: u16, position: u32 }, + Spectral([u16; 10]), + Heartbeat(u32), +} + +enum Mail { + Open(String, u32), + Close, + Send(DownlinkCommand), + Heartbeat, + Shutdown, +} + +enum Deadline { + /// 已写线,等待首包 ACK(100ms)。 + FirstTimeout(Instant), + /// NAK/超时后的退避等待,到点重传。 + Backoff(Instant), +} + +/// 对外句柄;`&self` 方法可在任意线程调用。 +pub struct ProtocolHandler { + mail: SyncSender, + events: Receiver, + open_flag: Arc, + worker: Option>, +} + +impl ProtocolHandler { + pub fn new() -> Self { + let (mail_tx, mail_rx) = std::sync::mpsc::sync_channel(64); + let (event_tx, event_rx) = std::sync::mpsc::sync_channel(CHANNEL_BOUND); + let open_flag = Arc::new(AtomicBool::new(false)); + let worker = thread::Builder::new() + .name("com-worker".into()) + .spawn({ + let open_flag = Arc::clone(&open_flag); + move || worker_loop(mail_rx, event_tx, open_flag) + }) + .expect("spawn com-worker"); + + Self { + mail: mail_tx, + events: event_rx, + open_flag, + worker: Some(worker), + } + } + + pub fn is_open(&self) -> bool { + self.open_flag.load(Ordering::Relaxed) + } + + /// 连接串口(8N1)。未指定端口时上报错误事件,与 Python 一致。 + pub fn connect(&self, port: &str, baud: u32) { + let _ = self.mail.send(Mail::Open(port.to_string(), baud)); + } + + pub fn disconnect(&self) { + let _ = self.mail.send(Mail::Close); + } + + /// 发送命令并纳入 ACK/重试管理。 + pub fn send(&self, cmd: DownlinkCommand) { + let _ = self.mail.send(Mail::Send(cmd)); + } + + /// 发送心跳;有 pending 命令时跳过,避免覆盖其确认状态。 + pub fn send_heartbeat(&self) { + let _ = self.mail.send(Mail::Heartbeat); + } + + /// 排空事件队列(由 UI 层周期调用,对应 Python `poll()`)。 + pub fn poll(&self) -> Vec { + let mut out = Vec::new(); + while let Ok(ev) = self.events.try_recv() { + out.push(ev); + } + out + } + + /// 停止工作线程并释放串口。 + pub fn shutdown(mut self) { + let _ = self.mail.send(Mail::Shutdown); + if let Some(handle) = self.worker.take() { + let _ = handle.join(); + } + } +} + +impl Default for ProtocolHandler { + fn default() -> Self { + Self::new() + } +} + +fn worker_loop(mail: Receiver, events: SyncSender, open_flag: Arc) { + let mut port: Option> = None; + let mut parser = UplinkParser::new(); + let mut retry = RetryMachine::new(); + let mut deadline: Option = None; + let mut buf = [0u8; 1024]; + + let emit = |ev: Event| { + // 上位机 100ms 轮询下 4096 深度远够用;满时丢弃新事件并记录。 + if events.send(ev).is_err() { + // 接收端已 drop(应用关闭),静默。 + } + }; + + loop { + // ---- 1) 处理调用方指令 ---- + match mail.recv_timeout(POLL_IDLE) { + Ok(Mail::Open(name, baud)) => { + close_port(&mut port, &open_flag, &emit, true); + if name.is_empty() { + emit(Event::Error("未指定串口端口".into())); + } else { + match serialport::new(&name, baud) + .data_bits(serialport::DataBits::Eight) + .parity(serialport::Parity::None) + .stop_bits(serialport::StopBits::One) + .timeout(POLL_IDLE) + .open() + { + Ok(p) => { + parser.reset(); + retry.clear(); + deadline = None; + open_flag.store(true, Ordering::Relaxed); + port = Some(p); + emit(Event::Connected); + } + Err(exc) => emit(Event::Error(exc.to_string())), + } + } + } + Ok(Mail::Close) => close_port(&mut port, &open_flag, &emit, true), + Ok(Mail::Send(cmd)) => { + if let Some(p) = port.as_mut() { + let frame = cmd.encode(); + retry.send(frame.clone(), cmd.id()); + let _ = p.write_all(&frame); + deadline = Some(Deadline::FirstTimeout( + Instant::now() + Duration::from_millis(FIRST_TIMEOUT_MS), + )); + } + } + Ok(Mail::Heartbeat) => { + // 普通命令只有一个 pending 槽;命令等待确认时跳过本次心跳, + // 避免覆盖泵控制命令并误判其 ACK。 + if !retry.is_pending() { + if let Some(p) = port.as_mut() { + let _ = p.write_all(&heartbeat_frame()); + } + } + } + Ok(Mail::Shutdown) => { + close_port(&mut port, &open_flag, &emit, true); + return; + } + Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => {} + } + + // ---- 2) 重试/超时截止 ---- + let due = matches!(&deadline, Some(Deadline::FirstTimeout(t)) if *t <= Instant::now()) + || matches!(&deadline, Some(Deadline::Backoff(t)) if *t <= Instant::now()); + if due { + match deadline.take() { + Some(Deadline::FirstTimeout(_)) => { + // 首包超时等价 NAK(Python _on_first_timeout → poll → _handle_nak) + if let Some(id) = retry.pending_id() { + apply_nak(retry.on_nak(id), &mut deadline, &mut port, &emit); + } + } + Some(Deadline::Backoff(_)) => { + if let Some(frame) = retry.pending_frame().map(<[u8]>::to_vec) { + if let Some(p) = port.as_mut() { + let _ = p.write_all(&frame); + deadline = Some(Deadline::FirstTimeout( + Instant::now() + Duration::from_millis(FIRST_TIMEOUT_MS), + )); + } + } + } + None => {} + } + } + + // ---- 3) 读串口 → 解析 → 分发(先绑定结果,释放串口借用)---- + let read_result: std::io::Result = match port.as_mut() { + Some(p) => p.read(&mut buf), + None => Ok(0), + }; + match read_result { + Ok(0) => {} + Ok(n) => { + let mut frames = Vec::new(); + parser.feed(&buf[..n], &mut frames); + for (frame_type, payload) in frames { + let Some(frame) = UplinkFrame::decode(frame_type, &payload) else { + continue; + }; + match frame { + UplinkFrame::Ack(cmd) => { + if let Some(text) = retry.on_ack(cmd).error_text() { + emit(Event::Error(text)); + } + emit(Event::Ack(cmd)); + } + UplinkFrame::Nak(cmd) => { + apply_nak(retry.on_nak(cmd), &mut deadline, &mut port, &emit); + emit(Event::Nak(cmd)); + } + other => emit(frame_event(other)), + } + } + } + Err(exc) if is_read_idle_error(&exc) => {} + Err(exc) => { + emit(Event::Error(exc.to_string())); + close_port(&mut port, &open_flag, &emit, true); + } + } + } +} + +/// NAK/超时处置:安排退避重传,或耗尽后发 AbortAll + 报错。 +fn apply_nak( + outcome: NakOutcome, + deadline: &mut Option, + port: &mut Option>, + emit: &impl Fn(Event), +) { + match outcome { + NakOutcome::Retry { delay_ms } => { + *deadline = Some(Deadline::Backoff( + Instant::now() + Duration::from_millis(delay_ms), + )); + } + NakOutcome::AbortAndError => { + *deadline = None; + if let Some(p) = port.as_mut() { + let _ = p.write_all(&abort_all_frame()); + } + emit(Event::Error(ABORT_ERROR.into())); + } + NakOutcome::Ignored => {} + } +} + +fn is_read_idle_error(error: &std::io::Error) -> bool { + matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) +} + +fn frame_event(frame: UplinkFrame) -> Event { + match frame { + UplinkFrame::Ack(cmd) => Event::Ack(cmd), + UplinkFrame::Nak(cmd) => Event::Nak(cmd), + UplinkFrame::PumpPos { pump, position } => Event::PumpPos { pump, position }, + UplinkFrame::PumpDone { pump, position } => Event::PumpDone { pump, position }, + UplinkFrame::Adc { value, position } => Event::Adc { value, position }, + UplinkFrame::Spectral(vals) => Event::Spectral(vals), + UplinkFrame::Heartbeat(uptime_ms) => Event::Heartbeat(uptime_ms), + } +} + +fn close_port( + port: &mut Option>, + open_flag: &AtomicBool, + emit: &impl Fn(Event), + notify: bool, +) { + if port.take().is_some() && notify { + emit(Event::Disconnected); + } + open_flag.store(false, Ordering::Relaxed); +} + +/// 可用于 UI 展示的串口信息;连接时仍只使用 `port_name`。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PortInfo { + pub port_name: String, + pub description: Option, +} + +fn port_description(port_type: &SerialPortType) -> Option { + match port_type { + SerialPortType::UsbPort(info) => { + let mut parts = Vec::new(); + if let Some(manufacturer) = info.manufacturer.as_deref().filter(|s| !s.is_empty()) { + parts.push(manufacturer.to_string()); + } + if let Some(product) = info.product.as_deref().filter(|s| !s.is_empty()) { + if !parts.iter().any(|part| part == product) { + parts.push(product.to_string()); + } + } + if parts.is_empty() { + parts.push(format!("USB VID_{:04X}:PID_{:04X}", info.vid, info.pid)); + } + if let Some(serial) = info.serial_number.as_deref().filter(|s| !s.is_empty()) { + parts.push(format!("S/N {serial}")); + } + Some(parts.join(" ")) + } + SerialPortType::BluetoothPort => Some("Bluetooth serial port".into()), + SerialPortType::PciPort => Some("PCI serial port".into()), + SerialPortType::Unknown => None, + } +} + +/// 供 Tauri 层列举可用串口及设备描述。 +pub fn list_ports() -> Vec { + serialport::available_ports() + .map(|ports| { + ports + .into_iter() + .map(|port| PortInfo { + port_name: port.port_name, + description: port_description(&port.port_type), + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::retry::RetryMachine; + + #[test] + fn heartbeat_is_skipped_while_command_pending() { + // 复刻 Python test_send_heartbeat_does_not_overwrite_pending_command: + // 心跳路径的判定就是 is_pending()——pending 存在时心跳不写线。 + let mut m = RetryMachine::new(); + m.send(vec![0xBB, 0x55, 0x02, 0x02, 0x00], 0x02); + assert!(m.is_pending()); + assert_eq!(m.pending_id(), Some(0x02)); + } + + #[test] + fn read_idle_errors_are_nonfatal() { + assert!(is_read_idle_error(&std::io::Error::new( + std::io::ErrorKind::TimedOut, + "idle" + ))); + assert!(is_read_idle_error(&std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "idle" + ))); + assert!(!is_read_idle_error(&std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed" + ))); + } + + #[test] + fn list_ports_returns_vector() { + let ports = list_ports(); + // 无法断言具体端口(CI 无串口),只验证类型与不 panic。 + assert!(ports.len() <= 256); + assert!(ports.iter().all(|port| !port.port_name.is_empty())); + } +} diff --git a/TController/crates/controller-core/src/protocol/mod.rs b/TController/crates/controller-core/src/protocol/mod.rs new file mode 100644 index 0000000..26189de --- /dev/null +++ b/TController/crates/controller-core/src/protocol/mod.rs @@ -0,0 +1,10 @@ +//! 上下位机通信协议层 — Python `Communication/protocol.py` 的移植。 + +pub mod crc; +pub mod frames; +pub mod handler; +pub mod parser; +pub mod retry; + +pub use frames::{DownlinkCommand, UplinkFrame}; +pub use handler::{list_ports, Event, PortInfo, ProtocolHandler}; diff --git a/TController/crates/controller-core/src/protocol/parser.rs b/TController/crates/controller-core/src/protocol/parser.rs new file mode 100644 index 0000000..f3fefc1 --- /dev/null +++ b/TController/crates/controller-core/src/protocol/parser.rs @@ -0,0 +1,164 @@ +//! 上行帧逐字节状态机解析器(`AA 55` 前导,CRC 校验失败静默丢弃)。 + +use super::crc::crc8; +use super::frames::uplink_payload_len; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Sync, + Type, + Data, + Checksum, +} + +/// 逐字节喂入,吐出 `(类型, 载荷)`;与 Python `_UplinkParser` 逐位一致。 +#[derive(Debug)] +pub struct UplinkParser { + state: State, + frame_type: u8, + data: Vec, + data_len: usize, + /// SYNC 态缓存,用于识别 `AA 55` 前导(允许中间夹杂干扰字节后重同步)。 + sync_buf: Vec, +} + +impl Default for UplinkParser { + fn default() -> Self { + Self::new() + } +} + +impl UplinkParser { + pub fn new() -> Self { + Self { + state: State::Sync, + frame_type: 0, + data: Vec::new(), + data_len: 0, + sync_buf: Vec::new(), + } + } + + pub fn reset(&mut self) { + *self = Self::new(); + } + + /// 喂入一段字节流,把本批次解析出的帧追加到 `out`。 + pub fn feed(&mut self, bytes: &[u8], out: &mut Vec<(u8, Vec)>) { + for &b in bytes { + if let Some(frame) = self.feed_byte(b) { + out.push(frame); + } + } + } + + fn feed_byte(&mut self, b: u8) -> Option<(u8, Vec)> { + match self.state { + State::Sync => { + if b == 0xAA { + self.sync_buf.push(b); + } else if b == 0x55 && self.sync_buf.last() == Some(&0xAA) { + self.state = State::Type; + self.sync_buf.clear(); + } else { + self.sync_buf.clear(); + } + None + } + State::Type => { + self.frame_type = b; + match uplink_payload_len(b) { + Some(len) => { + self.data_len = len; + self.data.clear(); + self.state = if len == 0 { + State::Checksum + } else { + State::Data + }; + } + None => self.reset(), + } + None + } + State::Data => { + self.data.push(b); + if self.data.len() >= self.data_len { + self.state = State::Checksum; + } + None + } + State::Checksum => { + let mut body = vec![self.frame_type]; + body.extend_from_slice(&self.data); + let ok = crc8(&body) == b; + let result = ok.then(|| (self.frame_type, std::mem::take(&mut self.data))); + self.reset(); + result + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_well_formed_adc_frame() { + let payload: Vec = (0..11u8).collect(); + let mut frame = vec![0xAA, 0x55, 0x20]; + frame.extend_from_slice(&payload); + frame.push(crc8( + &[0x20] + .iter() + .chain(payload.iter()) + .copied() + .collect::>(), + )); + + let mut out = Vec::new(); + UplinkParser::new().feed(&frame, &mut out); + assert_eq!(out, vec![(0x20, payload)]); + } + + #[test] + fn resynchronizes_after_repeated_preamble_noise() { + // 复刻 Python 测试:BB BB AA 55 前的干扰不阻碍 ACK 帧解析。 + let payload = vec![0x01]; + let mut frame = vec![0xBB, 0xBB, 0xAA, 0x55, 0x00]; + frame.extend_from_slice(&payload); + frame.push(crc8(&[0x00, 0x01])); + + let mut out = Vec::new(); + UplinkParser::new().feed(&frame, &mut out); + assert_eq!(out, vec![(0x00, payload)]); + } + + #[test] + fn drops_frame_with_bad_crc_and_recovers() { + // 心跳帧载荷长度须为 4,否则坏帧会吞掉后续字节 + let payload = vec![0x05, 0x00, 0x00, 0x00]; + let mut frame = vec![0xAA, 0x55, 0x40]; + frame.extend_from_slice(&payload); + frame.push( + crc8( + &[0x40] + .iter() + .chain(payload.iter()) + .copied() + .collect::>(), + ) ^ 0xFF, + ); + frame.extend_from_slice(&{ + // 随后跟一个好帧 + let mut f = vec![0xAA, 0x55, 0x00, 0x02]; + f.push(crc8(&[0x00, 0x02])); + f + }); + + let mut out = Vec::new(); + UplinkParser::new().feed(&frame, &mut out); + assert_eq!(out, vec![(0x00, vec![0x02])]); + } +} diff --git a/TController/crates/controller-core/src/protocol/retry.rs b/TController/crates/controller-core/src/protocol/retry.rs new file mode 100644 index 0000000..a00dd9c --- /dev/null +++ b/TController/crates/controller-core/src/protocol/retry.rs @@ -0,0 +1,181 @@ +//! ACK/NAK 重试状态机(纯逻辑,无 I/O)— Python `ProtocolHandler` 重试语义的移植。 +//! +//! 单 pending 槽:任一时刻只有一条命令等待确认。 +//! NAK/超时 → 指数退避重传(50ms 起,最多 5 次);重试耗尽 → 自动 AbortAll + 报错。 + +/// 最大重试次数(Python `_max_retries`)。 +pub const MAX_RETRIES: u32 = 5; +/// 退避基数(Python `_backoff_ms`)。 +pub const BACKOFF_MS: u64 = 50; +/// 首包 ACK 超时(Python `send_cmd` 的 100ms 定时器)。 +pub const FIRST_TIMEOUT_MS: u64 = 100; + +/// 重试耗尽后上报的错误文本(Python `_send_abort_and_error`)。 +pub const ABORT_ERROR: &str = "下位机通讯异常"; + +/// `on_ack` 的结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AckOutcome { + /// ACK 匹配当前 pending 命令,已清除。 + Cleared, + /// 收到不匹配的 ACK 且仍有 pending —— 状态可能不同步,应上报错误。 + Unexpected { received: u8, expected: u8 }, + /// 无 pending 时收到的 ACK,忽略(可能是重复响应)。 + Ignored, +} + +/// `on_nak` / 首包超时的结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NakOutcome { + /// 安排重传:先等 `delay_ms` 再写线,随后重新启动首包超时。 + Retry { delay_ms: u64 }, + /// 重试耗尽:pending 已清除,调用方应发送 AbortAll 并上报错误。 + AbortAndError, + /// 无 pending 或命令号不匹配,忽略。 + Ignored, +} + +/// 单命令 ACK/重试状态。 +#[derive(Debug, Default)] +pub struct RetryMachine { + pending_frame: Option>, + pending_id: Option, + retry_count: u32, +} + +impl RetryMachine { + pub fn new() -> Self { + Self::default() + } + + pub fn is_pending(&self) -> bool { + self.pending_frame.is_some() + } + + pub fn pending_id(&self) -> Option { + self.pending_id + } + + /// 待重传的完整帧(重试写线用)。 + pub fn pending_frame(&self) -> Option<&[u8]> { + self.pending_frame.as_deref() + } + + /// 发送新命令:无条件覆盖旧 pending(含清除计时器语义,由调用方落地)。 + pub fn send(&mut self, frame: Vec, cmd_id: u8) { + self.pending_frame = Some(frame); + self.pending_id = Some(cmd_id); + self.retry_count = 0; + } + + pub fn clear(&mut self) { + self.pending_frame = None; + self.pending_id = None; + self.retry_count = 0; + } + + /// 收到 ACK(Python `_on_ack`)。 + pub fn on_ack(&mut self, cmd: u8) -> AckOutcome { + match self.pending_id { + Some(expected) if expected == cmd => { + self.clear(); + AckOutcome::Cleared + } + Some(expected) => AckOutcome::Unexpected { + received: cmd, + expected, + }, + None => AckOutcome::Ignored, + } + } + + /// 收到 NAK 或首包超时(Python `_handle_nak`)。首包超时由调用方 + /// 以当前 pending 命令号调用本方法,行为与 NAK 一致。 + pub fn on_nak(&mut self, cmd: u8) -> NakOutcome { + if self.pending_frame.is_none() || self.pending_id != Some(cmd) { + return NakOutcome::Ignored; + } + self.retry_count += 1; + if self.retry_count >= MAX_RETRIES { + self.clear(); + return NakOutcome::AbortAndError; + } + NakOutcome::Retry { + delay_ms: BACKOFF_MS * (1 << (self.retry_count - 1)), + } + } +} + +impl AckOutcome { + /// 不匹配 ACK 的错误文本(Python:收到意外 ACK 0x%02X,期望 0x%02X)。 + pub fn error_text(&self) -> Option { + match self { + AckOutcome::Unexpected { received, expected } => Some(format!( + "收到意外 ACK 0x{received:02X},期望 0x{expected:02X}" + )), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ack_requires_matching_pending_command() { + let mut m = RetryMachine::new(); + m.send(vec![0xBB, 0x55, 0x02, 0x02, 0x00], 0x02); + + assert_eq!( + m.on_ack(0x05), + AckOutcome::Unexpected { + received: 0x05, + expected: 0x02 + } + ); + assert!(m.is_pending(), "意外 ACK 不得清除 pending"); + + assert_eq!(m.on_ack(0x02), AckOutcome::Cleared); + assert!(!m.is_pending()); + } + + #[test] + fn ack_without_pending_is_ignored() { + let mut m = RetryMachine::new(); + assert_eq!(m.on_ack(0x02), AckOutcome::Ignored); + } + + #[test] + fn nak_backoff_doubles_per_retry() { + let mut m = RetryMachine::new(); + m.send(vec![0xBB], 0x02); + assert_eq!(m.on_nak(0x02), NakOutcome::Retry { delay_ms: 50 }); + assert_eq!(m.on_nak(0x02), NakOutcome::Retry { delay_ms: 100 }); + assert_eq!(m.on_nak(0x02), NakOutcome::Retry { delay_ms: 200 }); + assert_eq!(m.on_nak(0x02), NakOutcome::Retry { delay_ms: 400 }); + } + + // 注:delay 序列 [50,100,200,400] 的断言在 nak_exhaustion_aborts_and_clears + // 中一并覆盖,此处保持独立验证前两次翻倍。 + + #[test] + fn nak_exhaustion_aborts_and_clears() { + let mut m = RetryMachine::new(); + m.send(vec![0xBB], 0x02); + // 前 MAX_RETRIES-1 次:指数退避;第 MAX_RETRIES 次:AbortAll + 报错 + for expected in [50u64, 100, 200, 400] { + assert_eq!(m.on_nak(0x02), NakOutcome::Retry { delay_ms: expected }); + } + assert_eq!(m.on_nak(0x02), NakOutcome::AbortAndError); + assert!(!m.is_pending()); + } + + #[test] + fn nak_for_other_command_is_ignored() { + let mut m = RetryMachine::new(); + m.send(vec![0xBB], 0x02); + assert_eq!(m.on_nak(0x05), NakOutcome::Ignored); + assert!(m.is_pending()); + } +} diff --git a/TController/crates/controller-core/src/workflow.rs b/TController/crates/controller-core/src/workflow.rs new file mode 100644 index 0000000..85dabf7 --- /dev/null +++ b/TController/crates/controller-core/src/workflow.rs @@ -0,0 +1,250 @@ +//! 滴定工作流状态机 — Python `gui/main_window.py` 的 `TitrationState` + +//! `_run_detection` 泵控判据的后端化移植。 +//! +//! 工作流:空闲 → [开始] 进样泵 MaxCount → 滴定泵 FreeRun → 终点 T=1 +//! → 继续 FreeRun 至 2×V_ep → T=2 停泵 + AMPD 精修 → 完成。 +//! +//! T=1 的泵控判据是"报告的体积有电位证据支撑",而不是枚举 method 名字: +//! consensus 已由 KF 融合双模态;potential_only 与 conflict 报告的都是电位 +//! 终点(conflict 即"双模态都确认但未过 NIS 门控,退回电位")。只有 +//! spectral_only 不能控泵——它没有电极证据。若按 method 名白名单就会漏掉 +//! conflict:两模态持续不一致时 T=1 永不触发,滴定死锁而泵无限运行 +//! (Python 版的实际回归,此处固化为测试 `conflict_with_potential_evidence_triggers_t1`)。 + +use serde::Serialize; + +use crate::processing::calibration::PumpCalibration; +use crate::processing::endpoint::{EndpointDetector, EndpointResult, Method}; +use crate::protocol::DownlinkCommand; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum TitrationState { + #[default] + Idle, + Injecting, + Titrating, + Degree1, + Titrating2, + Done, + Error, +} + +/// 泵指令(由传输层执行)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PumpCommand { + MaxCount { pump: u8, steps: u32 }, + FreeRun(u8), + FreeStop(u8), +} + +impl From for DownlinkCommand { + fn from(cmd: PumpCommand) -> Self { + match cmd { + PumpCommand::MaxCount { pump, steps } => { + DownlinkCommand::MaxCount { pump, count: steps } + } + PumpCommand::FreeRun(p) => DownlinkCommand::FreeRun(p), + PumpCommand::FreeStop(p) => DownlinkCommand::FreeStop(p), + } + } +} + +/// 一次 tick 的决策输出。 +#[derive(Debug, Clone, Default)] +pub struct WorkflowOutcome { + pub state: TitrationState, + pub commands: Vec, + /// T=1 首次报告的终点。 + pub first_endpoint: Option, + /// T=2/手动停止时 AMPD 精修后的终点。 + pub refined_endpoint: Option, + /// T=1 时 method 为 conflict(界面据此给出不同提示)。 + pub conflict_at_t1: bool, + /// 本 tick 的检测诊断(JSON 由上层序列化转发)。 + pub detection: Option, +} + +/// 工作流引擎:状态 + 检测器 + 泵 2 体积,纯逻辑,不含 I/O。 +pub struct WorkflowEngine { + pub state: TitrationState, + pub detector: EndpointDetector, + pub calibration: PumpCalibration, + pump2_volume: f64, + endpoint_volume: Option, +} + +impl WorkflowEngine { + pub fn new(flow_rate: f64, calibration: PumpCalibration) -> Self { + Self { + state: TitrationState::Idle, + detector: EndpointDetector::new(flow_rate), + calibration, + pump2_volume: 0.0, + endpoint_volume: None, + } + } + + pub fn endpoint_volume(&self) -> Option { + self.endpoint_volume + } + + pub fn pump2_volume(&self) -> f64 { + self.pump2_volume + } + + fn titrating(&self) -> bool { + matches!( + self.state, + TitrationState::Titrating | TitrationState::Degree1 | TitrationState::Titrating2 + ) + } + + /// 开始滴定:进样体积(mL)→ MaxCount 步数,状态进入 Injecting。 + pub fn start(&mut self, sample_volume_ml: f64) -> WorkflowOutcome { + self.detector.reset(); + self.endpoint_volume = None; + self.pump2_volume = 0.0; + self.state = TitrationState::Injecting; + let steps = self.calibration.steps_from_volume(sample_volume_ml); + WorkflowOutcome { + state: self.state, + commands: vec![PumpCommand::MaxCount { pump: 1, steps }], + ..Default::default() + } + } + + /// 泵完成回调:泵 1 进样完成 → 启动滴定泵。 + pub fn on_pump_done(&mut self, pump_id: u8) -> WorkflowOutcome { + if pump_id == 1 && self.state == TitrationState::Injecting { + self.state = TitrationState::Titrating; + return WorkflowOutcome { + state: self.state, + commands: vec![PumpCommand::FreeRun(2)], + ..Default::default() + }; + } + WorkflowOutcome { + state: self.state, + ..Default::default() + } + } + + /// ADC 上行帧(1 kHz):更新泵 2 体积并馈入检测器。 + /// `position` 为固件累计步数;`t` 为连接起点的秒;`voltage` 已换算为伏。 + pub fn on_adc(&mut self, position: u32, t: f64, voltage: f64) { + self.pump2_volume = self.calibration.volume_from_steps(position); + if self.titrating() { + self.detector.feed_potential(self.pump2_volume, t, voltage); + } + } + + /// 光谱上行帧:仅在滴定期间馈入检测器。 + pub fn on_spectrum(&mut self, spectrum: &[f64]) { + if self.titrating() { + self.detector.feed_spectrum(self.pump2_volume, spectrum); + } + } + + /// 周期决策(对应 Python `_run_detection`,500ms 周期)。 + pub fn poll(&mut self) -> WorkflowOutcome { + if !self.titrating() { + return WorkflowOutcome { + state: self.state, + ..Default::default() + }; + } + + let Some(result) = self.detector.detect() else { + return WorkflowOutcome { + state: self.state, + ..Default::default() + }; + }; + let vol = result.volume; + let method = result.method; + let potential_evidence = result.reliability.potential_evidence; + let mut outcome = WorkflowOutcome { + state: self.state, + detection: Some(result), + ..Default::default() + }; + + match self.state { + TitrationState::Titrating => { + // 判据 = 有电位证据(见模块文档);spectral_only 只能候选。 + let can_control = method == Method::Consensus + || (matches!(method, Method::PotentialOnly | Method::Conflict) + && potential_evidence); + if !can_control { + return outcome; + } + self.endpoint_volume = Some(vol); + self.state = TitrationState::Degree1; + outcome.state = self.state; + outcome.first_endpoint = Some(vol); + outcome.conflict_at_t1 = method == Method::Conflict; + } + TitrationState::Degree1 | TitrationState::Titrating2 => { + if let Some(ep) = self.endpoint_volume { + if self.pump2_volume >= 2.0 * ep { + let refined = self.detector.refine_with_ampd(); + if let Some(r) = refined { + self.endpoint_volume = Some(r); + } + self.state = TitrationState::Done; + outcome.state = self.state; + outcome.commands = vec![PumpCommand::FreeStop(2)]; + outcome.refined_endpoint = self.endpoint_volume; + return outcome; + } + } + if self.state == TitrationState::Degree1 { + self.state = TitrationState::Titrating2; + outcome.state = self.state; + } + } + _ => unreachable!("titrating() 已过滤其余状态"), + } + outcome + } + + /// 手动停止:停泵;已有 T=1 时用 AMPD 精修并收尾。 + pub fn manual_stop(&mut self) -> WorkflowOutcome { + let mut outcome = WorkflowOutcome { + state: TitrationState::Done, + commands: vec![PumpCommand::FreeStop(2)], + ..Default::default() + }; + if self.endpoint_volume.is_some() { + outcome.refined_endpoint = self.detector.refine_with_ampd(); + if let Some(r) = outcome.refined_endpoint { + self.endpoint_volume = Some(r); + } + } + self.state = TitrationState::Done; + outcome.state = self.state; + outcome + } + + /// 急停 / MCU 复位后的状态归零。 + pub fn abort(&mut self) -> WorkflowOutcome { + self.state = TitrationState::Idle; + self.endpoint_volume = None; + self.pump2_volume = 0.0; + self.detector.reset(); + WorkflowOutcome { + state: self.state, + ..Default::default() + } + } + + /// 滴定期间是否允许手动停止(连接后 Injecting 起允许)。 + pub fn can_manual_stop(&self) -> bool { + !matches!( + self.state, + TitrationState::Idle | TitrationState::Done | TitrationState::Error + ) + } +} diff --git a/TController/crates/controller-core/tests/endpoint_reliability.rs b/TController/crates/controller-core/tests/endpoint_reliability.rs new file mode 100644 index 0000000..0cfbaaf --- /dev/null +++ b/TController/crates/controller-core/tests/endpoint_reliability.rs @@ -0,0 +1,292 @@ +//! Python `tests/test_endpoint_reliability.py` 的移植 — 行为对齐契约。 + +use controller_core::processing::divergence::js_divergence; +use controller_core::processing::endpoint::{Confidence, EndpointDetector, Method, PotentialState}; +use controller_core::processing::tracker::{SpectralFeatureTracker, TrackerState}; + +fn ones4() -> Vec { + vec![1.0; 4] +} + +fn gauss(x: f64, center: f64, sigma: f64) -> f64 { + (-((x - center).powi(2)) / (2.0 * sigma * sigma)).exp() +} + +fn baseline_frames(tracker: &mut SpectralFeatureTracker) { + for i in 1..9 { + tracker.update(i as f64 * 0.05, &ones4()); + } +} + +fn tracker_for_events(supersede_ratio: f64) -> SpectralFeatureTracker { + SpectralFeatureTracker::with_params( + 0.20, // alpha + 0.1, // js_enter + 0.03, // js_exit + 0.001, // baseline_enter + 4, // baseline_frames + 0.4, // baseline_max_volume + 3, // confirm_frames + 0.05, // min_event_volume + 1e-8, // epsilon_volume + 8, // lookback_frames + supersede_ratio, + 1e-14, + true, // use_jsd + ) +} + +/// 驱动一次"升起并恢复"的激变;返回新的体积与诊断。 +fn feed_excursion( + tracker: &mut SpectralFeatureTracker, + mut volume: f64, + amplitude: f64, + recovery: usize, +) -> (f64, controller_core::processing::tracker::Diagnostics) { + let mut diagnostic = Default::default(); + for _ in 0..3 { + volume += 0.05; + diagnostic = tracker.update(volume, &[amplitude, 1.0, 1.0, 1.0]); + } + for _ in 0..recovery { + volume += 0.05; + diagnostic = tracker.update(volume, &ones4()); + } + (volume, diagnostic) +} + +#[test] +fn js_is_symmetric_bounded_and_gain_invariant() { + let p = [1.0, 2.0, 4.0, 8.0]; + let q = [2.0, 3.0, 5.0, 7.0]; + let pq = js_divergence(&p, &q); + approx::assert_relative_eq!(pq, js_divergence(&q, &p)); + assert!((0.0..=std::f64::consts::LN_2).contains(&pq)); + // Python np.isclose 默认 rtol=1e-5;放大 17 倍引入 ~1e-9 相对舍入差 + approx::assert_relative_eq!( + pq, + js_divergence(&[17.0, 34.0, 68.0, 136.0], &[34.0, 51.0, 85.0, 119.0]), + max_relative = 1e-6 + ); +} + +#[test] +fn tracker_handles_invalid_and_repeated_volume_without_infinity() { + let mut tracker = SpectralFeatureTracker::new(); + let first = tracker.update(0.1, &ones4()); + let repeated = tracker.update(0.1, &[2.0, 1.0, 1.0, 1.0]); + let invalid = tracker.update(0.1, &[1.0, f64::NAN, 1.0, 1.0]); + assert!(first.valid_frame); + assert!(!repeated.volume_sync_valid); + assert_eq!(repeated.repeated_volume_count, 1); + assert!(repeated.js_local.is_finite()); + assert!(repeated.js_speed.is_finite()); + assert!(!invalid.valid_frame); + assert_eq!(invalid.data_quality, "spectrum_nonfinite"); +} + +#[test] +fn cross_curvature_is_causal() { + let prefix = [ + vec![1.0, 1.0, 2.0, 1.0, 1.0], + vec![1.0, 2.0, 2.0, 1.0, 1.0], + vec![1.0, 3.0, 2.0, 1.0, 1.0], + ]; + let future = [vec![1.0, 4.0, 1.0, 2.0, 1.0], vec![2.0, 1.0, 1.0, 4.0, 1.0]]; + let mut left = SpectralFeatureTracker::with_params( + 0.20, 0.05, 0.008, 3e-7, 3, 0.2, 4, 0.08, 1e-8, 8, 1.5, 1e-14, true, + ); + let mut right = SpectralFeatureTracker::with_params( + 0.20, 0.05, 0.008, 3e-7, 3, 0.2, 4, 0.08, 1e-8, 8, 1.5, 1e-14, true, + ); + let left_values: Vec<_> = prefix + .iter() + .enumerate() + .map(|(i, frame)| left.update((i + 1) as f64 * 0.05, frame)) + .collect(); + let all = [prefix.as_slice(), future.as_slice()].concat(); + let right_values: Vec<_> = all + .iter() + .enumerate() + .map(|(i, frame)| right.update((i + 1) as f64 * 0.05, frame)) + .collect(); + for (before, after) in left_values.iter().zip(right_values.iter()) { + approx::assert_relative_eq!(before.cross_curvature, after.cross_curvature); + approx::assert_relative_eq!(before.js_local, after.js_local); + assert_eq!(before.state, after.state); + } +} + +#[test] +fn peak_requires_recovery_before_confirmation() { + let mut tracker = SpectralFeatureTracker::with_params( + 0.20, 0.2, 0.03, 0.001, 4, 0.4, 3, 0.05, 1e-8, 8, 1.5, 1e-14, true, + ); + baseline_frames(&mut tracker); + for volume in [0.45, 0.50, 0.55] { + let diagnostic = tracker.update(volume, &[100.0, 1.0, 1.0, 1.0]); + assert_eq!(diagnostic.state, TrackerState::InChange); + } + let diagnostic = tracker.update(0.60, &ones4()); + assert_eq!(diagnostic.state, TrackerState::InChange); + let mut diagnostic = diagnostic; + let mut v = 0.65; + while v < 2.05 { + diagnostic = tracker.update(v, &ones4()); + v += 0.05; + } + assert_eq!(diagnostic.state, TrackerState::EndConfirmed); + assert!(diagnostic.candidate_volume.unwrap().is_finite()); +} + +#[test] +fn detector_keeps_legacy_feed_and_result_keys() { + let mut detector = EndpointDetector::new(0.0061); + for index in 1..240 { + let volume = index as f64 * 0.01; + let voltage = 1.0 - 0.8 * gauss(volume, 1.0, 0.035); + detector.feed_potential(volume, volume / 0.0061, voltage); + detector.feed_spectrum(volume, &[1.0, 2.0, 3.0, 4.0]); + } + let result = detector.detect().expect("endpoint expected"); + assert_eq!(detector.potential_state(), PotentialState::EndConfirmed); + assert!(matches!( + result.method, + Method::Consensus | Method::PotentialOnly + )); + assert!(matches!( + result.confidence, + Confidence::High | Confidence::Medium + )); + assert!(result.potential.is_some()); + assert!( + (result.volume - 1.0).abs() < 0.3, + "volume={} ", + result.volume + ); +} + +#[test] +fn detector_reset_retains_spectrum_configuration() { + let mut detector = EndpointDetector::with_options( + 0.0061, + true, + true, + true, + Some(&[400.0, 500.0, 600.0, 700.0]), + ); + detector.feed_spectrum(0.1, &ones4()); + detector.reset(); + let diagnostics = detector.diagnostics(); + assert_eq!(diagnostics.spectral_features.sample_count, 0); + detector.feed_spectrum(0.1, &ones4()); + assert!(detector + .diagnostics() + .spectral_features + .cross_curvature + .is_finite()); +} + +/// 生产路径复现:多帧光谱共享同一泵体积。速度滤波必须*保持*电平而不是 +/// 喂零——喂零会把活跃激变拖到退出阈值以下,伪造一次恢复。 +#[test] +fn repeated_volume_holds_speed_instead_of_injecting_zero() { + let mut tracker = SpectralFeatureTracker::with_params( + 0.20, 0.05, 0.008, 3e-7, 3, 0.2, 4, 0.08, 1e-8, 8, 1.5, 1e-14, true, + ); + for index in 1..4 { + tracker.update(index as f64 * 0.05, &ones4()); + } + let advancing = tracker.update(0.25, &[4.0, 1.0, 1.0, 1.0]); + assert!(advancing.js_speed_smooth > 0.0); + let mut repeated = advancing.clone(); + for _ in 0..5 { + repeated = tracker.update(0.25, &[4.0, 1.0, 1.0, 1.0]); + assert!(!repeated.volume_sync_valid); + approx::assert_relative_eq!(repeated.js_speed_smooth, advancing.js_speed_smooth); + approx::assert_relative_eq!(repeated.cross_curvature, advancing.cross_curvature); + } + assert_eq!(repeated.repeated_volume_count, 5); + // 之后的前进帧恢复正常归一化(锚定最后同步帧)。 + let resumed = tracker.update(0.30, &[4.0, 1.0, 1.0, 1.0]); + assert!(resumed.volume_sync_valid); +} + +/// Paper/ExpData B 组回归:一次性闩锁选择了瞬态。 +#[test] +fn stronger_late_excursion_supersedes_an_early_transient() { + let mut tracker = tracker_for_events(1.5); + baseline_frames(&mut tracker); + let (volume, weak) = feed_excursion(&mut tracker, 0.40, 3.0, 30); + assert_eq!(weak.state, TrackerState::EndConfirmed); + let weak_candidate = weak.candidate_volume.unwrap(); + let (_v, strong) = feed_excursion(&mut tracker, volume, 100.0, 30); + assert_eq!(strong.state, TrackerState::EndConfirmed); + let strong_candidate = strong.candidate_volume.unwrap(); + assert_ne!(strong_candidate, weak_candidate); + assert!(strong_candidate > weak_candidate); + assert_eq!(strong.event_count, 2); + assert_eq!(strong.superseded_count, 1); + assert!(strong.event_peak_speed > weak.event_peak_speed); + assert_eq!(tracker.endpoint_volume(), Some(strong_candidate)); + assert_eq!(tracker.events().len(), 2); +} + +/// 完全相同的两次激变,只有滞回不同:报告的终点不能抖动。 +#[test] +fn supersede_ratio_suppresses_a_near_tie() { + let mut loose = tracker_for_events(1.5); + let mut tight = tracker_for_events(4.0); + let mut outcomes = Vec::new(); + for tracker in [&mut loose, &mut tight] { + baseline_frames(tracker); + let (volume, _) = feed_excursion(tracker, 0.40, 3.0, 30); + let (_, final_diag) = feed_excursion(tracker, volume, 4.0, 30); + outcomes.push(final_diag); + } + assert_eq!(outcomes[0].superseded_count, 1); + assert_eq!(outcomes[1].superseded_count, 0); + assert!(outcomes[1].candidate_volume.unwrap() < outcomes[0].candidate_volume.unwrap()); + // 两个 tracker 看到相同的两次激变;只是报告的赢家不同。 + assert_eq!(outcomes[0].event_count, 2); + assert_eq!(outcomes[1].event_count, 2); +} + +/// js_speed 除以 ~1e-8,舍入地板量级的散度必须保持 0(放大的不能是算术噪声)。 +#[test] +fn round_off_scale_divergence_is_not_normalised() { + let mut tracker = SpectralFeatureTracker::new(); + tracker.update(0.05, &ones4()); + let tiny = tracker.update(0.10, &[1.0 + 1e-9, 1.0, 1.0, 1.0]); + assert!(tiny.volume_sync_valid); + assert_eq!(tiny.js_speed, 0.0); + let real = tracker.update(0.15, &[2.0, 1.0, 1.0, 1.0]); + assert!(real.js_speed > 0.0); +} + +/// cross_entropy(p,p) 是 p 的熵,原始值永远出不了 IN_CHANGE; +/// cross_entropy_excess(=KL)为 0,旧路径(use_jsd=False)可用。 +/// 两种模式的候选体积必须一致。 +#[test] +fn legacy_cross_entropy_mode_confirms_an_endpoint() { + let mut candidates = Vec::new(); + for use_jsd in [true, false] { + let mut detector = EndpointDetector::with_options(0.0061, use_jsd, true, true, None); + for index in 1..300 { + let volume = index as f64 * 0.01; + let voltage = 1.0 - 0.8 * gauss(volume, 1.0, 0.035); + detector.feed_potential(volume, volume / 0.0061, voltage); + let amplitude = 1.0 + 60.0 * gauss(volume, 1.0, 0.03); + detector.feed_spectrum(volume, &[amplitude, 1.0, 1.0, 1.0]); + } + assert_eq!(detector.spectral_state(), TrackerState::EndConfirmed); + candidates.push( + detector + .diagnostics() + .spectral + .map(|s| s.volume) + .expect("spectral result"), + ); + } + approx::assert_relative_eq!(candidates[0], candidates[1], epsilon = 1e-9); +} diff --git a/TController/crates/controller-core/tests/tmp_diff_python.rs b/TController/crates/controller-core/tests/tmp_diff_python.rs new file mode 100644 index 0000000..4d01279 --- /dev/null +++ b/TController/crates/controller-core/tests/tmp_diff_python.rs @@ -0,0 +1,288 @@ +//! 临时差分测试:在真实滴定数据 A 上与 Python 实现逐帧数值比对。 +//! +//! 前置:`tmp_diff/dump_python.py` 生成 `tmp_diff/dataA_python.json` +//! (输入事件序列 + Python 逐帧特征 + 最终结果)。缺文件时跳过。 +//! 这是移植验证用的一次性测试,数值对齐后可删除。 + +use controller_core::processing::endpoint::EndpointDetector; +use serde_json::Value; + +const JSON_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tmp_diff/dataA_python.json" +); + +/// 相对 1e-9 + 绝对 1e-12:两实现的求和顺序差异(NumPy 成对求和 vs 顺序求和) +/// 引入 ~1e-16 相对噪声,留三个数量级裕量。 +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-12 + 1e-9 * a.abs().max(b.abs()) +} + +fn num<'a>(v: &'a Value) -> Option { + v.as_f64() +} + +struct Report { + frames_checked: usize, + pot_checked: usize, + float_mismatch: usize, + decision_mismatch: usize, + first_issues: Vec, +} + +impl Report { + fn issue(&mut self, msg: String, decision: bool) { + if decision { + self.decision_mismatch += 1; + } else { + self.float_mismatch += 1; + } + if self.first_issues.len() < 12 { + self.first_issues.push(msg); + } + } +} + +#[test] +fn matches_python_on_titration_data_a() { + let Ok(text) = std::fs::read_to_string(JSON_PATH) else { + eprintln!("跳过:未找到 {JSON_PATH}(先运行 tmp_diff/dump_python.py)"); + return; + }; + let data: Value = serde_json::from_str(&text).expect("parse json"); + let flow = data["flow_rate"].as_f64().expect("flow_rate"); + let events = data["events"].as_array().expect("events"); + let py_frames = data["frames"].as_array().expect("frames"); + let py_pot = data["pot_progress"].as_array().expect("pot_progress"); + let py_final = &data["final"]; + + let mut det = EndpointDetector::new(flow); + let scale = 3.3f64 / 65535.0; + let mut rep = Report { + frames_checked: 0, + pot_checked: 0, + float_mismatch: 0, + decision_mismatch: 0, + first_issues: Vec::new(), + }; + + let mut frame_i = 0usize; + let mut pot_i = 0usize; + + for ev in events { + let kind = ev[0].as_str().expect("kind"); + let vol = ev[1].as_f64().expect("vol"); + if kind == "pot" { + let lsb = ev[2].as_f64().expect("lsb"); + det.feed_potential(vol, vol / flow, lsb * scale - 1.1); + let py = &py_pot[pot_i]; + pot_i += 1; + rep.pot_checked += 1; + let idx = pot_i; + + let py_state = py[0].as_str().unwrap_or("?"); + if det.potential_state().as_str() != py_state { + rep.issue( + format!( + "pot[{idx}] state {} vs {py_state}", + det.potential_state().as_str() + ), + true, + ); + } + match (det.potential_endpoint_volume(), num(&py[1])) { + (Some(a), Some(b)) if close(a, b) => {} + (None, None) => {} + (a, b) => rep.issue(format!("pot[{idx}] ep_vol {a:?} vs {b:?}"), true), + } + let pyd = num(&py[2]).unwrap_or(f64::NAN); + if !close(det.last_potential_derivative(), pyd) { + rep.issue( + format!( + "pot[{idx}] d_sm {:.12e} vs {:.12e}", + det.last_potential_derivative(), + pyd + ), + false, + ); + } + } else { + let spec: Vec = ev[2] + .as_array() + .expect("spectrum") + .iter() + .map(|v| v.as_f64().expect("ch")) + .collect(); + det.feed_spectrum(vol, &spec); + let f = det.diagnostics().spectral_features; + let py = &py_frames[frame_i]; + frame_i += 1; + rep.frames_checked += 1; + let idx = frame_i; + + // 决策字段必须完全一致 + if f.state.as_str() != py["state"].as_str().unwrap_or("?") { + rep.issue( + format!( + "frame[{idx}] state {} vs {}", + f.state.as_str(), + py["state"].as_str().unwrap_or("?") + ), + true, + ); + } + for key in [ + "event_count", + "superseded_count", + "recovery_frames", + "repeated_volume_count", + "nonmonotonic_count", + "sample_count", + ] { + let mine = py[key].as_u64().unwrap_or(u64::MAX); + let theirs = match key { + "event_count" => f.event_count as u64, + "superseded_count" => f.superseded_count as u64, + "recovery_frames" => f.recovery_frames as u64, + "repeated_volume_count" => f.repeated_volume_count as u64, + "nonmonotonic_count" => f.nonmonotonic_count as u64, + _ => f.sample_count as u64, + }; + if mine != theirs { + rep.issue(format!("frame[{idx}] {key} {theirs} vs {mine}"), true); + } + } + match (f.candidate_volume, num(&py["candidate_volume"])) { + (Some(a), Some(b)) if close(a, b) => {} + (None, None) => {} + (a, b) => rep.issue(format!("frame[{idx}] candidate {a:?} vs {b:?}"), true), + } + + // 浮点特征字段(容差内) + for (key, mine) in [ + ("js_local", f.js_local), + ("js_local_smooth", f.js_local_smooth), + ("js_speed", f.js_speed), + ("js_speed_smooth", f.js_speed_smooth), + ("js_base", f.js_base), + ("cross_curvature", f.cross_curvature), + ] { + let theirs = num(&py[key]).unwrap_or(f64::NAN); + if !close(mine, theirs) { + rep.issue( + format!("frame[{idx}] {key} {mine:.9e} vs {theirs:.9e}"), + false, + ); + } + } + } + } + + // ---- 最终结果 ---- + let py_pot_vol = num(&py_final["potential_volume"]).unwrap_or(f64::NAN); + if !close( + det.potential_endpoint_volume().unwrap_or(f64::NAN), + py_pot_vol, + ) { + rep.issue( + format!( + "final potential_volume {:?} vs {py_pot_vol}", + det.potential_endpoint_volume() + ), + true, + ); + } + let py_spec_vol = num(&py_final["spectral_volume"]).unwrap_or(f64::NAN); + let my_spec_vol = det.endpoint_volume(); + // endpoint_volume 在 KF 可融合时返回融合值;光谱通道单独看 diagnostics + let diag = det.diagnostics(); + let my_spec = diag.spectral.as_ref().map(|s| s.volume).unwrap_or(f64::NAN); + if !close(my_spec, py_spec_vol) { + rep.issue( + format!("final spectral_volume {my_spec} vs {py_spec_vol}"), + true, + ); + } + + if let Some(py_detect) = py_final["detect"].as_object() { + let result = det.detect().expect("detect"); + let py_method = py_detect["method"].as_str().unwrap_or("?"); + let py_conf = py_detect["confidence"].as_str().unwrap_or("?"); + let py_vol = num(&py_detect["volume"]).unwrap_or(f64::NAN); + // 双方都对体积做了 round(·,3),边界处允许 1 个最小刻度差 + if (result.volume - py_vol).abs() > 1.5e-3 { + rep.issue(format!("detect volume {} vs {py_vol}", result.volume), true); + } + let my_method = match result.method { + controller_core::processing::endpoint::Method::Consensus => "consensus", + controller_core::processing::endpoint::Method::PotentialOnly => "potential_only", + controller_core::processing::endpoint::Method::SpectralOnly => "spectral_only", + controller_core::processing::endpoint::Method::Conflict => "conflict", + }; + if my_method != py_method { + rep.issue(format!("detect method {my_method} vs {py_method}"), true); + } + let my_conf = match result.confidence { + controller_core::processing::endpoint::Confidence::High => "high", + controller_core::processing::endpoint::Confidence::Medium => "medium", + controller_core::processing::endpoint::Confidence::Low => "low", + }; + if my_conf != py_conf { + rep.issue(format!("detect confidence {my_conf} vs {py_conf}"), true); + } + } + + let py_status = py_final["reliability_status"].as_str().unwrap_or("?"); + if diag.reliability.status != py_status { + rep.issue( + format!("reliability {} vs {py_status}", diag.reliability.status), + true, + ); + } + + if let Some(py_kf) = py_final["kf"].as_object() { + if let Some(my_kf) = &diag.kf { + if let (Some(a), Some(b)) = (my_kf.endpoint_volume, num(&py_kf["endpoint_volume"])) { + if !close(a, b) { + rep.issue(format!("kf endpoint_volume {a:.9} vs {b:.9}"), false); + } + } + if let (Some(a), Some(b)) = (my_kf.endpoint_std, num(&py_kf["endpoint_std"])) { + if !close(a, b) { + rep.issue(format!("kf endpoint_std {a:.9} vs {b:.9}"), false); + } + } + if let (Some(a), Some(b)) = (my_kf.nis, num(&py_kf["nis"])) { + if !close(a, b) { + rep.issue(format!("kf nis {a:.9} vs {b:.9}"), false); + } + } + } else { + rep.issue("kf missing on rust side".into(), true); + } + } + + // refine_with_ampd:Python 在该数据上返回 None + let py_refine = num(&py_final["refine_with_ampd"]); + let my_refine = det.refine_with_ampd(); + match (my_refine, py_refine) { + (None, None) => {} + (Some(a), Some(b)) if close(a, b) => {} + (a, b) => rep.issue(format!("refine_with_ampd {a:?} vs {b:?}"), true), + } + + println!( + "== 差分比对:{} 电位点, {} 光谱帧;浮点失配 {}, 决策失配 {} ==", + rep.pot_checked, rep.frames_checked, rep.float_mismatch, rep.decision_mismatch + ); + for msg in &rep.first_issues { + println!(" {msg}"); + } + + assert_eq!( + rep.decision_mismatch, 0, + "决策字段(状态/计数/终点)必须与 Python 完全一致" + ); + assert_eq!(rep.float_mismatch, 0, "浮点特征存在超出容差(1e-9)的失配"); + let _ = my_spec_vol; +} diff --git a/TController/crates/controller-core/tests/workflow.rs b/TController/crates/controller-core/tests/workflow.rs new file mode 100644 index 0000000..04fd567 --- /dev/null +++ b/TController/crates/controller-core/tests/workflow.rs @@ -0,0 +1,155 @@ +//! 工作流状态机测试 — 含 Python 版曾实际发生的 T=1 死锁回归。 + +use controller_core::processing::calibration::PumpCalibration; +use controller_core::workflow::{PumpCommand, TitrationState, WorkflowEngine}; + +fn gauss(x: f64, center: f64, sigma: f64) -> f64 { + (-((x - center).powi(2)) / (2.0 * sigma * sigma)).exp() +} + +/// 1 步 = 0.01 mL 的整定(便于精确驱动体积)。 +fn engine() -> WorkflowEngine { + WorkflowEngine::new( + 0.0061, + PumpCalibration { + slope: 0.01, + intercept: 0.0, + }, + ) +} + +/// 注入 → 滴定,然后按 0.01 mL 步进喂电位+光谱并逐点 poll。 +/// 电位下陷在 `pot_center`,光谱激变在 `spec_center`(幅度 60,σ=0.03)。 +/// 返回 (T=1 结果, T=2 结果)。 +struct DriveResult { + t1: Option<(f64, bool)>, // (endpoint, conflict_at_t1) + /// (停止时泵 2 体积, AMPD 精修后终点) + t2: Option<(f64, Option)>, +} + +fn drive( + engine: &mut WorkflowEngine, + steps: u32, + pot_center: f64, + spec_center: f64, + stop_after_t1: bool, +) -> DriveResult { + let start = engine.start(5.0); + assert!(matches!( + start.commands[..], + [PumpCommand::MaxCount { pump: 1, .. }] + )); + let done = engine.on_pump_done(1); + assert!(matches!(done.commands[..], [PumpCommand::FreeRun(2)])); + assert_eq!(engine.state, TitrationState::Titrating); + + let mut out = DriveResult { t1: None, t2: None }; + for i in 1..=steps { + let vol = i as f64 * 0.01; + let t = vol / 0.0061; + let voltage = 1.0 - 0.8 * gauss(vol, pot_center, 0.035); + engine.on_adc(i, t, voltage); + let amplitude = 1.0 + 60.0 * gauss(vol, spec_center, 0.03); + engine.on_spectrum(&[amplitude, 1.0, 1.0, 1.0]); + + let outcome = engine.poll(); + if outcome.first_endpoint.is_some() && out.t1.is_none() { + out.t1 = Some((outcome.first_endpoint.unwrap(), outcome.conflict_at_t1)); + if stop_after_t1 { + return out; + } + } + if outcome.state == TitrationState::Done && out.t2.is_none() { + assert!(matches!(outcome.commands[..], [PumpCommand::FreeStop(2)])); + out.t2 = Some((engine.pump2_volume(), outcome.refined_endpoint)); + return out; + } + } + out +} + +#[test] +fn consensus_happy_path_reaches_t2_with_ampd_refinement() { + let mut engine = engine(); + let result = drive(&mut engine, 400, 1.0, 1.0, false); + let (t1_vol, conflict) = result.t1.expect("T=1 must trigger"); + assert!(!conflict); + assert!((t1_vol - 1.0).abs() < 0.3, "T1 endpoint {t1_vol}"); + let (stop_vol, refined) = result.t2.expect("T=2 must trigger"); + // T=2 判据:实际泵 2 体积到达 2×T1 终点 + assert!(stop_vol >= 2.0 * t1_vol - 0.05, "stop volume {stop_vol}"); + let refined = refined.expect("AMPD refinement"); + assert!((refined - 1.0).abs() < 0.2, "refined {refined}"); + assert_eq!(engine.state, TitrationState::Done); +} + +/// 死锁回归(Python 实际发生过):双模态均确认但未过 NIS 门控 → +/// method=conflict。判据必须按"有电位证据"放行 T=1,否则泵无限运行。 +/// 场景:光谱事件先出现在 1.0 mL(此时无电位证据,不得控泵), +/// 电位终点在 3.5 mL 确认 → conflict 放行 T=1。 +#[test] +fn spectral_only_does_not_control_then_conflict_still_triggers_t1() { + let mut engine = engine(); + + // 阶段 1:光谱已确认、电位未确认 → spectral_only,绝不 T=1。 + let early = drive(&mut engine, 300, 3.5, 1.0, false); + assert!( + early.t1.is_none(), + "spectral_only 不得控泵(t1={:?})", + early.t1 + ); + assert_eq!(engine.state, TitrationState::Titrating); + + // 阶段 2:继续喂到电位终点确认 → conflict(两模态差 2.5 mL,KF 必拒) + // → 但有电位证据 → T=1 必须触发。 + let mut t1 = None; + for i in 301..=460u32 { + let vol = i as f64 * 0.01; + let t = vol / 0.0061; + let voltage = 1.0 - 0.8 * gauss(vol, 3.5, 0.035); + engine.on_adc(i, t, voltage); + let amplitude = 1.0 + 60.0 * gauss(vol, 1.0, 0.03); + engine.on_spectrum(&[amplitude, 1.0, 1.0, 1.0]); + let outcome = engine.poll(); + if outcome.first_endpoint.is_some() { + t1 = Some((outcome.first_endpoint.unwrap(), outcome.conflict_at_t1)); + break; + } + } + let (vol, conflict) = t1.expect("conflict + potential evidence 必须 T=1"); + assert!(conflict, "KF 门控应拒绝 2.5 mL 偏差 → conflict"); + assert!((vol - 3.5).abs() < 0.4, "conflict 退回电位终点 {vol}"); + assert_eq!(engine.state, TitrationState::Degree1); +} + +#[test] +fn manual_stop_refines_and_completes() { + let mut engine = engine(); + drive(&mut engine, 200, 1.0, 1.0, true); // 到 T=1 即返回 + // AMPD 需要足够历史(峰位须被多尺度覆盖;短记录返回 None 属正常行为, + // 与 Python 一致),继续滴到 2.5 mL 再手动停止。 + for i in 201..=250u32 { + let vol = i as f64 * 0.01; + let t = vol / 0.0061; + let voltage = 1.0 - 0.8 * gauss(vol, 1.0, 0.035); + engine.on_adc(i, t, voltage); + engine.on_spectrum(&[1.0, 1.0, 1.0, 1.0]); + } + assert!(engine.can_manual_stop()); + let outcome = engine.manual_stop(); + assert_eq!(outcome.state, TitrationState::Done); + assert!(matches!(outcome.commands[..], [PumpCommand::FreeStop(2)])); + let refined = outcome.refined_endpoint.expect("AMPD refinement"); + assert!((refined - 1.0).abs() < 0.2, "refined {refined}"); + assert!(!engine.can_manual_stop()); +} + +#[test] +fn abort_returns_to_idle() { + let mut engine = engine(); + drive(&mut engine, 150, 1.0, 1.0, false); + let outcome = engine.abort(); + assert_eq!(outcome.state, TitrationState::Idle); + assert!(outcome.commands.is_empty()); + assert_eq!(engine.endpoint_volume(), None); +} diff --git a/TController/scripts/validate_endpoint.py b/TController/scripts/validate_endpoint.py deleted file mode 100644 index cc2c3b7..0000000 --- a/TController/scripts/validate_endpoint.py +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env python3 -"""TController 滴定终点检测算法验证(高效版)。""" - -from __future__ import annotations - -import argparse -import sys -import warnings -from pathlib import Path - -import numpy as np -import openpyxl - -warnings.filterwarnings("ignore") - -PROJ = Path(__file__).resolve().parents[1] -SRC = PROJ / "src" -sys.path.insert(0, str(SRC)) - -from DataProcessor.calibration import FLOW_RATE, update_from_file - -parser = argparse.ArgumentParser(description="TController 滴定终点检测算法验证(离线回放)") -parser.add_argument( - "--input", - type=Path, - required=True, - help="滴定数据 xlsx(含「电位-体积曲线」「光谱数据」两个 sheet)", -) -args = parser.parse_args() -DATA_FILE = args.input -OUT_DIR = PROJ / "data" / "validation" -OUT_DIR.mkdir(parents=True, exist_ok=True) - -update_from_file() -print(f"流速: {FLOW_RATE:.6f} mL/s") - -# ═══════════════════════════════════════════════════════════════════════ -# 1. Load & preprocess -# ═══════════════════════════════════════════════════════════════════════ - -print("\n[1] 加载数据…") -wb = openpyxl.load_workbook(str(DATA_FILE), read_only=True) -rows_p = list(wb["电位-体积曲线"].iter_rows(min_row=2, values_only=True)) -rows_s = list(wb["光谱数据"].iter_rows(min_row=2, values_only=True)) -wb.close() - -pot_vol = np.array([r[0] for r in rows_p], dtype=np.float64) -pot_val = np.array([r[1] for r in rows_p], dtype=np.float64) -spec_vol = np.array([r[0] for r in rows_s], dtype=np.float64) -spec_8 = np.array([list(r[1:9]) for r in rows_s], dtype=np.float64) - - -def group_mean(vol, vals): - u, inv, cnt = np.unique(np.round(vol, 6), return_inverse=True, return_counts=True) - if vals.ndim == 1: - m = np.bincount(inv, weights=vals) / cnt - else: - m = np.column_stack( - [np.bincount(inv, weights=vals[:, c]) / cnt for c in range(vals.shape[1])] - ) - return u, m - - -uvol_p, mpot = group_mean(pot_vol, pot_val) -uvol_s, mspec = group_mean(spec_vol, spec_8) -print(f" 电位: {len(uvol_p)} 步 ({uvol_p[0]:.4f}–{uvol_p[-1]:.4f} mL)") -print(f" 光谱: {len(uvol_s)} 步") - -print("[2] 对齐 & 重建光谱…") -mspec_interp = np.column_stack( - [np.interp(uvol_p, uvol_s, mspec[:, c]) for c in range(8)] -) - -calib = np.load(str(PROJ / "data" / "calibre.npz"), allow_pickle=True) -mat, ofs, fac = ( - calib["spectral_matrix"], - calib["spectral_offsets"], - calib["spectral_factors"], -) -full = np.zeros((len(mspec_interp), 10), dtype=np.float64) -full[:, :8] = mspec_interp -corrected = fac * np.maximum(full - ofs, 0.0) -spectra_721 = corrected @ mat.T -print( - f" 全光谱: {spectra_721.shape} ({np.sum(spectra_721 < 0)}/{spectra_721.size} 负值)" -) - -t = uvol_p / FLOW_RATE -N = len(uvol_p) - -# ═══════════════════════════════════════════════════════════════════════ -# 2. AMPD analysis (efficient with limited max_scale) -# ═══════════════════════════════════════════════════════════════════════ - -print("\n[3] 电位通道分析 (savgol w=15, AMPD max_scale=200)…") -from DataProcessor.endpoint import savgol_filter - - -def ampd_limited(signal, max_scale=200): - Ns = len(signal) - if Ns < 4: - return np.array([], dtype=int) - L = min(max_scale, Ns // 2) - LMS = np.ones((L - 1, Ns), dtype=np.int32) - for k in range(2, L + 1): - r = np.ones(Ns, dtype=np.int32) - for i in range(k, Ns - k): - if signal[i] > signal[i - k] and signal[i] >= signal[i + k]: - r[i] = 0 - LMS[k - 2] = r - row_sums = LMS.sum(axis=1) - # Find best scale from first 25% to avoid edge-effect artifacts - search = max(1, int((L - 1) * 0.25)) - best_k = int(np.argmin(row_sums[:search])) + 2 - peaks = np.where(LMS[best_k - 2] == 0)[0] - return peaks - - -dv = np.diff(mpot) -dt = np.diff(t) -with np.errstate(divide="ignore", invalid="ignore"): - deriv = np.where(dt > 0, dv / dt, 0.0) -deriv_sm = savgol_filter(deriv, window=15, order=2) -peaks_ampd = ampd_limited(deriv_sm, max_scale=200) - -vol_mid = (uvol_p[:-1] + uvol_p[1:]) / 2 -print(f" AMPD: {len(peaks_ampd)} 峰 (总 {len(deriv_sm)} 点)") -if len(peaks_ampd): - pv = deriv_sm[peaks_ampd] - top3 = np.argsort(np.abs(pv))[-3:][::-1] - print(" Top-3 (|dV/dt|):") - for idx in top3: - print(f" vol={vol_mid[peaks_ampd[idx]]:.4f} mL dV/dt={pv[idx]:+.2f}") -else: - top3 = np.array([], dtype=int) - -# ═══════════════════════════════════════════════════════════════════════ -# 3. Spectral cross-entropy analysis -# ═══════════════════════════════════════════════════════════════════════ - -print("\n[4] 光谱通道分析…") - - -def cross_entropy(arr): - p = arr[1:].astype(np.float64) - q = arr[:-1].astype(np.float64) - p /= p.sum(axis=1, keepdims=True) + 1e-12 - q /= q.sum(axis=1, keepdims=True) + 1e-12 - return -np.sum(p * np.log(np.maximum(q, 1e-12)), axis=1) - - -ce_8 = cross_entropy(mspec_interp) -ce_721 = cross_entropy(spectra_721) - -for label, ce in [("8ch-raw", ce_8), ("721-pt", ce_721)]: - w = min(15, len(ce) if len(ce) % 2 else len(ce) - 1) - ce_sm = savgol_filter(ce, window=w, order=2) if w >= 5 else ce - lm = ampd_limited(ce_sm) - print(f" {label}: CE∈[{ce.min():.6e}, {ce.max():.6e}], {len(lm)} 局部极大值") - if len(lm): - vals = ce_sm[lm] - top = np.argsort(vals)[-3:][::-1] - for idx in top: - print(f" vol={vol_mid[lm[idx]]:.4f} mL CE={vals[idx]:.6e}") - -# ═══════════════════════════════════════════════════════════════════════ -# 4. Run EndpointDetector with monkey-patched AMPD -# ═══════════════════════════════════════════════════════════════════════ - -print("\n[5] 运行 EndpointDetector (patched AMPD)…") -import DataProcessor.endpoint as ep_mod - -ep_mod._ampd_peak_idx = lambda s: (ampd_limited(s)[0] if len(ampd_limited(s)) else None) - -from DataProcessor.endpoint import EndpointDetector - -det = EndpointDetector(flow_rate=FLOW_RATE) - -stride = 5 -for i in range(0, N, stride): - det.feed_potential(float(uvol_p[i]), float(t[i]), float(mpot[i])) - det.feed_spectrum(float(uvol_p[i]), spectra_721[i]) - -result = det.detect() - -# ═══════════════════════════════════════════════════════════════════════ -# 5. Visualization -# ═══════════════════════════════════════════════════════════════════════ - -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -fig, axes = plt.subplots(3, 1, figsize=(14, 14), sharex=True) -fig.suptitle("TController 滴定终点检测算法验证", fontsize=15, fontweight="bold") - -ax = axes[0] -ax.plot(uvol_p, mpot, "b-", lw=0.6, alpha=0.7, label="电位 (LSB)") -ax.set_ylabel("电位 (LSB)") -ax.set_title("(a) 电位 — 体积曲线") -ax.grid(True, alpha=0.3) -ax.legend(loc="upper right") - -ax = axes[1] -ax.plot(vol_mid, deriv_sm, "g-", lw=1, label="dV/dt (savgol w=15)") -if len(peaks_ampd): - ax.scatter( - vol_mid[peaks_ampd], - deriv_sm[peaks_ampd], - c="red", - s=20, - zorder=5, - label=f"AMPD 峰 ({len(peaks_ampd)})", - alpha=0.6, - ) - for idx in top3: - ax.axvline(vol_mid[peaks_ampd[idx]], color="red", ls="--", lw=1, alpha=0.4) -ax.axhline(0, color="gray", lw=0.5) -ax.set_ylabel("dV/dt (LSB/s)") -ax.set_title("(b) 电位一阶导数 + AMPD (max_scale=200, window=15)") -ax.grid(True, alpha=0.3) -ax.legend(loc="upper right") - -ax = axes[2] -ce_label = "8ch 交叉熵 (savgol w=15)" -ax.plot(vol_mid, savgol_filter(ce_8, window=15, order=2), "m-", lw=1, label=ce_label) -lm_ce8 = ampd_limited(savgol_filter(ce_8, window=15, order=2)) -if len(lm_ce8): - ax.scatter( - vol_mid[lm_ce8], - savgol_filter(ce_8, window=15, order=2)[lm_ce8], - c="red", - s=20, - zorder=5, - label=f"局部极大值 ({len(lm_ce8)})", - alpha=0.6, - ) -ax.set_ylabel("交叉熵") -ax.set_title("(c) 光谱 8通道交叉熵") -ax.set_xlabel("体积 (mL)") -ax.grid(True, alpha=0.3) -ax.legend(loc="upper right") - -# summary -lines = ["滴定终点检测结果", "─" * 32, ""] -if result: - lines.append(f"终点体积: {result['volume']:.4f} mL") - lines.append(f"置信度: {result['confidence']}") - lines.append(f"方法: {result['method']}") - if result.get("potential"): - lines.append(f"电位峰: {result['potential']['volume']:.4f} mL") - if result.get("spectral"): - lines.append(f"光谱峰: {result['spectral']['volume']:.4f} mL") -lines.append("") -lines.append("AMPD max_scale=200") -lines.append(f"数据 {N}步 | 流速 {FLOW_RATE:.6f}") - -bbox = {"boxstyle": "round,pad=0.5", "fc": "lightyellow", "alpha": 0.9} -fig.text( - 0.70, - 0.92, - "\n".join(lines), - fontfamily="monospace", - fontsize=9, - va="top", - bbox=bbox, - transform=fig.transFigure, -) - -plt.tight_layout(rect=(0, 0, 1, 0.92)) -out = OUT_DIR / "validation.png" -plt.savefig(str(out), dpi=150, bbox_inches="tight") -plt.close() -print(f"\n可视化: {out}") - -print("\n" + "=" * 60) -print("最终检测结果") -print("=" * 60) -if result: - icons = {"high": "✅", "medium": "⚠️", "low": "❌"} - print(f" {icons.get(result['confidence'], '❓')} 终点: {result['volume']:.4f} mL") - print(f" 置信度: {result['confidence']} | 方法: {result['method']}") - if result.get("warning"): - print(f" 警告: {result['warning']}") - if result.get("potential"): - p = result["potential"] - print(f" 电位: {p['volume']:.4f} mL ({p['peak_count']} peaks)") - if result.get("spectral"): - s = result["spectral"] - print(f" 光谱: {s['volume']:.4f} mL") -else: - print(" ❌ 未检测到终点") diff --git a/TController/scripts/validate_online.py b/TController/scripts/validate_online.py deleted file mode 100644 index 015d932..0000000 --- a/TController/scripts/validate_online.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env python3 -""" -在线滴定终点检测 — 实时 matplotlib 绘图版。 - -每帧跳过 stride 步再刷新,控制播放速度。 -""" - -from __future__ import annotations - -import argparse -import sys -import warnings -from pathlib import Path - -import numpy as np -import openpyxl - -warnings.filterwarnings("ignore") - -PJ = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PJ / "src")) -from DataProcessor.calibration import FLOW_RATE, update_from_file - -update_from_file() - -parser = argparse.ArgumentParser(description="在线滴定终点检测 — 实时回放验证") -parser.add_argument( - "--input", - type=Path, - required=True, - help="滴定数据 xlsx(含「电位-体积曲线」sheet)", -) -args = parser.parse_args() -DATA = args.input - -print("[1] 加载数据 …") -wb = openpyxl.load_workbook(str(DATA), read_only=True) -rows = list(wb["电位-体积曲线"].iter_rows(min_row=2, values_only=True)) -wb.close() -vol = np.array([r[0] for r in rows], dtype=np.float64) -pot = np.array([r[1] for r in rows], dtype=np.float64) -u, inv, cnt = np.unique(np.round(vol, 6), return_inverse=True, return_counts=True) -mp = np.bincount(inv, weights=pot) / cnt -N = len(u) -T = u / FLOW_RATE -print(f" 电位: {N} 步, {u[0]:.4f}–{u[-1]:.4f} mL") - - -# ── 在线检测器 ───────────────────────────────────────────────────────── -class OD: - POT_ENTER = -80 - POT_EXIT = -15 - POT_MIN_VOL = 0.5 - POT_CONFIRM_VOL = 0.15 - - def __init__(self, fr): - self.fr = fr - self.va = 0.15 - self.da = 0.05 - self.vs = None - self.pvs = None - self.pt = None - self.ds = 0.0 - self.ps = "IDLE" - self.pe = None - self.md = 0.0 - self.cv = None - self.ev = None - self.pd = False - self.vh = [] - self.dh = [] - self.sh = [] - - def feed(self, t0, v0): - v = t0 * self.fr - if self.vs is None: - self.vs = float(v0) - else: - self.vs = self.va * float(v0) + (1 - self.va) * self.vs - if self.pt is not None and self.pvs is not None: - dt = t0 - self.pt - dv = self.vs - self.pvs - dr = dv / dt if dt > 0 else 0 - else: - dr = 0 - self.ds = self.da * dr + (1 - self.da) * self.ds - self.pvs = self.vs - self.pt = t0 - if not self.pd and v > self.POT_MIN_VOL: - if self.ps == "IDLE": - if self.ds < self.POT_ENTER: - self.ps = "TRACKING" - self.md = self.ds - self.cv = v - self.ev = v - elif self.ps == "TRACKING": - if self.ds < self.md: - self.md = self.ds - self.cv = v - if self.ds > self.POT_EXIT and (v - self.ev) > self.POT_CONFIRM_VOL: - self.pe = self.cv - self.ps = "END_CONFIRMED" - self.pd = True - self.vh.append(v) - self.dh.append(self.ds) - self.sh.append(self.ps) - - -# ── 预计算完整 dV/dt(画背景用) ──────────────────────────────────── -bd = np.zeros(N) -vs = None -pvs = None -pt = None -ds = 0.0 -for i in range(N): - v0 = float(mp[i]) - t0 = float(T[i]) - if vs is None: - vs = v0 - else: - vs = 0.15 * v0 + 0.85 * vs - if pt is not None and pvs is not None: - dt = t0 - pt - dv = vs - pvs - bd[i] = dv / dt if dt > 0 else 0 - else: - bd[i] = 0 - pvs = vs - pt = t0 - ds = 0.05 * bd[i] + 0.95 * ds - bd[i] = ds - -# ── matplotlib 实时绘图 ───────────────────────────────────────────────── -import matplotlib - -matplotlib.use("TkAgg") -import matplotlib.pyplot as plt - -plt.rcParams["font.size"] = 9 -fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 7), sharex=True) -fig.canvas.manager.set_window_title("Titration - Online Endpoint Detection") # type: ignore[union-attr] - -for ax in (ax1, ax2): - ax.set_xlim(0, 2.25) - ax.grid(True, alpha=0.3) -ax1.set_ylim(29500, 34000) -ax1.set_ylabel("Potential (LSB)") -ax2.set_ylim(-350, 50) -ax2.set_ylabel("dV/dt (LSB/s)") -ax2.set_xlabel("Volume (mL)") -ax2.axhline(-80, color="gray", ls=":", lw=1, alpha=0.6, label="Enter threshold") -ax2.axhline(-15, color="gray", ls="-.", lw=1, alpha=0.6, label="Exit threshold") -ax2.axhline(0, color="black", lw=0.5) -for ax in (ax1, ax2): - ax.axvline( - 1.089, color="gray", ls=":", lw=1, alpha=0.35, label="Ground truth ~1.089" - ) -ax1.plot(u, mp, "b-", lw=0.4, alpha=0.12) -ax2.plot(u, bd, "g-", lw=0.4, alpha=0.12) - -(lp,) = ax1.plot([], [], "b-", lw=1, label="Potential") -(ld,) = ax2.plot([], [], "g-", lw=1, label="dV/dt (EWMA)") -(st,) = ax2.plot([], [], "o", c="orange", ms=3, alpha=0.6, label="TRACKING") -(sc,) = ax2.plot([], [], "o", c="red", ms=3, alpha=0.6, label="CONFIRMED") -ep = ax2.axvline(0, color="red", ls="--", lw=2, alpha=0, label="Endpoint") -tx = ax1.text( - 0.02, - 0.97, - "", - transform=ax1.transAxes, - fontfamily="monospace", - fontsize=9, - va="top", - bbox={"boxstyle": "round,pad=0.3", "fc": "lightyellow", "alpha": 0.9}, -) -for ax in (ax1, ax2): - ax.legend(fontsize=8, loc="lower right") - -# ── 逐点回放(每 stride 步刷新一次) ───────────────────────────────── -stride = 50 # 每 stride 步刷新一次画面 -interval_s = 0.01 # 帧间隔秒数 - -print(f"\n[2] 逐点回放 … stride={stride}, 帧间隔={interval_s}s") -print(f" 理论时长 ≈ {N / stride * interval_s:.0f}s") -plt.ion() -plt.show() - -det = OD(FLOW_RATE) -for i in range(N): - det.feed(float(T[i]), float(mp[i])) - # 仅当 stride 整数倍或最后一步时刷新 - if (i + 1) % stride != 0 and i != N - 1: - continue - vs = np.array(det.vh) - ds = np.array(det.dh) - ss = np.array(det.sh) - lp.set_data(vs, mp[: len(vs)]) - ld.set_data(vs, ds) - trk = np.where(ss == "TRACKING")[0] - cnf = np.where(ss == "END_CONFIRMED")[0] - st.set_data(vs[trk] if len(trk) else [], ds[trk] if len(trk) else []) - sc.set_data(vs[cnf] if len(cnf) else [], ds[cnf] if len(cnf) else []) - ep.set_alpha(1) if det.pe is not None else ep.set_alpha(0) - if det.pe is not None: - ep.set_xdata([det.pe, det.pe]) - txt = ( - f"Step {i + 1}/{N} Vol = {u[i]:.4f} mL\nState: {det.ps} dV/dt = {det.ds:.1f}" - ) - if det.pe: - txt += f"\nENDPOINT = {det.pe:.4f} mL" - elif det.cv and det.ps != "IDLE": - txt += f"\nCandidate = {det.cv:.4f} mL" - tx.set_text(txt) - fig.canvas.draw_idle() - fig.canvas.start_event_loop(interval_s) - -plt.ioff() -print(f"\n✅ 检测完成!终点 = {det.pe} mL") -fig.suptitle( - f"Detection Complete — Endpoint = {det.pe:.4f} mL", fontsize=13, fontweight="bold" -) -fig.canvas.draw_idle() -plt.show(block=True) diff --git a/TController/scripts/validate_online_multimodal.py b/TController/scripts/validate_online_multimodal.py deleted file mode 100644 index d680063..0000000 --- a/TController/scripts/validate_online_multimodal.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -"""Replay a titration workbook through the causal TController detector. - -By default rows are aggregated to one mean sample per volume, which is convenient -but hides a production path: the firmware reports a spectrum per AS7341 frame while -the volume comes from the pump, so several spectra share one volume and the tracker -must hold its volume-normalised speed instead of normalising by a zero step. -Aggregated replays report ``repeated_spectral_volume=0`` and therefore never -exercise that path. Pass ``--raw-frames`` to feed every row at its own volume. -""" - -from __future__ import annotations - -import argparse -import sys -from collections.abc import Iterator -from pathlib import Path -from typing import Any - -import numpy as np -import openpyxl - -PROJ = Path(__file__).resolve().parents[1] -SRC = PROJ / "src" -sys.path.insert(0, str(SRC)) - -from DataProcessor.endpoint import EndpointDetector - -# One replay event: volume, potential in ADC LSB (or None), spectrum (or None). -Event = tuple[float, float | None, np.ndarray | None] - - -def load_rows(path: Path) -> tuple[list[tuple[float, float]], list[tuple[float, np.ndarray]]]: - """Return the raw (volume, potential) and (volume, spectrum) rows, duplicates kept.""" - workbook = openpyxl.load_workbook(path, read_only=True, data_only=True) - potential_rows: list[tuple[float, float]] = [] - spectrum_rows: list[tuple[float, np.ndarray]] = [] - for row in workbook["电位-体积曲线"].iter_rows(min_row=2, values_only=True): - if row[0] is not None and row[1] is not None: - potential_rows.append((round(float(str(row[0])), 6), float(str(row[1])))) - for row in workbook["光谱数据"].iter_rows(min_row=2, values_only=True): - if row[0] is not None and all(value is not None for value in row[1:9]): - spectrum_rows.append( - ( - round(float(str(row[0])), 6), - np.asarray([float(str(value)) for value in row[1:9]], dtype=np.float64), - ) - ) - workbook.close() - return potential_rows, spectrum_rows - - -def _aggregated_events( - potential_rows: list[tuple[float, float]], spectrum_rows: list[tuple[float, np.ndarray]] -) -> Iterator[Event]: - """One mean potential and one mean spectrum per shared volume.""" - potentials: dict[float, list[float]] = {} - spectra: dict[float, list[np.ndarray]] = {} - for volume, potential in potential_rows: - potentials.setdefault(volume, []).append(potential) - for volume, spectrum in spectrum_rows: - spectra.setdefault(volume, []).append(spectrum) - for volume in sorted(set(potentials) & set(spectra)): - yield ( - volume, - float(np.mean(potentials[volume])), - np.mean(spectra[volume], axis=0), - ) - - -def _raw_events( - potential_rows: list[tuple[float, float]], spectrum_rows: list[tuple[float, np.ndarray]] -) -> Iterator[Event]: - """Every row at its own volume, merged on volume so both streams stay causal.""" - potentials = sorted(potential_rows, key=lambda row: row[0]) - spectra = sorted(spectrum_rows, key=lambda row: row[0]) - index = 0 - for volume, spectrum in spectra: - while index < len(potentials) and potentials[index][0] <= volume: - yield (potentials[index][0], potentials[index][1], None) - index += 1 - yield (volume, None, spectrum) - for pot_volume, potential in potentials[index:]: - yield (pot_volume, potential, None) - - -def replay(path: Path, flow_rate: float, raw_frames: bool = False) -> dict[str, Any]: - potential_rows, spectrum_rows = load_rows(path) - builder = _raw_events if raw_frames else _aggregated_events - detector = EndpointDetector(flow_rate=flow_rate) - feature_rows: list[dict[str, Any]] = [] - potential_scale = 3.3 / 65535.0 - volumes: list[float] = [] - for volume, potential, spectrum in builder(potential_rows, spectrum_rows): - if potential is not None: - # The detector's production contract is volts; workbooks store ADC LSB. - detector.feed_potential( - volume, volume / flow_rate, potential * potential_scale - 1.1 - ) - if spectrum is not None: - detector.feed_spectrum(volume, spectrum) - feature_rows.append(detector.diagnostics()) - volumes.append(volume) - - spectral_rows = [row["spectral_features"] for row in feature_rows] - valid = [row for row in spectral_rows if row.get("valid_frame")] - result = detector.detect() - return { - "path": str(path), - "samples": len(volumes), - "volume_range": (min(volumes), max(volumes)) if volumes else (0.0, 0.0), - "result": result, - "detector": detector, - "spectral_rows": valid, - } - - -def _peak(rows: list[dict[str, Any]], key: str) -> tuple[float, float] | None: - values = [(float(row["volume"]), float(row.get(key, 0.0))) for row in rows if row.get("volume") is not None] - return max(values, key=lambda item: item[1]) if values else None - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--input", type=Path, required=True, help="Workbook with the two titration sheets") - parser.add_argument("--flow-rate", type=float, default=0.00603752, help="Pump flow in mL/s") - parser.add_argument( - "--raw-frames", - action="store_true", - help="Feed every row separately instead of one mean per volume, so repeated " - "volumes reach the tracker the way they do in production", - ) - args = parser.parse_args() - report = replay(args.input, args.flow_rate, raw_frames=args.raw_frames) - result = report["result"] - rows = report["spectral_rows"] - quality = (result or {}).get("reliability", {}).get("data_quality", {}) - print(f"input={report['path']} raw_frames={args.raw_frames}") - print(f"samples={report['samples']} volume={report['volume_range'][0]:.6f}..{report['volume_range'][1]:.6f} mL") - print( - f"repeated_volume={quality.get('repeated_spectral_volume')} " - f"nonmonotonic={quality.get('nonmonotonic_volume')}" - ) - print(f"js_speed_peak={_peak(rows, 'js_speed')}") - print(f"cross_curvature_peak={_peak(rows, 'cross_curvature')}") - print(f"potential_state={report['detector'].potential_state} spectral_state={report['detector'].spectral_state}") - print(f"result={result}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/TController/src/Communication/__init__.py b/TController/src/Communication/__init__.py deleted file mode 100644 index 70a7294..0000000 --- a/TController/src/Communication/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""AutoTitrator 通信协议封装包。 - -提供 ProtocolHandler,封装上下位机间的 -二进制帧协议(上行 AA 55 / 下行 BB 55 + CRC8 Checksum)。 - -典型用法:: - - from Communication import ProtocolHandler - com = ProtocolHandler(port="/dev/ttyUSB0", baudrate=115200) - com.on("spectral", on_spectral) - com.connect() - com.poll() # GUI 主循环中周期性调用 -""" - -from Communication.protocol import ProtocolHandler - -__all__ = ["ProtocolHandler"] diff --git a/TController/src/Communication/protocol.py b/TController/src/Communication/protocol.py deleted file mode 100644 index fb5683d..0000000 --- a/TController/src/Communication/protocol.py +++ /dev/null @@ -1,538 +0,0 @@ -""" -ProtocolHandler — 封装上下位机通信协议(threading + queue,无 Qt 依赖)。 - -上行帧 (MCU → Host):: - AA 55 | 类型(1B) | 数据(NB) | CRC8(类型+数据) - -下行帧 (Host → MCU):: - BB 55 | 命令(1B) | 参数(NB) | CRC8(命令+参数) - -使用方法:: - - com = ProtocolHandler(port="/dev/ttyUSB0", baudrate=115200) - com.on("spectral", on_spectral) - com.on("adc", on_adc) - com.connect() - com.send_frerun(1) - - # GUI 主循环中轮询事件 - com.poll() -""" - -from __future__ import annotations - -import queue -import threading -import time -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass -from enum import IntEnum, auto -from typing import Any - -import serial - -# ---- CRC-8 (Maxim-Dallas, poly = 0x31) ---- - - -def _crc8_update(crc: int, data: int) -> int: - crc ^= data - for _ in range(8): - crc = ((crc << 1) ^ 0x31) if (crc & 0x80) else (crc << 1) - return crc & 0xFF - - -def _crc8(data: bytes) -> int: - crc = 0 - for b in data: - crc = _crc8_update(crc, b) - return crc - - -# ---- 上行帧类型 & 载荷长度 ---- - -_UPLINK: dict[int, int] = { - 0x00: 1, # ACK — echo_cmd(1) - 0x01: 1, # NAK — echo_cmd(1) - 0x10: 5, # PumpPos — pump_id(1) + position(4) LE - 0x11: 5, # PumpDone — pump_id(1) + position(4) LE - 0x20: 11, # ADC — sum(4) + samples(2) + shift(1) + pump2_pos(4) - 0x30: 22, # Spectral — 10 x uint16 LE + reserved(2) - 0x40: 4, # Heartbeat — uptime_ms(4) -} - -# ---- 下行命令 & 参数长度 ---- - -_DOWNLINK: dict[int, int] = { - 0x01: 5, # MaxCount — pump_id(1) + count(4) - 0x02: 1, # FreeRun — pump_id(1) - 0x03: 1, # FreeStop — pump_id(1) - 0x04: 1, # AbortAll — pump_id(1), 0xFF=全部 - 0x05: 1, # Heartbeat — 0x01=enable watchdog - 0x06: 0, # Reset — 无载荷 -} - -# ---- FIFO 上限 ---- - -FIFO_SPECTRAL_MAX = 4096 -FIFO_ADC_MAX = 16384 -FIFO_RAW_CHUNKS = 512 - - -# ====================================================================== -# 事件(替代 Qt Signal) -# ====================================================================== - - -@dataclass -class _Event: - """通信事件,由后台线程写入队列,由 poll() 在主线程消费。""" - - kind: str - """事件类型:connected / disconnected / error / spectral / adc / ack / nak / pump_done / pump1_progress / pump2_progress / heartbeat""" - - data: Any = None - """事件数据,类型随 kind 变化。""" - - -# ====================================================================== -# 上行帧解析器(状态机) -# ====================================================================== - - -class _State(IntEnum): - SYNC = auto() - TYPE = auto() - DATA = auto() - CHECKSUM = auto() - - -class _UplinkParser: - """逐字节状态机,解析 AA 55 上行帧。""" - - def __init__(self) -> None: - self.reset() - - def reset(self) -> None: - self._state = _State.SYNC - self._type = 0 - self._data = bytearray() - self._data_len = 0 - self._buf = bytearray() - - def feed(self, data: bytes) -> list[tuple[int, bytes]]: - """喂入字节流,返回本批次解析出的 (type, payload) 列表。""" - frames: list[tuple[int, bytes]] = [] - for b in data: - frame = self._feed_byte(b) - if frame is not None: - frames.append(frame) - return frames - - def _feed_byte(self, b: int) -> tuple[int, bytes] | None: - if self._state == _State.SYNC: - if b == 0xAA: - self._buf.append(b) - elif b == 0x55 and self._buf and self._buf[-1] == 0xAA: - self._state = _State.TYPE - self._buf.clear() - else: - self._buf.clear() - - elif self._state == _State.TYPE: - self._type = b - self._data_len = _UPLINK.get(b, 0xFFFF) - if self._data_len == 0xFFFF: - self.reset() - return None - self._data.clear() - self._state = _State.CHECKSUM if self._data_len == 0 else _State.DATA - - elif self._state == _State.DATA: - self._data.append(b) - if len(self._data) >= self._data_len: - self._state = _State.CHECKSUM - - elif self._state == _State.CHECKSUM: - cs = _crc8(bytes([self._type]) + bytes(self._data)) - result: tuple[int, bytes] | None = None - if cs == b: - result = (self._type, bytes(self._data)) - self.reset() - return result - - return None - - -# ====================================================================== -# 后台串口读取线程 -# ====================================================================== - - -class _SerialReader(threading.Thread): - """后台读取串口数据、解析上行帧并写入事件队列。""" - - def __init__(self, event_queue: queue.Queue[_Event]) -> None: - super().__init__(daemon=True) - self._port: serial.Serial | None = None - self._running = False - self._parser = _UplinkParser() - self._event_queue = event_queue - - self.spectral_queue: deque[list[int]] = deque(maxlen=FIFO_SPECTRAL_MAX) - self.adc_queue: deque[int] = deque(maxlen=FIFO_ADC_MAX) - self.raw_queue: deque[bytes] = deque(maxlen=FIFO_RAW_CHUNKS) - - # ---- 公开接口 ---- - - def open(self, port_name: str, baudrate: int) -> None: - if self._port and self._port.is_open: - self.close() - try: - self._port = serial.Serial( - port=port_name, - baudrate=baudrate, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=0.05, - ) - self._parser.reset() - self._running = True - if not self.is_alive(): - self.start() - self._event_queue.put(_Event("connected")) - except serial.SerialException as exc: - self._event_queue.put(_Event("error", str(exc))) - - def close(self) -> None: - self._running = False - if self._port and self._port.is_open: - try: - self._port.close() - except Exception: - pass - self._port = None - self._event_queue.put(_Event("disconnected")) - - @property - def is_open(self) -> bool: - return self._port is not None and self._port.is_open - - def write(self, data: bytes) -> None: - if self._port and self._port.is_open: - self._port.write(data) - - # ---- 帧分发 ---- - - @staticmethod - def _u32(payload: bytes, off: int) -> int: - return ( - payload[off] - | (payload[off + 1] << 8) - | (payload[off + 2] << 16) - | (payload[off + 3] << 24) - ) - - def _on_frame(self, typ: int, payload: bytes) -> None: - if typ == 0x00 and len(payload) == 1: - self._event_queue.put(_Event("ack", payload[0])) - elif typ == 0x01 and len(payload) == 1: - self._event_queue.put(_Event("nak", payload[0])) - elif typ == 0x10 and len(payload) == 5: - pump_id = payload[0] - pos = self._u32(payload, 1) - kind = "pump1_progress" if pump_id == 1 else "pump2_progress" - self._event_queue.put(_Event(kind, pos)) - elif typ == 0x11 and len(payload) == 5: - pump_id = payload[0] - pos = self._u32(payload, 1) - self._event_queue.put(_Event("pump_done", (pump_id, pos))) - elif typ == 0x20 and len(payload) == 11: - acc = self._u32(payload, 0) - shift = payload[6] - val = (acc >> shift) & 0xFFFF - pos = self._u32(payload, 7) - self.adc_queue.append(val) - self._event_queue.put(_Event("adc", (val, pos))) - elif typ == 0x30 and len(payload) == 22: - # 前 20 字节为 F1..F8/Clear/NIR,末 2 字节保留 - vals = [payload[i] | (payload[i + 1] << 8) for i in range(0, 20, 2)] - self.spectral_queue.append(vals) - self._event_queue.put(_Event("spectral", vals)) - elif typ == 0x40 and len(payload) == 4: - self._event_queue.put(_Event("heartbeat", self._u32(payload, 0))) - - # ---- 线程主循环 ---- - - def run(self) -> None: - while self._running and self._port and self._port.is_open: - try: - if self._port.in_waiting: - raw = self._port.read(self._port.in_waiting) - self.raw_queue.append(raw) - frames = self._parser.feed(raw) - for typ, payload in frames: - self._on_frame(typ, payload) - else: - time.sleep(0.005) - except serial.SerialException as exc: - self._event_queue.put(_Event("error", str(exc))) - break - except Exception as exc: - self._event_queue.put(_Event("error", str(exc))) - break - self.close() - - -# ====================================================================== -# 协议封装(对外接口) -# ====================================================================== - - -class ProtocolHandler: - """封装上下位机串口通信协议(threading + queue,无 Qt 依赖)。 - - 串口参数通过构造函数传入,调用 connect() 建立连接。 - 接收数据通过 on() 注册回调 + poll() 轮询,发送命令 - 通过 send_xxx() 方法。 - """ - - def __init__(self, port: str = "", baudrate: int = 115200) -> None: - self._port_name = port - self._baudrate = baudrate - self._event_queue: queue.Queue[_Event] = queue.Queue() - self._reader = _SerialReader(self._event_queue) - - # 用户回调 - self._callbacks: dict[str, list[Callable[[Any], None]]] = {} - - # 单次回调(替代 Qt SingleShotConnection) - self._pump_done_once: Callable[[tuple[int, int]], None] | None = None - - # ACK/NAK 重试 - self._pending_cmd: bytes | None = None - self._pending_cmd_id: int | None = None - self._retry_count: int = 0 - self._max_retries: int = 5 - self._backoff_ms: int = 50 - self._ack_received: bool = False - self._timeout_flag: bool = False - self._first_timeout_timer: threading.Timer | None = None - self._retry_timer: threading.Timer | None = None - - # ---- 回调注册 ---- - - def on(self, kind: str, callback: Callable[[Any], None]) -> None: - """注册事件回调。 - - kind 取值:connected / disconnected / error / spectral / adc / - ack / nak / pump_done / pump1_progress / pump2_progress / heartbeat - - pump_done 事件数据为 (pump_id, position) 元组。 - """ - self._callbacks.setdefault(kind, []).append(callback) - - def request_pump_done_once( - self, callback: Callable[[tuple[int, int]], None] - ) -> None: - """注册单次 pump_done 回调(触发后自动清除,替代 SingleShotConnection)。 - - 回调参数为 (pump_id, position) 元组。 - """ - self._pump_done_once = callback - - # ---- 事件轮询(GUI 主线程调用)---- - - def poll(self) -> None: - """排空事件队列,在 GUI 主线程中周期性调用(如 root.after(50, poll))。""" - # 检查首包超时标志(由 Timer 线程设置) - if self._timeout_flag: - self._timeout_flag = False - if not self._ack_received and self._pending_cmd is not None: - self._handle_nak(self._pending_cmd_id) - - # 排空事件队列 - while True: - try: - event = self._event_queue.get_nowait() - except queue.Empty: - break - self._dispatch(event) - - def _dispatch(self, event: _Event) -> None: - # 内部事件先处理 - if event.kind == "ack": - self._on_ack(event.data) - elif event.kind == "nak": - self._handle_nak(event.data) - - # 单次 pump_done 回调 - if event.kind == "pump_done" and self._pump_done_once is not None: - cb = self._pump_done_once - self._pump_done_once = None - cb(event.data) - - # 用户回调 - for cb in self._callbacks.get(event.kind, []): - cb(event.data) - - # ---- 连接管理 ---- - - @property - def is_open(self) -> bool: - return self._reader.is_open - - def connect(self) -> None: - if not self._port_name: - self._event_queue.put(_Event("error", "未指定串口端口")) - return - self._reader.open(self._port_name, self._baudrate) - - def disconnect(self) -> None: - self._reader.close() - - def reconfigure(self, port: str, baudrate: int) -> None: - if self.is_open: - self.disconnect() - self._port_name = port - self._baudrate = baudrate - - # ---- 命令发送(下行帧) ---- - - @staticmethod - def _build_downlink(cmd: int, params: bytes = b"") -> bytes: - cs = _crc8(bytes([cmd]) + params) - return b"\xbb\x55" + bytes([cmd]) + params + bytes([cs]) - - def send_maxcount(self, pump_id: int, count: int) -> None: - params = bytes( - [ - pump_id, - count & 0xFF, - (count >> 8) & 0xFF, - (count >> 16) & 0xFF, - (count >> 24) & 0xFF, - ] - ) - self.send_cmd(0x01, params) - - def send_frerun(self, pump_id: int) -> None: - self.send_cmd(0x02, bytes([pump_id])) - - def send_frestop(self, pump_id: int = 0xFF) -> None: - """正常停止泵(0x03)。""" - self.send_cmd(0x03, bytes([pump_id])) - - def send_abort(self, pump_id: int = 0xFF) -> None: - """紧急停止泵(0x04),功能等价于 0x03 但语义用于异常情况。""" - self.send_cmd(0x04, bytes([pump_id])) - - def send_reset(self) -> None: - """下发 MCU 复位指令。""" - self.send_cmd(0x06) - - def send_raw(self, data: bytes) -> None: - """发送已在外部构建好的完整帧。""" - self._reader.write(data) - - # ---- 心跳 / 看门狗 ---- - - def enable_watchdog(self) -> None: - """启用心跳看门狗(发送 0x05 0x01)。""" - self.send_cmd(0x05, bytes([0x01])) - - def send_heartbeat(self) -> None: - """发送心跳帧,不占用普通命令的 ACK/重试状态。""" - # 普通命令只有一个 pending 槽;命令等待确认时跳过本次心跳, - # 避免覆盖泵控制命令并误判其 ACK。 - if self._pending_cmd is not None: - return - frame = self._build_downlink(0x05, bytes([0x01])) - self._reader.write(frame) - - # ---- 带重试的命令发送 ---- - - def _on_ack(self, cmd: int) -> None: - if self._pending_cmd_id != cmd: - # 收到不匹配的 ACK,可能是重复响应或状态不同步 - if self._pending_cmd_id is not None: - self._event_queue.put( - _Event("error", f"收到意外 ACK 0x{cmd:02X},期望 0x{self._pending_cmd_id:02X}") - ) - return - self._ack_received = True - self._pending_cmd = None - self._pending_cmd_id = None - self._retry_count = 0 - self._cancel_timers() - - def _handle_nak(self, cmd: int) -> None: - """NAK 处理:指数退避重传(在主线程 poll() 中调用)。""" - if self._pending_cmd is None or self._pending_cmd_id != cmd: - return - self._retry_count += 1 - if self._retry_count >= self._max_retries: - self._send_abort_and_error() - return - # 指数退避 - delay = self._backoff_ms * (2 ** (self._retry_count - 1)) / 1000.0 - self._cancel_timers() - self._retry_timer = threading.Timer(delay, self._retry_send) - self._retry_timer.daemon = True - self._retry_timer.start() - - def _retry_send(self) -> None: - """重传(Timer 线程调用,仅写串口,线程安全)。""" - if self._pending_cmd: - self._reader.write(self._pending_cmd) - self._first_timeout_timer = threading.Timer(0.1, self._on_first_timeout) - self._first_timeout_timer.daemon = True - self._first_timeout_timer.start() - - def _send_abort_and_error(self) -> None: - self._pending_cmd = None - self._pending_cmd_id = None - self._retry_count = 0 - self._cancel_timers() - self._reader.write(self._build_downlink(0x04, bytes([0xFF]))) - self._event_queue.put(_Event("error", "下位机通讯异常")) - - def _cancel_timers(self) -> None: - if self._first_timeout_timer is not None: - self._first_timeout_timer.cancel() - self._first_timeout_timer = None - if self._retry_timer is not None: - self._retry_timer.cancel() - self._retry_timer = None - - def send_cmd(self, cmd: int, params: bytes = b"") -> None: - """发送命令并启动 ACK/NAK 重试监控(含 100ms 首包超时)。""" - expected_len = _DOWNLINK.get(cmd) - if expected_len is None or len(params) != expected_len: - raise ValueError(f"invalid parameters for command 0x{cmd:02X}") - frame = self._build_downlink(cmd, params) - self._cancel_timers() - self._pending_cmd = frame - self._pending_cmd_id = cmd - self._ack_received = False - self._retry_count = 0 - self._reader.write(frame) - # 启动首包超时定时器 - self._first_timeout_timer = threading.Timer(0.1, self._on_first_timeout) - self._first_timeout_timer.daemon = True - self._first_timeout_timer.start() - - def _on_first_timeout(self) -> None: - """首包 100ms 超时(Timer 线程调用,仅设标志,由 poll() 处理)。""" - self._timeout_flag = True - - # ---- 生命周期 ---- - - def shutdown(self) -> None: - self._cancel_timers() - self._reader.close() - self._reader.join(timeout=2.0) - - -__all__ = ["ProtocolHandler"] diff --git a/TController/src/DataProcessor/__init__.py b/TController/src/DataProcessor/__init__.py deleted file mode 100644 index 323c7a9..0000000 --- a/TController/src/DataProcessor/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -"""DataProcessor — 数据处理包。 - -功能:: - - reconstruct: AS7341 10 通道光谱 → 全光谱重建 - - EndpointDetector: 滴定终点在线检测(电位 + 光谱共识) - -用法:: - - from DataProcessor import reconstruct, EndpointDetector -""" - -from DataProcessor.calibration import ( - FLOW_RATE, - PUMP_INTERCEPT, - PUMP_SLOPE, - PUMP_STEP_FREQ, - steps_from_volume, - volume_from_steps, -) -from DataProcessor.endpoint import EndpointDetector, savgol_filter -from DataProcessor.online_features import ( - EndpointFusionKF, - SpectralFeatureTracker, - cross_entropy, - cross_entropy_excess, - js_divergence, - normalize_spectrum, -) -from DataProcessor.reconstructor import get_wavelengths, is_available, reconstruct - -__all__ = [ - "FLOW_RATE", - "PUMP_INTERCEPT", - "PUMP_SLOPE", - "PUMP_STEP_FREQ", - "EndpointDetector", - "EndpointFusionKF", - "SpectralFeatureTracker", - "cross_entropy", - "cross_entropy_excess", - "get_wavelengths", - "is_available", - "js_divergence", - "normalize_spectrum", - "reconstruct", - "savgol_filter", - "steps_from_volume", - "volume_from_steps", -] diff --git a/TController/src/DataProcessor/_path.py b/TController/src/DataProcessor/_path.py deleted file mode 100644 index 8719626..0000000 --- a/TController/src/DataProcessor/_path.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -路径解析工具:在开发模式与 Nuitka 打包模式下均能正确找到数据文件。 - -开发模式: data/calibre.npz 位于仓库根目录 -打包模式: data/calibre.npz 位于可执行文件同级目录 - -采用文件系统探测,不依赖 sys.frozen 等 packager 特定属性。 -""" - -from __future__ import annotations - -import os -import sys - -_CALIBRE = "calibre.npz" - - -def _find() -> str: - """按优先级搜索 calibre.npz,返回所在目录。""" - # 1) exe 同级(Nuitka standalone / onefile 解压目录) - exe_dir = os.path.dirname(os.path.abspath(sys.executable)) - if os.path.isfile(os.path.join(exe_dir, _CALIBRE)): - return exe_dir - - # 2) 开发模式:相对此模块的 ../../data/ - dev = os.path.normpath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "data") - ) - if os.path.isfile(os.path.join(dev, _CALIBRE)): - return dev - - # 3) 兜底返回 exe 目录(即使文件不存在,报错信息更清晰) - return exe_dir - - -CALIBRE_PATH = os.path.join(_find(), _CALIBRE) -"""calibre.npz 的完整路径(开发/打包均有效)。""" diff --git a/TController/src/DataProcessor/calibration.py b/TController/src/DataProcessor/calibration.py deleted file mode 100644 index 20d5eb7..0000000 --- a/TController/src/DataProcessor/calibration.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -蠕动泵标定数据。 - -从 data/pump1_calib.json 自动加载,也可通过 update_from_file() 刷新。 -""" - -from __future__ import annotations - -import os - -import numpy as np - -from DataProcessor._path import CALIBRE_PATH - -# ---- 泵运行频率 ---- -PUMP_STEP_FREQ = 1000 # Hz, PumpMotor1::Initialize(1000) - -# ---- 默认值(JSON 加载失败时回退) ---- -PUMP_SLOPE = 6.03752e-6 -PUMP_INTERCEPT = 0.0 - -# ---- 运行时加载 ---- -_DATA_FILE = CALIBRE_PATH - - -def _load() -> None: - """从 data/calibration.npz 加载泵标定参数,静默失败时保留默认值。""" - global PUMP_SLOPE, PUMP_INTERCEPT - if not os.path.isfile(_DATA_FILE): - return - try: - _data = np.load(_DATA_FILE, allow_pickle=True) - slope = float(_data["pump1_slope"]) - intercept = float(_data["pump1_intercept"]) - - # 合法性校验:slope 必须为正数,intercept 允许负值但不应过大 - if slope <= 0: - raise ValueError(f"pump1_slope 必须为正数,当前值为 {slope}") - if abs(intercept) > 10.0: - raise ValueError(f"pump1_intercept 绝对值超限(>10.0 mL),当前值为 {intercept}") - - PUMP_SLOPE = slope - PUMP_INTERCEPT = intercept - except Exception: - # 静默失败,保留模块级默认值 - pass - - -_load() - -FLOW_RATE = PUMP_SLOPE * PUMP_STEP_FREQ - - -def steps_from_volume(vol_mL: float) -> int: - """将体积(mL)转换为泵步数。 - - 使用线性模型反向计算:steps = (volume - intercept) / slope - """ - if PUMP_SLOPE <= 0: - raise ValueError(f"PUMP_SLOPE 必须为正数,当前值为 {PUMP_SLOPE}") - steps = (vol_mL - PUMP_INTERCEPT) / PUMP_SLOPE - return max(0, int(steps)) - - -def volume_from_steps(steps: int) -> float: - """将泵步数转换为体积(mL)。 - - 使用线性模型:volume = slope × steps + intercept - """ - return PUMP_SLOPE * steps + PUMP_INTERCEPT - - -def update_from_file() -> None: - """重新从 data/ 加载校准文件(用户在标定界面保存后调用)。""" - _load() - global FLOW_RATE - FLOW_RATE = PUMP_SLOPE * PUMP_STEP_FREQ - - -__all__ = [ - "FLOW_RATE", - "PUMP_INTERCEPT", - "PUMP_SLOPE", - "PUMP_STEP_FREQ", - "steps_from_volume", - "update_from_file", - "volume_from_steps", -] diff --git a/TController/src/DataProcessor/endpoint.py b/TController/src/DataProcessor/endpoint.py deleted file mode 100644 index 7258796..0000000 --- a/TController/src/DataProcessor/endpoint.py +++ /dev/null @@ -1,572 +0,0 @@ -"""Causal online titration endpoint detection. - -Potential data is processed with the existing causal EWMA state machine. -Spectra are processed by :mod:`DataProcessor.online_features`, which adds a -bounded Jensen-Shannon signal, causal cross-curvature and a two-state KF for -endpoint/delay fusion. No feature uses future samples. - -Either endpoint can be revised after the fact -- the spectral one when a stronger -excursion supersedes an early transient, the potential one after AMPD refinement --- so :meth:`EndpointDetector._consume_kf_observations` re-runs the filter from -scratch whenever the observed pair changes. Gating a corrected value against a -state built from the stale one would reject the correction. -""" - -from __future__ import annotations - -from typing import Any - -import numpy as np - -from DataProcessor.online_features import EndpointFusionKF, SpectralFeatureTracker - -_SAVGOL_COEFFS_CACHE: dict[tuple[int, int], np.ndarray] = {} - - -def _savgol_coeffs(window: int, order: int) -> np.ndarray: - if window % 2 == 0: - raise ValueError(f"window 必须为奇数,得到 {window}") - key = (window, order) - if key not in _SAVGOL_COEFFS_CACHE: - half = window // 2 - x = np.arange(-half, half + 1, dtype=np.float64) - A = np.vander(x, order + 1, increasing=True) - ATA = A.T @ A - coeffs = np.linalg.solve(ATA, A.T) - _SAVGOL_COEFFS_CACHE[key] = coeffs[0].copy() - return _SAVGOL_COEFFS_CACHE[key] - - -def savgol_filter(signal: np.ndarray, window: int = 5, order: int = 2) -> np.ndarray: - """Savitzky-Golay smoothing (symmetric and intended for offline use).""" - coeffs = _savgol_coeffs(window, order) - half = window // 2 - padded = np.pad(signal, half, mode="edge") - return np.convolve(padded, coeffs[::-1], mode="valid") - - -class _EWMA: - """First-order causal exponential moving average.""" - - __slots__ = ("_a", "_v") - - def __init__(self, alpha: float) -> None: - self._a = alpha - self._v: float | None = None - - def __call__(self, x: float) -> float: - if self._v is None: - self._v = float(x) - else: - self._v = self._a * float(x) + (1.0 - self._a) * self._v - return self._v - - @property - def value(self) -> float | None: - return self._v - - def reset(self) -> None: - self._v = None - - -def _ampd_peak_idx(signal: np.ndarray) -> int | None: - """Return the most prominent AMPD peak index, or ``None``. - - Reduces each scale row on the fly instead of materialising the dense - ``L x N`` local-maxima matrix. The original nested Python loop cost O(N^2) - interpreted iterations and O(N^2) memory: on a full titration record - (measured on Paper/ExpData, ~1.4-1.7e4 samples) that was tens of seconds and - 0.4-0.6 GB on the GUI thread. Results are bit-identical -- the same strict - comparisons, the same first-minimum ``argmin`` and first-maximum ``argmax``. - """ - sig = np.asarray(signal, dtype=np.float64) - N = sig.size - if N < 12: - return None - L = N // 2 - 1 - if L < 2: - return None - - def _row(k: int) -> np.ndarray: - centre = sig[k : N - k] - return (centre > sig[: N - 2 * k]) & (centre > sig[2 * k :]) - - # gamma[k-1] counts local maxima at scale k; sigma is the scale where the - # signal is most consistently peaked. - gamma = np.fromiter( - (np.count_nonzero(_row(k)) for k in range(1, L + 1)), dtype=np.int64, count=L - ) - sigma = int(np.argmin(gamma)) - - score = np.zeros(N, dtype=np.int64) - for k in range(sigma + 1, L + 1): - score[k : N - k] += _row(k) - best = int(np.argmax(score)) - return best if score[best] > 0 else None - - -class EndpointDetector: - """Causal endpoint detector for potential and full-field spectral data.""" - - # Potential channel parameters. - POT_V_ALPHA = 0.15 - POT_D_ALPHA = 0.05 - POT_OBSERVE_VOL = 0.1 - POT_ENTER_SIGMA = 2.5 - POT_EXIT_SIGMA = 2.5 - POT_MIN_ENTER = 0.005 - POT_MIN_EXIT = 0.001 - POT_CONFIRM_VOL = 0.15 - - # Legacy spectral names remain public for old validation/configuration code. - # SPEC_ENTER/SPEC_EXIT are the cross-entropy-era thresholds and are only used - # when use_jsd=False; that path is kept for compatibility and is not tuned. - SPEC_CE_ALPHA = 0.20 - SPEC_ENTER = 1e-3 - SPEC_EXIT = 1e-4 - SPEC_CONFIRM_FRAMES = 10 - - # Thresholds on the volume-normalised JS speed, i.e. nats/mL^2 -- not the - # bounded JS value itself. JS between adjacent frames is second order in the - # volume step, so dividing by that step squared makes the speed independent - # of the sampling density; the numbers below are therefore not comparable to - # the ln(2) bound on plain JS. - SPEC_JS_ENTER = 0.05 - SPEC_JS_EXIT = 0.008 - SPEC_BASELINE_ENTER = 3e-7 - SPEC_BASELINE_FRAMES = 12 - SPEC_BASELINE_MAX_VOL = 0.30 - SPEC_MIN_EVENT_VOL = 0.08 - # A later excursion must be this much stronger to take over the endpoint. - SPEC_SUPERSEDE_RATIO = 1.5 - - # AMPD refinement rejects a peak beyond this fraction of the record: the - # largest AMPD scale only evaluates the middle of the window, so a peak in - # the tail is supported by few scales. 0.75 silently rejected valid late - # endpoints (a manual stop shortly after the equivalence point puts it near - # 0.8 of the record), so the guard sits just inside the unsupported tail. - AMPD_MAX_POSITION = 0.9 - - def __init__( - self, - flow_rate: float | None = None, - *, - use_jsd: bool = True, - enable_curvature: bool = True, - enable_kf: bool = True, - wavelengths: np.ndarray | None = None, - ) -> None: - if flow_rate is None: - from DataProcessor.calibration import FLOW_RATE - - self._flow_rate = FLOW_RATE - else: - self._flow_rate = float(flow_rate) - self._use_jsd = bool(use_jsd) - self._enable_curvature = bool(enable_curvature) - self._enable_kf = bool(enable_kf) - self._spectrum_axis = None if wavelengths is None else np.asarray(wavelengths, dtype=np.float64).copy() - self._reset_state() - - def _reset_state(self) -> None: - # Potential state and causal derivative history. - self._pot_v_smooth = _EWMA(self.POT_V_ALPHA) - self._pot_d_smooth = _EWMA(self.POT_D_ALPHA) - self._pot_prev_v: float | None = None - self._pot_prev_t: float | None = None - self._pot_state = "IDLE" - self._pot_ep_vol: float | None = None - self._pot_ep_t: float | None = None - self._pot_min_d = 0.0 - self._pot_cand_vol: float | None = None - self._pot_entry_vol: float | None = None - self._pot_done = False - self._pot_d_vals: list[float] = [] - self._pot_obs_done = False - self._pot_enter_th = -1e9 - self._pot_exit_th = -1e9 - self._pot_raw_buf: list[float] = [] - self._pot_vol_buf: list[float] = [] - self._pot_sample_count = 0 - self._pot_last_d = 0.0 - - # Spectral state is delegated to the causal feature tracker. - self._spectral = SpectralFeatureTracker( - alpha=self.SPEC_CE_ALPHA, - js_enter=self.SPEC_JS_ENTER if self._use_jsd else self.SPEC_ENTER, - js_exit=self.SPEC_JS_EXIT if self._use_jsd else self.SPEC_EXIT, - baseline_enter=self.SPEC_BASELINE_ENTER, - baseline_frames=self.SPEC_BASELINE_FRAMES, - baseline_max_volume=self.SPEC_BASELINE_MAX_VOL, - confirm_frames=self.SPEC_CONFIRM_FRAMES, - min_event_volume=self.SPEC_MIN_EVENT_VOL, - supersede_ratio=self.SPEC_SUPERSEDE_RATIO, - wavelengths=self._spectrum_axis, - use_jsd=self._use_jsd, - ) - self._spec_state = "IDLE" - self._spec_ep_vol: float | None = None - self._spec_ep_t: float | None = None - self._spec_max_js = 0.0 - self._spec_last_diag: dict[str, Any] = self._spectral.last - self._spec_done = False - - self._kf = EndpointFusionKF() if self._enable_kf else None - # Endpoint pair the KF has already been run on, so a superseded value can - # be detected and re-fused instead of gated against a stale state. - self._kf_consumed: tuple[float | None, float | None] | None = None - self._last_pot_result: dict[str, Any] | None = None - self._last_spec_result: dict[str, Any] | None = None - self._last_reliability = self._build_reliability(None, None) - - # ================================================================ - # Data input - # ================================================================ - - def feed_potential(self, vol: float, t: float, v: float) -> None: - """Feed one potential point: volume in mL, time in seconds, voltage.""" - vol = float(vol) - t = float(t) - v_sm = self._pot_v_smooth(float(v)) - - if self._pot_prev_t is not None and self._pot_prev_v is not None: - dt = t - self._pot_prev_t - dv = v_sm - self._pot_prev_v - d_raw = dv / dt if dt > 0 else 0.0 - else: - d_raw = 0.0 - - d_sm = self._pot_d_smooth(d_raw) - self._pot_prev_v = v_sm - self._pot_prev_t = t - self._pot_last_d = d_sm - self._pot_sample_count += 1 - self._pot_raw_buf.append(float(d_raw)) - self._pot_vol_buf.append(vol) - - if not self._pot_obs_done: - self._pot_d_vals.append(d_sm) - if vol >= self.POT_OBSERVE_VOL: - arr = np.asarray(self._pot_d_vals, dtype=np.float64) - if len(arr) < 3: - return - d_mean = float(np.mean(arr)) - d_std = max(float(np.std(arr, ddof=1)), abs(d_mean) * 0.01, 1e-6) - self._pot_enter_th = d_mean - max( - self.POT_MIN_ENTER, self.POT_ENTER_SIGMA * d_std - ) - self._pot_exit_th = d_mean - max( - self.POT_MIN_EXIT, self.POT_EXIT_SIGMA * d_std - ) - self._pot_obs_done = True - self._pot_d_vals = [] - return - - if not self._pot_done: - if self._pot_state == "IDLE" and d_sm < self._pot_enter_th: - self._pot_state = "TRACKING" - self._pot_min_d = d_sm - self._pot_cand_vol = vol - self._pot_entry_vol = vol - elif self._pot_state == "TRACKING": - if d_sm < self._pot_min_d: - self._pot_min_d = d_sm - self._pot_cand_vol = vol - if ( - d_sm > self._pot_exit_th - and self._pot_entry_vol is not None - and self._pot_cand_vol is not None - and vol - self._pot_entry_vol > self.POT_CONFIRM_VOL - ): - self._pot_ep_vol = self._pot_cand_vol - self._pot_ep_t = self._pot_cand_vol / self._flow_rate - self._pot_state = "END_CONFIRMED" - self._pot_done = True - - def feed_spectrum( - self, - vol: float, - spectrum: np.ndarray, - t: float | None = None, - ) -> None: - """Feed one raw-channel or reconstructed full-spectrum frame.""" - diag = self._spectral.update(float(vol), np.asarray(spectrum), t=t) - self._spec_last_diag = diag - self._spec_state = str(diag["state"]) - self._spec_max_js = max(self._spec_max_js, float(diag.get("max_js", 0.0))) - candidate = self._spectral.endpoint_volume - # The tracker reports the strongest excursion so far, so the candidate can - # move when a later, clearly stronger event supersedes an early transient. - if candidate is not None and candidate != self._spec_ep_vol: - self._spec_ep_vol = float(candidate) - self._spec_ep_t = self._spec_ep_vol / self._flow_rate - self._spec_done = True - - def set_spectrum_axis(self, wavelengths: np.ndarray | None) -> None: - """Configure the wavelength axis used by causal cross-curvature.""" - if wavelengths is None: - self._spectrum_axis = None - else: - self._spectrum_axis = np.asarray(wavelengths, dtype=np.float64).copy() - self._spectral.set_wavelengths(self._spectrum_axis) - - # ================================================================ - # Results and reliability - # ================================================================ - - def _build_pot_result(self) -> dict[str, Any] | None: - if self._pot_ep_vol is None: - return None - result: dict[str, Any] = { - "volume": self._pot_ep_vol, - "time": self._pot_ep_t or (self._pot_ep_vol / self._flow_rate), - "min_dvdt": round(self._pot_min_d, 2), - "state": self._pot_state, - } - if self._kf is not None: - kf = self._kf.snapshot() - for field in ("endpoint_std", "nis", "innovation"): - kf_value = kf.get(field) - if kf_value is not None: - result[field] = kf_value - return result - - def _build_spec_result(self) -> dict[str, Any] | None: - if self._spec_ep_vol is None: - return None - diag = self._spec_last_diag - # max_ce is retained as a schema alias for old exports/consumers. - return { - "volume": self._spec_ep_vol, - "time": self._spec_ep_t or (self._spec_ep_vol / self._flow_rate), - "max_ce": round(self._spec_max_js, 8), - "max_js": round(self._spec_max_js, 8), - "js_local": round(float(diag.get("js_local", 0.0)), 8), - "js_speed": round(float(diag.get("js_speed", 0.0)), 8), - "js_base": round(float(diag.get("js_base", 0.0)), 8), - "cross_curvature": round(float(diag.get("cross_curvature", 0.0)), 8) - if self._enable_curvature - else None, - "event_maturity": float(diag.get("event_maturity", 0.0)), - "recovery_frames": int(diag.get("recovery_frames", 0)), - "event_count": int(diag.get("event_count", 0)), - "superseded_count": int(diag.get("superseded_count", 0)), - "event_peak_speed": round(float(diag.get("event_peak_speed", 0.0)), 8), - "state": self._spec_state, - } - - def _build_reliability( - self, - pot: dict[str, Any] | None, - spec: dict[str, Any] | None, - ) -> dict[str, Any]: - pot_confirmed = pot is not None - spec_confirmed = spec is not None - diagnostic = self._spec_last_diag - kf = self._kf.snapshot() if self._kf is not None else {} - if pot_confirmed and spec_confirmed: - status = "CONFIRMED" if self._kf is not None and self._kf.can_fuse else "CONFLICT" - elif pot_confirmed or spec_confirmed: - status = "CONFIRMED" if (pot_confirmed and not self._enable_kf) else "CANDIDATE" - elif self._pot_state == "TRACKING" or self._spec_state == "IN_CHANGE": - status = "CONFIRMING" - elif self._pot_sample_count == 0 and diagnostic.get("sample_count", 0) == 0: - status = "UNOBSERVABLE" - else: - status = "EARLY_WARNING" - - reasons: list[str] = [] - if diagnostic.get("data_quality") not in {"ok", "no_spectrum"}: - reasons.append(str(diagnostic["data_quality"])) - if diagnostic.get("repeated_volume_count", 0): - reasons.append("repeated_spectral_volume") - if diagnostic.get("nonmonotonic_count", 0): - reasons.append("nonmonotonic_volume") - if self._kf is not None and pot_confirmed and spec_confirmed and not self._kf.can_fuse: - reasons.append("kf_innovation_gate") - if diagnostic.get("superseded_count", 0): - reasons.append("spectral_endpoint_superseded") - if not diagnostic.get("baseline_ready", False): - reasons.append("baseline_pending") - - if pot_confirmed and spec_confirmed: - agreement = abs(float(pot["volume"]) - float(spec["volume"])) - else: - agreement = None - return { - "status": status, - "data_quality": { - "potential_samples": self._pot_sample_count, - "spectral_samples": int(diagnostic.get("sample_count", 0)), - "valid_spectral_frames": self._spectral.valid_frame_count, - "baseline_ready": bool(diagnostic.get("baseline_ready", False)), - "repeated_spectral_volume": int(diagnostic.get("repeated_volume_count", 0)), - "nonmonotonic_volume": int(diagnostic.get("nonmonotonic_count", 0)), - "last_frame": diagnostic.get("data_quality", "no_spectrum"), - }, - "potential_evidence": pot is not None, - "spectral_evidence": spec is not None, - "modal_consistency": { - "agreement_mL": agreement, - "kf_consistent": bool(self._kf.can_fuse) if self._kf is not None else None, - }, - "event_maturity": float(diagnostic.get("event_maturity", 0.0)), - "spectral_events": int(diagnostic.get("event_count", 0)), - "spectral_superseded": int(diagnostic.get("superseded_count", 0)), - "endpoint_std": kf.get("endpoint_std"), - "spectral_delay": kf.get("spectral_delay"), - "nis": kf.get("nis"), - "innovation": kf.get("innovation"), - "reason_codes": reasons, - } - - def diagnostics(self) -> dict[str, Any]: - """Return current causal feature and reliability diagnostics.""" - pot = self._build_pot_result() - spec = self._build_spec_result() - self._last_reliability = self._build_reliability(pot, spec) - return { - "potential_state": self._pot_state, - "spectral_state": self._spec_state, - "potential": pot, - "spectral": spec, - "spectral_features": dict(self._spec_last_diag), - "kf": self._kf.snapshot() if self._kf is not None else None, - "reliability": self._last_reliability, - } - - def _consume_kf_observations( - self, pot: dict[str, Any] | None, spec: dict[str, Any] | None - ) -> None: - if self._kf is None: - return - pot_vol = None if pot is None else float(pot["volume"]) - spec_vol = None if spec is None else float(spec["volume"]) - pair = (pot_vol, spec_vol) - if pair == self._kf_consumed: - return - # Either endpoint can be revised after the KF has already consumed it: the - # spectral candidate when a stronger excursion supersedes an early - # transient, the potential one after AMPD refinement. Gating the revised - # value against a state built from the stale one would reject the - # correction, so the filter is re-run from scratch on the current pair. - self._kf.reset() - if pot_vol is not None: - self._kf.observe("potential", pot_vol, token=("potential", pot_vol)) - if spec_vol is not None: - self._kf.observe("spectral", spec_vol, token=("spectral", spec_vol)) - self._kf_consumed = pair - - def detect(self) -> dict[str, Any] | None: - """Return a backward-compatible endpoint result with diagnostics.""" - pot = self._build_pot_result() - spec = self._build_spec_result() - if pot is None and spec is None: - self._last_reliability = self._build_reliability(None, None) - return None - - self._consume_kf_observations(pot, spec) - # Rebuild child results after the first observation so exported NIS/std - # fields describe the observation that was just consumed. - pot = self._build_pot_result() - spec = self._build_spec_result() - reliability = self._build_reliability(pot, spec) - self._last_reliability = reliability - result: dict[str, Any] - - if pot is not None and spec is not None: - kf = self._kf.snapshot() if self._kf is not None else None - if self._kf is not None and self._kf.can_fuse and kf is not None: - volume = float(kf["endpoint_volume"]) - result = { - "volume": round(volume, 3), - "time": round(volume / self._flow_rate, 3), - "confidence": "high", - "method": "consensus", - "potential": pot, - "spectral": spec, - "reliability": reliability, - } - elif self._kf is None and abs(pot["volume"] - spec["volume"]) < 0.3: - volume = (pot["volume"] + spec["volume"]) / 2.0 - result = { - "volume": round(volume, 3), - "time": round((pot["time"] + spec["time"]) / 2.0, 3), - "confidence": "high", - "method": "consensus", - "potential": pot, - "spectral": spec, - "reliability": reliability, - } - else: - result = { - "volume": round(float(pot["volume"]), 3), - "time": round(float(pot["time"]), 3), - "confidence": "low", - "method": "conflict", - "potential": pot, - "spectral": spec, - "warning": f"电位{pot['volume']:.3f}mL vs 光谱{spec['volume']:.3f}mL " - "未通过创新一致性门控", - "reliability": reliability, - } - return result - - if pot is not None: - return { - "volume": round(float(pot["volume"]), 3), - "time": round(float(pot["time"]), 3), - "confidence": "medium", - "method": "potential_only", - "potential": pot, - "spectral": None, - "reliability": reliability, - } - assert spec is not None - return { - "volume": round(float(spec["volume"]), 3), - "time": round(float(spec["time"]), 3), - "confidence": "medium", - "method": "spectral_only", - "potential": None, - "spectral": spec, - "reliability": reliability, - } - - def refine_with_ampd(self) -> float | None: - """Use offline AMPD refinement after enough historical samples exist.""" - if len(self._pot_raw_buf) < 20: - return None - arr = np.asarray(self._pot_raw_buf, dtype=np.float64) - idx = _ampd_peak_idx(-arr) - if idx is None or idx >= len(self._pot_vol_buf) * self.AMPD_MAX_POSITION: - return None - refined_vol = self._pot_vol_buf[idx] - self._pot_ep_vol = refined_vol - self._pot_ep_t = refined_vol / self._flow_rate - return refined_vol - - # ================================================================ - # Lifecycle and compatibility properties - # ================================================================ - - def reset(self) -> None: - """Clear all filters and state for a new titration.""" - self._reset_state() - - @property - def potential_state(self) -> str: - return self._pot_state - - @property - def spectral_state(self) -> str: - return self._spec_state - - @property - def endpoint_volume(self) -> float | None: - if self._kf is not None and self._kf.can_fuse: - return float(self._kf.x[0]) - return self._pot_ep_vol or self._spec_ep_vol - - -__all__ = ["EndpointDetector", "savgol_filter"] diff --git a/TController/src/DataProcessor/online_features.py b/TController/src/DataProcessor/online_features.py deleted file mode 100644 index ba9ec59..0000000 --- a/TController/src/DataProcessor/online_features.py +++ /dev/null @@ -1,640 +0,0 @@ -"""Causal online features for multimodal titration endpoint detection. - -The classes in this module are deliberately independent of the GUI and -communication layer. Every update consumes only the current observation and -state retained from previous observations. - -Two properties of ``SpectralFeatureTracker`` are worth knowing before reading it: - -* The volume-normalised speed is anchored to the last *advancing* frame, not to - the previous frame. Production feeds a spectrum per AS7341 frame while volume - comes from the pump, so several frames can share one volume; feeding those into - the speed filter would inject zeros and wash out a real excursion. -* ``END_CONFIRMED`` re-arms. Excursions are accumulated in ``events`` and the - reported endpoint is the strongest one, replaced only when a later excursion - beats it by ``supersede_ratio``. A one-shot latch picked an early transient - 0.97 mL before the true endpoint on real data (Paper/ExpData group B), which the - Kalman gate could only reject, not repair. -""" - -from __future__ import annotations - -from collections import deque -from typing import Any - -import numpy as np - -_EPS = 1e-12 - -# Measured on Paper/ExpData (2026-08-21): the float64 round-off floor of -# ``js_divergence`` on real 8-channel frames is ~5e-17, while the titration -# plateau sits at ~2e-12 and the endpoint event reaches ~2e-7. ``js_speed`` -# divides by the squared volume step (~2.4e-8 there, i.e. a 4e7 amplification), -# so a JS value at the round-off floor must never be normalised -- otherwise the -# speed signal is amplified arithmetic noise rather than chemistry. -_JS_FLOOR = 1e-14 - - -def _finite_vector(values: np.ndarray) -> tuple[np.ndarray | None, str | None]: - """Return a non-negative finite vector, or a data-quality reason.""" - arr = np.asarray(values, dtype=np.float64).reshape(-1) - if arr.size == 0: - return None, "spectrum_empty" - if not np.all(np.isfinite(arr)): - return None, "spectrum_nonfinite" - arr = np.maximum(arr, 0.0) - total = float(np.sum(arr)) - if total <= _EPS: - return None, "spectrum_zero" - return arr / total, None - - -def normalize_spectrum(values: np.ndarray, epsilon: float = 1e-9) -> np.ndarray: - """Normalize a spectrum with additive smoothing and finite-value checks.""" - arr = np.asarray(values, dtype=np.float64).reshape(-1) - if arr.size == 0 or not np.all(np.isfinite(arr)): - raise ValueError("spectrum must be a non-empty finite array") - arr = np.maximum(arr, 0.0) + float(epsilon) - return arr / float(np.sum(arr)) - - -def js_divergence(p: np.ndarray, q: np.ndarray) -> float: - """Return the natural-log Jensen-Shannon divergence of two distributions.""" - p_norm = normalize_spectrum(p) - q_norm = normalize_spectrum(q) - midpoint = 0.5 * (p_norm + q_norm) - value = 0.5 * np.sum(p_norm * np.log(p_norm / midpoint)) - value += 0.5 * np.sum(q_norm * np.log(q_norm / midpoint)) - return float(np.clip(value, 0.0, np.log(2.0))) - - -def cross_entropy(p: np.ndarray, q: np.ndarray) -> float: - """Return the legacy directional cross-entropy used by older builds.""" - p_norm = normalize_spectrum(p) - q_norm = normalize_spectrum(q) - return float(-np.sum(p_norm * np.log(np.maximum(q_norm, 1e-12)))) - - -def cross_entropy_excess(p: np.ndarray, q: np.ndarray) -> float: - """Return the legacy cross-entropy above its own floor, i.e. ``KL(p||q)``. - - ``cross_entropy(p, p)`` is the entropy of ``p`` (~ln(n)), not zero, so the raw - value cannot drive a threshold state machine: divided by the squared volume - step it stays enormous forever and the tracker can never leave ``IN_CHANGE``. - Measured with ``use_jsd=False`` on a synthetic titration, the speed signal sat - at 1.4e4 from the first post-baseline frame onwards and no spectral endpoint - was ever confirmed. Subtracting the floor leaves the Kullback-Leibler - divergence, which is zero for identical distributions and therefore - comparable against an exit threshold. ``cross_entropy`` itself is unchanged - because it is re-exported and read by older scripts. - """ - p_norm = normalize_spectrum(p) - q_norm = normalize_spectrum(q) - delta = np.log(np.maximum(p_norm, 1e-12)) - np.log(np.maximum(q_norm, 1e-12)) - return float(max(np.sum(p_norm * delta), 0.0)) - - -class _ScalarEWMA: - __slots__ = ("alpha", "value") - - def __init__(self, alpha: float) -> None: - self.alpha = float(np.clip(alpha, 0.0, 1.0)) - self.value: float | None = None - - def __call__(self, value: float) -> float: - value = float(value) - if self.value is None: - self.value = value - else: - self.value = self.alpha * value + (1.0 - self.alpha) * self.value - return self.value - - def hold(self) -> float: - """Return the current level without folding in a new sample.""" - return 0.0 if self.value is None else float(self.value) - - def reset(self) -> None: - self.value = None - - -class SpectralFeatureTracker: - """Causal JS, baseline distance and cross-curvature tracker. - - ``update`` accepts arbitrary-length spectra. Spectral frames may reuse the - most recent ADC volume, so the volume-normalised speed is anchored to the - last frame that actually advanced the burette: JS is accumulated across the - stale frames and divided by the real volume step, and the speed filter is - held (not fed a zero) while the volume stands still. - - Confirmation is not one-shot. Each completed excursion is recorded as an - event and the reported endpoint is the strongest one seen so far, so a weak - early transient cannot permanently mask the real endpoint. - """ - - IDLE = "IDLE" - IN_CHANGE = "IN_CHANGE" - END_CONFIRMED = "END_CONFIRMED" - - def __init__( - self, - *, - alpha: float = 0.20, - js_enter: float = 0.05, - js_exit: float = 0.008, - baseline_enter: float = 3e-7, - baseline_frames: int = 12, - baseline_max_volume: float = 0.30, - confirm_frames: int = 4, - min_event_volume: float = 0.08, - epsilon_volume: float = 1e-8, - lookback_frames: int = 8, - supersede_ratio: float = 1.5, - js_floor: float = _JS_FLOOR, - wavelengths: np.ndarray | None = None, - use_jsd: bool = True, - ) -> None: - self.alpha = float(alpha) - self.js_enter = float(js_enter) - self.js_exit = float(js_exit) - self.baseline_enter = float(baseline_enter) - self.baseline_frames = max(3, int(baseline_frames)) - self.baseline_max_volume = float(baseline_max_volume) - self.confirm_frames = max(1, int(confirm_frames)) - self.min_event_volume = float(min_event_volume) - self.epsilon_volume = max(float(epsilon_volume), 1e-12) - self.lookback_frames = max(1, int(lookback_frames)) - self.supersede_ratio = max(float(supersede_ratio), 1.0) - self.js_floor = max(float(js_floor), 0.0) - self.use_jsd = bool(use_jsd) - self._configured_wavelengths = ( - None if wavelengths is None else np.asarray(wavelengths, dtype=np.float64).copy() - ) - self._reset_state() - - def _reset_state(self) -> None: - self._smoothed: np.ndarray | None = None - self._previous_volume: float | None = None - # Anchor frame for volume-normalised features: the last frame whose - # volume actually advanced. - self._sync_spectrum: np.ndarray | None = None - self._sync_volume: float | None = None - self._baseline_sum: np.ndarray | None = None - self._baseline_count = 0 - self._baseline: np.ndarray | None = None - self._frame_count = 0 - self._valid_frame_count = 0 - self._invalid_frame_count = 0 - self._nonmonotonic_count = 0 - self._repeated_volume_count = 0 - self._last_volume_sync_valid = False - self._state = self.IDLE - self._candidate_volume: float | None = None - self._entry_volume: float | None = None - self._peak_value = 0.0 - self._peak_js = 0.0 - self._recovery_frames = 0 - self._recent: deque[tuple[float, float]] = deque(maxlen=self.lookback_frames) - self._events: list[dict[str, float]] = [] - self._best_event: dict[str, float] | None = None - self._supersede_count = 0 - self._last: dict[str, Any] = self._empty_diagnostic() - self._js_smooth = _ScalarEWMA(self.alpha) - self._speed_smooth = _ScalarEWMA(self.alpha) - self._curvature_smooth = _ScalarEWMA(self.alpha) - self._configured_axis = self._configured_wavelengths - - def reset(self) -> None: - """Clear history while retaining feature configuration and wavelength axis.""" - self._reset_state() - - def set_wavelengths(self, wavelengths: np.ndarray | None) -> None: - """Set the wavelength/channel axis used by cross-curvature.""" - if wavelengths is None: - self._configured_wavelengths = None - self._configured_axis = None - return - axis = np.asarray(wavelengths, dtype=np.float64).reshape(-1) - if axis.size < 2 or not np.all(np.isfinite(axis)): - raise ValueError("wavelength axis must contain at least two finite values") - if np.any(np.diff(axis) <= 0): - raise ValueError("wavelength axis must be strictly increasing") - self._configured_wavelengths = axis.copy() - self._configured_axis = axis.copy() - - @staticmethod - def _empty_diagnostic() -> dict[str, Any]: - return { - "sample_count": 0, - "valid_frame": False, - "data_quality": "no_spectrum", - "volume": None, - "delta_volume": 0.0, - "volume_sync_valid": False, - "js_local": 0.0, - "js_local_smooth": 0.0, - "js_speed": 0.0, - "js_speed_smooth": 0.0, - "js_base": 0.0, - "cross_curvature": 0.0, - "curvature_peak_channel": None, - "state": SpectralFeatureTracker.IDLE, - "candidate_volume": None, - "max_js": 0.0, - "event_maturity": 0.0, - "recovery_frames": 0, - "baseline_ready": False, - "repeated_volume_count": 0, - "nonmonotonic_count": 0, - "event_count": 0, - "superseded_count": 0, - "event_peak_speed": 0.0, - } - - def _axis_for(self, size: int) -> np.ndarray: - if self._configured_axis is not None and self._configured_axis.size == size: - return self._configured_axis - return np.arange(size, dtype=np.float64) - - def _lookback_peak(self, volume: float, speed: float) -> tuple[float, float]: - """Return the strongest (speed, volume) over the recent causal window. - - The speed filter lags the underlying excursion, so the frame that first - crosses ``js_enter`` can already be on the declining flank of a short - transient. Seeding the peak from the retained window keeps the - candidate on the actual maximum instead of the crossing point. - """ - peak_speed, peak_volume = speed, volume - for past_volume, past_speed in self._recent: - if past_speed > peak_speed: - peak_speed, peak_volume = past_speed, past_volume - return peak_speed, peak_volume - - def _commit_event(self) -> None: - """Record the finished excursion and keep the strongest one.""" - if self._candidate_volume is None: - return - event = { - "candidate_volume": float(self._candidate_volume), - "entry_volume": float(self._entry_volume or self._candidate_volume), - "peak_speed": float(self._peak_value), - "peak_js": float(self._peak_js), - } - self._events.append(event) - if self._best_event is None: - self._best_event = event - elif event["peak_speed"] > self._best_event["peak_speed"] * self.supersede_ratio: - # Hysteresis: a later excursion only takes over when it is clearly - # stronger, so near-ties do not make the reported endpoint flap. - self._best_event = event - self._supersede_count += 1 - - def update(self, volume: float, spectrum: np.ndarray, t: float | None = None) -> dict[str, Any]: - """Consume one spectrum and return a JSON-friendly causal diagnostic.""" - del t # The feature is volume-causal; time is retained by the detector. - self._frame_count += 1 - volume = float(volume) - normalized, reason = _finite_vector(np.asarray(spectrum)) - if normalized is None: - self._invalid_frame_count += 1 - self._last = dict(self._last) - self._last.update( - { - "sample_count": self._frame_count, - "valid_frame": False, - "data_quality": reason, - "volume": volume, - "baseline_ready": self._baseline is not None, - "repeated_volume_count": self._repeated_volume_count, - "nonmonotonic_count": self._nonmonotonic_count, - } - ) - return dict(self._last) - - self._valid_frame_count += 1 - smoothed = normalized if self._smoothed is None else ( - self.alpha * normalized + (1.0 - self.alpha) * self._smoothed - ) - smoothed = smoothed / max(float(np.sum(smoothed)), _EPS) - - if self._previous_volume is None: - delta_volume = 0.0 - sync_valid = False - else: - delta_volume = volume - self._previous_volume - if delta_volume > self.epsilon_volume: - sync_valid = True - elif abs(delta_volume) <= self.epsilon_volume: - sync_valid = False - self._repeated_volume_count += 1 - else: - sync_valid = False - self._nonmonotonic_count += 1 - self._last_volume_sync_valid = sync_valid - - # Frame-to-frame JS stays a pure diagnostic; it is not volume-normalised - # and so is meaningless as an event driver when frames repeat a volume. - local = 0.0 - if self._smoothed is not None: - local = ( - js_divergence(smoothed, self._smoothed) - if self.use_jsd - else cross_entropy_excess(smoothed, self._smoothed) - ) - local_smooth = self._js_smooth(local) - - # Volume-normalised speed is anchored to the last advancing frame. JS - # between nearby distributions is second order, so dividing by the - # squared step yields a step-size invariant Fisher-Rao speed squared. - speed = self._speed_smooth.hold() - speed_raw = 0.0 - curvature = 0.0 - peak_channel: int | None = None - anchor_delta = ( - 0.0 if self._sync_volume is None else volume - self._sync_volume - ) - if self._sync_spectrum is not None and anchor_delta > self.epsilon_volume: - anchor_js = ( - js_divergence(smoothed, self._sync_spectrum) - if self.use_jsd - else cross_entropy_excess(smoothed, self._sync_spectrum) - ) - if anchor_js > self.js_floor: - speed_raw = anchor_js / (anchor_delta * anchor_delta) - shape_gradient = ( - np.log(np.maximum(smoothed, 1e-12)) - - np.log(np.maximum(self._sync_spectrum, 1e-12)) - ) / anchor_delta - axis = self._axis_for(smoothed.size) - if axis.size == shape_gradient.size and shape_gradient.size >= 3: - wavelength_gradient = np.gradient(shape_gradient, axis) - curvature = float(np.sqrt(np.mean(wavelength_gradient**2))) - peak_channel = int(np.argmax(np.abs(wavelength_gradient))) - speed = self._speed_smooth(speed_raw) - self._curvature_smooth(curvature) - self._sync_spectrum = smoothed.copy() - self._sync_volume = volume - elif self._sync_spectrum is None: - self._sync_spectrum = smoothed.copy() - self._sync_volume = volume - speed_smooth = speed - curvature_smooth = self._curvature_smooth.hold() - - if self._baseline is None and volume <= self.baseline_max_volume: - if self._baseline_sum is None: - self._baseline_sum = np.zeros_like(smoothed) - self._baseline_sum += smoothed - self._baseline_count += 1 - if self._baseline_count >= self.baseline_frames: - self._baseline = self._baseline_sum / float(self._baseline_count) - self._baseline /= max(float(np.sum(self._baseline)), _EPS) - - base_js = 0.0 if self._baseline is None else js_divergence(smoothed, self._baseline) - - if self._baseline is not None: - # END_CONFIRMED re-arms: a later, clearly stronger excursion must be - # able to take over the reported endpoint. - if self._state in (self.IDLE, self.END_CONFIRMED): - if speed_smooth >= self.js_enter and base_js >= self.baseline_enter: - peak_speed, peak_volume = self._lookback_peak(volume, speed_smooth) - self._state = self.IN_CHANGE - self._entry_volume = volume - self._candidate_volume = peak_volume - self._peak_value = peak_speed - self._peak_js = local - self._recovery_frames = 0 - elif self._state == self.IN_CHANGE: - if speed_smooth > self._peak_value: - self._peak_value = speed_smooth - self._peak_js = local - self._candidate_volume = volume - self._recovery_frames = 0 - elif speed_smooth <= self.js_exit: - self._recovery_frames += 1 - if ( - self._entry_volume is not None - and volume - self._entry_volume >= self.min_event_volume - and self._recovery_frames >= self.confirm_frames - ): - self._state = self.END_CONFIRMED - self._commit_event() - else: - self._recovery_frames = 0 - - maturity = 0.0 - if self._state == self.IN_CHANGE: - maturity = min(0.99, self._recovery_frames / float(self.confirm_frames)) - elif self._state == self.END_CONFIRMED: - maturity = 1.0 - - best = self._best_event - self._smoothed = smoothed.copy() - self._previous_volume = volume - self._recent.append((volume, speed_smooth)) - self._last = { - "sample_count": self._frame_count, - "valid_frame": True, - "data_quality": "ok", - "volume": volume, - "delta_volume": float(delta_volume), - "volume_sync_valid": bool(sync_valid), - "js_local": float(local), - "js_local_smooth": float(local_smooth), - "js_speed": float(speed_raw), - "js_speed_smooth": float(speed_smooth), - "js_base": float(base_js), - "cross_curvature": float(curvature_smooth), - "curvature_peak_channel": peak_channel, - "state": self._state, - "candidate_volume": None if best is None else best["candidate_volume"], - "max_js": float(self._peak_js if best is None else best["peak_js"]), - "event_maturity": float(maturity), - "recovery_frames": self._recovery_frames, - "baseline_ready": self._baseline is not None, - "repeated_volume_count": self._repeated_volume_count, - "nonmonotonic_count": self._nonmonotonic_count, - "event_count": len(self._events), - "superseded_count": self._supersede_count, - "event_peak_speed": float(0.0 if best is None else best["peak_speed"]), - } - return dict(self._last) - - @property - def last(self) -> dict[str, Any]: - return dict(self._last) - - @property - def valid_frame_count(self) -> int: - """Number of frames that passed the finite/non-zero spectrum checks.""" - return self._valid_frame_count - - @property - def events(self) -> list[dict[str, float]]: - """All confirmed excursions, in the order they completed.""" - return [dict(event) for event in self._events] - - @property - def endpoint_volume(self) -> float | None: - """Candidate volume of the strongest confirmed excursion so far.""" - if self._best_event is None: - return None - return float(self._best_event["candidate_volume"]) - - -class EndpointFusionKF: - """Two-state linear KF for potential endpoint and spectral volume delay.""" - - # The innovation of each observation is scalar, so the chi-square gate has - # one degree of freedom: 6.635 is the 99th percentile of chi2(1). The - # previous 9.21 is the chi2(2) percentile and was a degrees-of-freedom - # mismatch, giving an effective significance of 0.24% instead of 1%. - DEFAULT_NIS_GATE = 6.635 - - def __init__( - self, - *, - potential_std: float = 0.012, - spectral_std: float = 0.025, - delay_std: float = 0.08, - process_std: float = 0.004, - delay_prior: float = 0.02, - nis_gate: float = DEFAULT_NIS_GATE, - ) -> None: - self.potential_var = max(float(potential_std) ** 2, 1e-8) - self.spectral_var = max(float(spectral_std) ** 2, 1e-8) - self.delay_var = max(float(delay_std) ** 2, 1e-8) - self.process_var = max(float(process_std) ** 2, 1e-10) - self.delay_prior = float(delay_prior) - self.nis_gate = max(float(nis_gate), 1.0) - self.reset() - - def reset(self) -> None: - self.x = np.zeros(2, dtype=np.float64) - self.P = np.eye(2, dtype=np.float64) * 1e6 - self.initialized = False - self.observed: set[str] = set() - self._observed_tokens: set[object] = set() - self.last: dict[str, Any] = { - "initialized": False, - "endpoint_volume": None, - "spectral_delay": None, - "endpoint_std": None, - "innovation": None, - "innovation_covariance": None, - "nis": None, - "accepted": False, - "kind": None, - "consistent": False, - "reason": "no_observation", - } - - def _prediction(self) -> tuple[np.ndarray, np.ndarray]: - if not self.initialized: - return self.x.copy(), self.P.copy() - return self.x.copy(), self.P + np.eye(2) * self.process_var - - def observe(self, kind: str, volume: float, token: object | None = None) -> dict[str, Any]: - """Consume one endpoint observation, idempotently when token repeats.""" - if kind not in {"potential", "spectral"}: - raise ValueError("kind must be 'potential' or 'spectral'") - if token is None: - token = (kind, round(float(volume), 9)) - if token in self._observed_tokens: - return dict(self.last) - z = float(volume) - if not np.isfinite(z): - self.last = dict(self.last) - self.last.update({"kind": kind, "accepted": False, "reason": "nonfinite_observation"}) - return dict(self.last) - - if not self.initialized: - if kind == "potential": - self.x[:] = (z, 0.0) - self.P[:] = np.diag([self.potential_var, self.delay_var]) - else: - self.x[:] = (z - self.delay_prior, self.delay_prior) - self.P[:] = np.diag([self.spectral_var + self.delay_var, self.delay_var]) - self.initialized = True - self.observed.add(kind) - self._observed_tokens.add(token) - self.last = self._snapshot( - kind=kind, - innovation=0.0, - innovation_covariance=self.P[0, 0], - nis=0.0, - accepted=True, - reason="initialized", - ) - return dict(self.last) - - x_prior, p_prior = self._prediction() - if kind == "potential": - h = np.array([1.0, 0.0]) - r = self.potential_var - else: - h = np.array([1.0, 1.0]) - r = self.spectral_var - innovation = z - float(h @ x_prior) - innovation_covariance = float(h @ p_prior @ h + r) - innovation_covariance = max(innovation_covariance, 1e-10) - nis = float(innovation * innovation / innovation_covariance) - accepted = bool(nis <= self.nis_gate) - if accepted: - gain = (p_prior @ h) / innovation_covariance - self.x = x_prior + gain * innovation - self.P = (np.eye(2) - np.outer(gain, h)) @ p_prior - self.P = 0.5 * (self.P + self.P.T) - self.observed.add(kind) - self._observed_tokens.add(token) - else: - self.x = x_prior - self.P = p_prior - self.last = self._snapshot( - kind=kind, - innovation=innovation, - innovation_covariance=innovation_covariance, - nis=nis, - accepted=accepted, - reason="accepted" if accepted else "nis_gate", - ) - return dict(self.last) - - def _snapshot( - self, - *, - kind: str, - innovation: float, - innovation_covariance: float, - nis: float, - accepted: bool, - reason: str, - ) -> dict[str, Any]: - endpoint_std = float(np.sqrt(max(self.P[0, 0], 0.0))) - return { - "initialized": bool(self.initialized), - "endpoint_volume": float(self.x[0]), - "spectral_delay": float(self.x[1]), - "endpoint_std": endpoint_std, - "innovation": float(innovation), - "innovation_covariance": float(innovation_covariance), - "nis": float(nis), - "accepted": bool(accepted), - "kind": kind, - "consistent": bool(self.observed == {"potential", "spectral"}), - "reason": reason, - } - - def snapshot(self) -> dict[str, Any]: - return dict(self.last) - - @property - def can_fuse(self) -> bool: - return self.observed == {"potential", "spectral"} and self.last.get("accepted", False) - - -__all__ = [ - "EndpointFusionKF", - "SpectralFeatureTracker", - "cross_entropy", - "js_divergence", - "normalize_spectrum", -] diff --git a/TController/src/DataProcessor/reconstructor.py b/TController/src/DataProcessor/reconstructor.py deleted file mode 100644 index 80f777c..0000000 --- a/TController/src/DataProcessor/reconstructor.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -AS7341 10 通道 → 全光谱重建。 - -使用 ams-OSRAM 官方 Golden Device 校准矩阵将 10 通道 -(F1–F8, Clear, NIR) 原始 ADC 值重建为 380–1100 nm -连续全光谱(1 nm 步长,721 点)。 - -重建流程:: - - corrected = factor × max(raw − offset, 0) - spectrum[λ] = Σ factor[ch] × corrected[ch] × matrix[λ, ch] -""" - -from __future__ import annotations - -import os -from collections.abc import Sequence - -import numpy as np - -from DataProcessor._path import CALIBRE_PATH - -_DATA_PATH = CALIBRE_PATH - -_lazy: dict | None = None - - -def _load() -> dict: - global _lazy - if _lazy is not None: - return _lazy - if not os.path.isfile(_DATA_PATH): - raise FileNotFoundError(f"光谱校准数据未找到: {_DATA_PATH}") - data = np.load(_DATA_PATH, allow_pickle=True) - _lazy = { - k.replace("spectral_", ""): v - for k, v in data.items() - if k.startswith("spectral_") - } - return _lazy - - -def is_available() -> bool: - """检查校准数据文件是否存在。""" - return os.path.isfile(_DATA_PATH) - - -def get_wavelengths() -> np.ndarray: - """返回波长数组 (380–1100 nm, 1 nm 步长)。""" - return _load()["wavelengths"].copy() - - -def reconstruct( - raw_values: Sequence[float] | np.ndarray, - offsets: np.ndarray | None = None, - factors: np.ndarray | None = None, -) -> tuple[np.ndarray, np.ndarray]: - """从 10 通道原始 ADC 值重建全光谱。 - - 参数 - ---- - raw_values: - 10 元素序列 [F1, F2, F3, F4, F5, F6, F7, F8, Clear, NIR]。 - offsets: - 每通道暗电流/偏移。默认 → Golden Device 参考值。 - factors: - 每通道校正系数。默认 → Golden Device 参考值。 - - 返回 - ---- - (wavelengths, spectrum) - 两个一维 NumPy 数组 (len = 721)。 - wavelengths — 纳米波长的点 - spectrum — 相对强度值 (a.u.) - """ - cal = _load() - raw = np.asarray(raw_values, dtype=np.float64) - if raw.shape != (10,): - raise ValueError(f"需要 10 通道数据,传入形状为 {raw.shape}") - if np.any(raw < 0): - raise ValueError("原始通道值不应包含负数") - if np.any(np.isnan(raw)) or np.any(np.isinf(raw)): - raise ValueError("原始通道值包含 NaN 或 Inf") - - ofs = cal["offsets"] if offsets is None else np.asarray(offsets, dtype=np.float64) - fac = cal["factors"] if factors is None else np.asarray(factors, dtype=np.float64) - - if offsets is not None and (ofs.shape != (10,) or np.any(np.isnan(ofs))): - raise ValueError("offsets 必须为长度 10 的有效数组") - if factors is not None and (fac.shape != (10,) or np.any(fac <= 0) or np.any(np.isnan(fac))): - raise ValueError("factors 必须为长度 10 的正数数组") - - corrected = fac * np.maximum(raw - ofs, 0.0) - spectrum = np.maximum( - cal["matrix"] @ corrected, 0.0 - ) # (721, 10) @ (10,) → (721,) - - return cal["wavelengths"].copy(), spectrum diff --git a/TController/src/gui/__init__.py b/TController/src/gui/__init__.py deleted file mode 100644 index dbb353d..0000000 --- a/TController/src/gui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""GUI 包 — 实时滴定曲线显示与控制界面。""" diff --git a/TController/src/gui/_plot.py b/TController/src/gui/_plot.py deleted file mode 100644 index b427657..0000000 --- a/TController/src/gui/_plot.py +++ /dev/null @@ -1,117 +0,0 @@ -"""matplotlib blit 加速绘图基类。 - -提供 _BlitPlot,封装 FigureCanvasTkAgg + blit 逻辑, -供 SpectrumWidget / PotentialWidget / 校准曲线复用。 -含空状态覆盖层(未连接/等待数据提示)。 -""" - -from __future__ import annotations - -import tkinter as tk - -import ttkbootstrap as ttk -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -from matplotlib.figure import Figure - - -class _BlitPlot(tk.Frame): - """matplotlib blit 加速绘图基类。 - - blit 流程: - 1. 首帧或坐标轴变化时:canvas.draw() + copy_from_bbox() 捕获背景 - 2. 后续帧:restore_region(bg) + draw_artist(artists) + blit() - - 性能优化: - - 关闭 constrained_layout,改用固定 subplots_adjust(避免每帧布局重算) - - 标签文本缓存(_set_title/_set_xlabel/_set_ylabel 仅在变化时写入) - """ - - def __init__(self, parent: tk.Misc, title: str = "", **kwargs) -> None: - super().__init__(parent, **kwargs) - - self._fig = Figure(figsize=(5, 3), dpi=100) - # 固定边距替代 constrained_layout:后者每次 set_title/set_xlabel - # 都触发布局重算(_set_title_offset_trans),是热路径上的隐性开销 - self._fig.subplots_adjust(left=0.12, right=0.97, top=0.88, bottom=0.14) - self._ax = self._fig.add_subplot(111) - self._ax.set_title(title) - self._canvas = FigureCanvasTkAgg(self._fig, master=self) - self._canvas.get_tk_widget().pack(fill="both", expand=True) - - self._bg = None - self._artists: list = [] - self._needs_full_redraw = True - - # 标签文本缓存:仅当文本实际变化时才调用 set_title/set_xlabel/ - # set_ylabel,避免每帧重复写入(constrained_layout 下会触发布局重算, - # 即使关闭 constrained_layout 也有 artist 标记开销) - self._title_text: str | None = title - self._xlabel_text: str | None = None - self._ylabel_text: str | None = None - - # 空状态覆盖层(默认隐藏) - self._overlay = ttk.Label(self, text="", style="Overlay.TLabel") - self._overlay_visible = False - - # ── 标签缓存写入 ───────────────────────────────────────── - - def _set_title(self, text: str) -> None: - if text != self._title_text: - self._ax.set_title(text) - self._title_text = text - self._request_full_redraw() - - def _set_xlabel(self, text: str) -> None: - if text != self._xlabel_text: - self._ax.set_xlabel(text) - self._xlabel_text = text - self._request_full_redraw() - - def _set_ylabel(self, text: str) -> None: - if text != self._ylabel_text: - self._ax.set_ylabel(text) - self._ylabel_text = text - self._request_full_redraw() - - # ── 空状态覆盖层 ───────────────────────────────────────── - - def show_overlay(self, text: str) -> None: - """在绘图区中央显示提示文字(如"设备未连接")。""" - self._overlay.config(text=text) - if not self._overlay_visible: - self._overlay.place(relx=0.5, rely=0.55, anchor="center") - self._overlay_visible = True - self._overlay.lift() - - def hide_overlay(self) -> None: - if self._overlay_visible: - self._overlay.place_forget() - self._overlay_visible = False - - # ── blit ─────────────────────────────────────────────── - - def _capture_bg(self) -> None: - """捕获背景(坐标轴/网格/标签/标题)。""" - self._canvas.draw() - self._bg = self._canvas.copy_from_bbox(self._ax.bbox) - - def _blit(self) -> None: - """恢复背景 + 重绘 artist + blit 到屏幕。""" - if self._bg is None or self._needs_full_redraw: - self._capture_bg() - self._needs_full_redraw = False - self._canvas.restore_region(self._bg) - for a in self._artists: - self._ax.draw_artist(a) - self._canvas.blit(self._ax.bbox) - - def _request_full_redraw(self) -> None: - """标记需要全量重绘(坐标轴范围/标签变化时调用)。""" - self._needs_full_redraw = True - - def refresh(self) -> None: - """子类实现:更新数据 → 调用 _blit()。""" - self._blit() - - -__all__ = ["_BlitPlot"] diff --git a/TController/src/gui/calibration_tab.py b/TController/src/gui/calibration_tab.py deleted file mode 100644 index a911d90..0000000 --- a/TController/src/gui/calibration_tab.py +++ /dev/null @@ -1,1084 +0,0 @@ -"""校准选项卡 — 泵校准 + 电极校准(ttkbootstrap + matplotlib,i18n 支持)。""" - -from __future__ import annotations - -import os -import tkinter as tk -from tkinter import simpledialog - -import numpy as np -import ttkbootstrap as ttk -from Communication import ProtocolHandler -from DataProcessor._path import CALIBRE_PATH -from ttkbootstrap.dialogs import Messagebox - -from gui import i18n, themes -from gui._plot import _BlitPlot -from gui.themes import MONO_FONT, UI_FONT -from gui.widgets import WorkflowHint - -DATA_DIR = os.path.dirname(CALIBRE_PATH) -os.makedirs(DATA_DIR, exist_ok=True) - - -# ====================================================================== -# 工具:线性回归 -# ====================================================================== - - -def _linreg(x: np.ndarray, y: np.ndarray) -> tuple[float, float, float]: - """(slope, intercept, r²) — 普通线性回归。""" - n = len(x) - sx, sy = x.sum(), y.sum() - sxx = (x * x).sum() - sxy = (x * y).sum() - slope = (n * sxy - sx * sy) / (n * sxx - sx * sx + 1e-12) - intercept = (sy - slope * sx) / n - y_pred = slope * x + intercept - ss_res = ((y - y_pred) ** 2).sum() - ss_tot = ((y - y.mean()) ** 2).sum() - r2 = 1.0 - ss_res / (ss_tot + 1e-12) - return slope, intercept, r2 - - -def _linreg_origin(x: np.ndarray, y: np.ndarray) -> tuple[float, float]: - """过原点回归: V = slope × 脉冲, 返回 (slope, r²)。""" - slope = (x * y).sum() / (x * x).sum() - y_pred = slope * x - ss_res = ((y - y_pred) ** 2).sum() - ss_tot = ((y - y.mean()) ** 2).sum() - r2 = 1.0 - ss_res / (ss_tot + 1e-12) - return slope, r2 - - -# ====================================================================== -# 泵校准面板 -# ====================================================================== - - -class PumpCalibWidget(ttk.Frame): - """单泵校准面板:点动 10000 脉冲 → 输入质量 → 记录 → 拟合。""" - - def __init__( - self, - pump_id: int, - com: ProtocolHandler, - parent: tk.Misc | None = None, - ) -> None: - super().__init__(parent) - self._pump_id = pump_id - self._com = com - self._points: list[tuple[int, float]] = [] # (pulses, volume_mL) - - inner = ttk.Frame(self) - inner.pack(fill="both", expand=True, padx=10, pady=10) - - # ---- 操作区 ---- - ctrl = ttk.Frame(inner) - ctrl.pack(fill="x") - - self._jog_btn = ttk.Button( - ctrl, text=i18n.tr("calib.jog"), bootstyle="primary", command=self._jog - ) - self._jog_btn.pack(side="left") - - self._vol_label = ttk.Label(ctrl, text=i18n.tr("calib.volume"), style="Muted.TLabel") - self._vol_label.pack(side="left", padx=(12, 4)) - - self._vol_input = ttk.Spinbox( - ctrl, from_=0, to=999, increment=0.01, format="%.6f", width=10 - ) - self._vol_input.set(0) - self._vol_input.pack(side="left") - - self._record_btn = ttk.Button( - ctrl, text=i18n.tr("common.record"), bootstyle="success", command=self._record - ) - self._record_btn.pack(side="left", padx=8) - self._undo_btn = ttk.Button( - ctrl, text=i18n.tr("common.undo_last"), bootstyle="outline", command=self._undo - ) - self._undo_btn.pack(side="left", padx=4) - self._clear_btn = ttk.Button( - ctrl, text=i18n.tr("common.clear"), bootstyle="outline", command=self._clear_points - ) - self._clear_btn.pack(side="left", padx=4) - - # ---- 工作流引导 ---- - flow_row = ttk.Frame(inner) - flow_row.pack(fill="x", pady=(8, 0)) - self._flow_caption = ttk.Label( - flow_row, text=i18n.tr("calib.flow") + ":", style="Subtle.TLabel" - ) - self._flow_caption.pack(side="left", padx=(0, 8)) - self._flow = WorkflowHint( - flow_row, ["calib.pump_s1", "calib.pump_s2", "calib.pump_s3"] - ) - self._flow.pack(side="left") - - # ---- 状态 ---- - self._status_label = ttk.Label(inner, text=i18n.tr("common.ready"), style="Muted.TLabel") - self._status_label.pack(anchor="w", pady=(6, 0)) - - # ---- 数据表 ---- - table_frame = ttk.Frame(inner) - table_frame.pack(fill="both", expand=True, pady=4) - self._table = ttk.Treeview( - table_frame, - columns=("idx", "pulses", "volume"), - show="headings", - height=6, - ) - self._table.heading("idx", text=i18n.tr("calib.th_idx")) - self._table.heading("pulses", text=i18n.tr("calib.th_pulses")) - self._table.heading("volume", text=i18n.tr("calib.th_volume")) - self._table.column("idx", width=40, anchor="center") - self._table.column("pulses", width=100, anchor="center") - self._table.column("volume", width=120, anchor="center") - self._table.pack(fill="both", expand=True, side="left") - sb = ttk.Scrollbar(table_frame, command=self._table.yview) - sb.pack(side="right", fill="y") - self._table.configure(yscrollcommand=sb.set) - - # ---- 校准曲线图 ---- - self._plot_widget = _PumpCalibPlot( - inner, title=i18n.tr("calib.pump_curve", id=pump_id) - ) - self._plot_widget.pack(fill="both", expand=True, pady=4) - - # ---- 校准结果 ---- - self._result_label = ttk.Label( - inner, text=i18n.tr("calib.not_fitted"), font=(UI_FONT, 9, "bold") - ) - self._result_label.pack(anchor="w") - - # ---- 保存 ---- - save_row = ttk.Frame(inner) - save_row.pack(fill="x", pady=(6, 0)) - self._save_btn = ttk.Button( - save_row, text=i18n.tr("common.save"), bootstyle="primary", command=self._save - ) - self._save_btn.pack(side="left") - - # 加载已有数据 - self._load() - self._update_table() - self._update_plot() - - i18n.subscribe(self._apply_i18n) - - # ---- i18n ---- - - def _apply_i18n(self) -> None: - self._jog_btn.config(text=i18n.tr("calib.jog")) - self._vol_label.config(text=i18n.tr("calib.volume")) - self._record_btn.config(text=i18n.tr("common.record")) - self._undo_btn.config(text=i18n.tr("common.undo_last")) - self._clear_btn.config(text=i18n.tr("common.clear")) - self._save_btn.config(text=i18n.tr("common.save")) - self._flow_caption.config(text=i18n.tr("calib.flow") + ":") - self._table.heading("idx", text=i18n.tr("calib.th_idx")) - self._table.heading("pulses", text=i18n.tr("calib.th_pulses")) - self._table.heading("volume", text=i18n.tr("calib.th_volume")) - self._plot_widget.set_title(i18n.tr("calib.pump_curve", id=self._pump_id)) - # 拟合结果文本重渲染 - if len(self._points) >= 2: - self._update_plot() - else: - self._result_label.config(text=i18n.tr("calib.not_fitted")) - - # ---- 操作 ---- - - def _jog(self) -> None: - if not self._com.is_open: - self._status_label.config(text=i18n.tr("calib.not_connected")) - return - self._jog_btn.state(["disabled"]) - self._status_label.config(text=i18n.tr("calib.pump_running")) - self._com.request_pump_done_once(self._on_jog_done) - self._com.send_maxcount(self._pump_id, 10000) - # 超时保护 - self.after(5000, lambda: self._jog_btn.state(["!disabled"])) - - def _on_jog_done(self, data: tuple) -> None: - pump_id, position = data - if pump_id != self._pump_id: - return - self._jog_btn.state(["!disabled"]) - self._status_label.config(text=i18n.tr("calib.jog_done", pos=position)) - - def _record(self) -> None: - try: - vol = float(self._vol_input.get()) - except (ValueError, tk.TclError): - self._status_label.config(text=i18n.tr("calib.invalid_volume")) - return - if vol <= 0: - self._status_label.config(text=i18n.tr("calib.volume_positive")) - return - # 累计脉冲数:上一点 + 10000,若无则从 10000 开始 - total_pulses = (self._points[-1][0] + 10000) if self._points else 10000 - self._points.append((total_pulses, vol)) - self._update_table() - self._update_plot() - self._vol_input.set(0) - self._flow.set_active(1) - self._status_label.config( - text=i18n.tr( - "calib.recorded", - n=len(self._points), - pulses=total_pulses, - vol=f"{vol:.6f}", - ) - ) - - def _undo(self) -> None: - if self._points: - self._points.pop() - self._update_table() - self._update_plot() - self._flow.set_active(1 if self._points else 0) - self._status_label.config( - text=i18n.tr("calib.undone", n=len(self._points)) - ) - - def _clear_points(self) -> None: - self._points.clear() - self._update_table() - self._update_plot() - self._flow.set_active(0) - self._result_label.config(text=i18n.tr("calib.not_fitted")) - self._status_label.config(text=i18n.tr("calib.cleared")) - - # ---- 显示 ---- - - def _update_table(self) -> None: - for item in self._table.get_children(): - self._table.delete(item) - for i, (pulses, vol) in enumerate(self._points): - self._table.insert( - "", "end", values=(i + 1, pulses, f"{vol:.6f}") - ) - - def _update_plot(self) -> None: - if len(self._points) < 2: - self._plot_widget.clear() - self._result_label.config(text=i18n.tr("calib.need_two")) - return - xs = np.array([p for p, _ in self._points], dtype=np.float64) - ys = np.array([v for _, v in self._points], dtype=np.float64) - - slope, r2 = _linreg_origin(xs, ys) - x_fit = np.linspace(0, xs.max() * 1.05, 200) - y_fit = slope * x_fit - self._plot_widget.update_data(xs, ys, x_fit, y_fit) - - self._result_label.config( - text=i18n.tr( - "calib.fit", slope=f"{slope:.10f}", r2=f"{r2:.6f}", n=len(self._points) - ) - ) - - # ---- 保存 / 加载 ---- - - def _save_path(self) -> str: - return os.path.join(DATA_DIR, "calibre.npz") - - def _save(self) -> None: - if len(self._points) < 2: - self._status_label.config(text=i18n.tr("calib.need_two_save")) - return - xs = np.array([p for p, _ in self._points], dtype=np.float64) - ys = np.array([v for _, v in self._points], dtype=np.float64) - slope, r2 = _linreg_origin(xs, ys) - pulses = np.array([p for p, _ in self._points], dtype=np.int32) - volumes = np.array([v for _, v in self._points], dtype=np.float64) - path = self._save_path() - # 与既有数据合并保存 - _merge = {} - if os.path.isfile(path): - try: - _old = np.load(path, allow_pickle=True) - for _k in _old: - _merge[_k] = _old[_k] - except Exception: - pass - _p = f"pump{self._pump_id}_" - _merge[f"{_p}pulses"] = pulses - _merge[f"{_p}volumes"] = volumes - _merge[f"{_p}slope"] = slope - _merge[f"{_p}intercept"] = 0.0 - _merge[f"{_p}r2"] = r2 - np.savez_compressed(path, **_merge) - self._flow.set_active(2) - self._status_label.config(text=i18n.tr("calib.saved", path=path)) - - def _load(self) -> None: - path = self._save_path() - if not os.path.isfile(path): - return - try: - data = np.load(path, allow_pickle=True) - _p = f"pump{self._pump_id}_" - if _p + "pulses" in data: - pulses = data[f"{_p}pulses"] - volumes = data[f"{_p}volumes"] - self._points = [ - (int(p), float(v)) for p, v in zip(pulses, volumes) - ] - self._flow.set_active(1) - self._status_label.config( - text=i18n.tr("calib.loaded", n=len(self._points)) - ) - else: - self._status_label.config(text=i18n.tr("calib.no_data")) - except Exception: - self._status_label.config(text=i18n.tr("calib.load_failed")) - self._update_table() - self._update_plot() - - -# ====================================================================== -# 泵校准绘图(matplotlib blit) -# ====================================================================== - - -class _PumpCalibPlot(_BlitPlot): - """泵校准散点 + 拟合线。""" - - def __init__(self, parent: tk.Misc, title: str = "", **kwargs) -> None: - super().__init__(parent, title=title, **kwargs) - self._set_xlabel(i18n.tr("calib.th_pulses")) - self._set_ylabel(i18n.tr("calib.th_volume")) - self._ax.grid(True, alpha=0.25) - - t = themes.current_tokens() - self._scatter = self._ax.scatter( - [], [], c=t.plot_scatter, s=40, zorder=3 - ) - (self._fit_line,) = self._ax.plot( - [], [], color=t.plot_fit, linewidth=2, zorder=2 - ) - - self._artists = [self._fit_line, self._scatter] - self._capture_bg() - - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - def set_title(self, title: str) -> None: - self._set_title(title) - self.refresh() - - def _apply_i18n(self) -> None: - self._set_xlabel(i18n.tr("calib.th_pulses")) - self._set_ylabel(i18n.tr("calib.th_volume")) - self.refresh() - - def _apply_theme(self) -> None: - t = themes.current_tokens() - self._scatter.set_facecolor(t.plot_scatter) - self._fit_line.set_color(t.plot_fit) - self._request_full_redraw() - self.refresh() - - def update_data( - self, - xs: np.ndarray, - ys: np.ndarray, - x_fit: np.ndarray, - y_fit: np.ndarray, - ) -> None: - self._scatter.set_offsets(np.column_stack([xs, ys])) - self._fit_line.set_data(x_fit, y_fit) - - # 自动范围 - all_x = np.concatenate([xs, x_fit]) - all_y = np.concatenate([ys, y_fit]) - self._ax.set_xlim(float(all_x.min()) * 0.95, float(all_x.max()) * 1.05) - self._ax.set_ylim(float(all_y.min()) * 0.95, float(all_y.max()) * 1.05) - self._request_full_redraw() - self.refresh() - - def clear(self) -> None: - self._scatter.set_offsets(np.zeros((0, 2))) - self._fit_line.set_data([], []) - self._request_full_redraw() - self.refresh() - - -# ====================================================================== -# 电极校准面板 -# ====================================================================== - - -class PHCalibWidget(ttk.Frame): - """电极校准:支持多个电极配置,可命名、设单位(pX)和备注。""" - - def __init__(self, com: ProtocolHandler, parent: tk.Misc | None = None) -> None: - super().__init__(parent) - self._com = com - self._current_mv: float = 0.0 - self._data: dict = self._load_data() - - inner = ttk.Frame(self) - inner.pack(fill="both", expand=True, padx=10, pady=10) - - # ---- 电极选择 ---- - sel_row = ttk.Frame(inner) - sel_row.pack(fill="x") - self._electrode_label = ttk.Label(sel_row, text=i18n.tr("calib.electrode"), style="Muted.TLabel") - self._electrode_label.pack(side="left") - self._electrode_combo = ttk.Combobox( - sel_row, state="readonly", width=15 - ) - self._electrode_combo.pack(side="left", padx=4) - self._electrode_combo.bind( - "<>", lambda _e: self._on_select() - ) - - self._add_elec_btn = ttk.Button(sel_row, text="+", width=3, command=self._add_electrode) - self._add_elec_btn.pack(side="left", padx=2) - self._del_elec_btn = ttk.Button(sel_row, text="—", width=3, command=self._del_electrode) - self._del_elec_btn.pack(side="left", padx=2) - - # ---- 属性 ---- - attr_row = ttk.Frame(inner) - attr_row.pack(fill="x", pady=(6, 0)) - self._unit_label = ttk.Label(attr_row, text=i18n.tr("calib.unit"), style="Muted.TLabel") - self._unit_label.pack(side="left") - self._unit_var = tk.StringVar(value="pH") - self._unit_entry = ttk.Entry(attr_row, textvariable=self._unit_var, width=6) - self._unit_entry.pack(side="left", padx=4) - self._unit_var.trace_add("write", self._mark_dirty) - self._notes_label = ttk.Label(attr_row, text=i18n.tr("calib.notes"), style="Muted.TLabel") - self._notes_label.pack(side="left", padx=(10, 0)) - self._notes_var = tk.StringVar() - self._notes_entry = ttk.Entry(attr_row, textvariable=self._notes_var) - self._notes_entry.pack(side="left", fill="x", expand=True, padx=4) - self._notes_var.trace_add("write", self._mark_dirty) - - # ---- 实时电压 ---- - live_row = ttk.Frame(inner) - live_row.pack(fill="x", pady=6) - self._live_caption = ttk.Label(live_row, text=i18n.tr("calib.current_potential"), style="Muted.TLabel") - self._live_caption.pack(side="left") - self._live_label = ttk.Label( - live_row, - text="--.-- mV", - font=(MONO_FONT, 12, "bold"), - foreground=themes.current_tokens().secondary, - ) - self._live_label.pack(side="left", padx=6) - - # ---- 输入区 ---- - input_row = ttk.Frame(inner) - input_row.pack(fill="x") - self._std_label = ttk.Label(input_row, text=i18n.tr("calib.std_value"), style="Muted.TLabel") - self._std_label.pack(side="left") - self._val_input = ttk.Spinbox( - input_row, from_=-10, to=20, increment=0.1, format="%.2f", width=8 - ) - self._val_input.set(7.00) - self._val_input.pack(side="left", padx=4) - self._confirm_btn = ttk.Button( - input_row, text=i18n.tr("calib.confirm"), bootstyle="success", command=self._confirm - ) - self._confirm_btn.pack(side="left", padx=4) - self._undo_btn = ttk.Button( - input_row, text=i18n.tr("common.undo"), bootstyle="outline", command=self._undo - ) - self._undo_btn.pack(side="left", padx=4) - - # ---- 工作流引导 ---- - flow_row = ttk.Frame(inner) - flow_row.pack(fill="x", pady=(8, 0)) - self._ph_flow_caption = ttk.Label( - flow_row, text=i18n.tr("calib.flow") + ":", style="Subtle.TLabel" - ) - self._ph_flow_caption.pack(side="left", padx=(0, 8)) - self._flow = WorkflowHint( - flow_row, ["calib.ph_s1", "calib.ph_s2", "calib.ph_s3"] - ) - self._flow.pack(side="left") - - # ---- 数据表 ---- - table_frame = ttk.Frame(inner) - table_frame.pack(fill="both", expand=True, pady=4) - self._table = ttk.Treeview( - table_frame, - columns=("val", "mv"), - show="headings", - height=6, - ) - self._table.heading("val", text=i18n.tr("calib.th_std")) - self._table.heading("mv", text=i18n.tr("calib.th_mv")) - self._table.column("val", width=120, anchor="center") - self._table.column("mv", width=120, anchor="center") - self._table.pack(fill="both", expand=True, side="left") - sb = ttk.Scrollbar(table_frame, command=self._table.yview) - sb.pack(side="right", fill="y") - self._table.configure(yscrollcommand=sb.set) - - # ---- 拟合结果 ---- - self._result_label = ttk.Label( - inner, text=i18n.tr("calib.not_calibrated"), font=(UI_FONT, 10, "bold") - ) - self._result_label.pack(anchor="w") - - # ---- 校准曲线图 ---- - self._plot_widget = _PHCalibPlot(inner, title=i18n.tr("calib.electrode_curve")) - self._plot_widget.pack(fill="both", expand=True, pady=4) - - # ---- 保存 ---- - self._save_btn = ttk.Button( - inner, text=i18n.tr("common.save"), bootstyle="primary", command=self._save - ) - self._save_btn.pack(anchor="w", pady=(6, 0)) - - # ---- 连接实时数据 ---- - self._com.on("adc", self._on_adc) - self._rebuild_combo() - self._dirty = False - - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - # ---- i18n / 主题 ---- - - def _apply_i18n(self) -> None: - self._electrode_label.config(text=i18n.tr("calib.electrode")) - self._unit_label.config(text=i18n.tr("calib.unit")) - self._notes_label.config(text=i18n.tr("calib.notes")) - self._live_caption.config(text=i18n.tr("calib.current_potential")) - self._std_label.config(text=i18n.tr("calib.std_value")) - self._confirm_btn.config(text=i18n.tr("calib.confirm")) - self._undo_btn.config(text=i18n.tr("common.undo")) - self._save_btn.config(text=i18n.tr("common.save")) - self._ph_flow_caption.config(text=i18n.tr("calib.flow") + ":") - self._table.heading("val", text=i18n.tr("calib.th_std")) - self._table.heading("mv", text=i18n.tr("calib.th_mv")) - _mvs, vals = self._get_points() - if len(vals) >= 2: - self._recalc() - else: - self._result_label.config(text=i18n.tr("calib.not_calibrated")) - - def _apply_theme(self) -> None: - self._live_label.config(foreground=themes.current_tokens().secondary) - - # ---- 数据文件 ---- - - @staticmethod - def _data_path() -> str: - return os.path.join(DATA_DIR, "calibre.npz") - - @staticmethod - def _load_data() -> dict: - path = PHCalibWidget._data_path() - result = {"electrodes": {}, "current": ""} - if not os.path.isfile(path): - return result - try: - data = np.load(path, allow_pickle=True) - if "n_electrodes" not in data: - return result - n = int(data["n_electrodes"]) - names = list(data["names"]) - result["current"] = str(data["current"]) - for i in range(n): - name = str(names[i]) - pts_vals = data[f"points_vals_{i}"] - pts_mvs = data[f"points_mvs_{i}"] - points = [ - (float(pts_vals[j]), float(pts_mvs[j])) - for j in range(len(pts_vals)) - ] - result["electrodes"][name] = { - "unit": str(data[f"unit_{i}"]), - "notes": str(data[f"notes_{i}"]), - "points": points, - "slope": float(data[f"slope_{i}"]), - "intercept": float(data[f"intercept_{i}"]), - "r2": float(data[f"r2_{i}"]), - } - except Exception: - pass - return result - - def _save_data(self) -> None: - path = self._data_path() - # 与既有泵数据合并保存 - _merge = {} - if os.path.isfile(path): - try: - _old = np.load(path, allow_pickle=True) - for _k in _old: - if ( - not _k.startswith("n_electrodes") - and not _k.startswith("names") - and not _k.startswith("current") - and not _k.startswith("points_") - and not _k.startswith("slope_") - and not _k.startswith("intercept_") - and not _k.startswith("r2_") - and not _k.startswith("unit_") - and not _k.startswith("notes_") - ): - _merge[_k] = _old[_k] - except Exception: - pass - electrodes = self._data.get("electrodes", {}) - names = list(electrodes.keys()) - _merge["n_electrodes"] = np.int32(len(names)) - _merge["names"] = np.array(names, dtype=object) - _merge["current"] = np.array(self._data.get("current", ""), dtype=object) - for i, name in enumerate(names): - e = electrodes[name] - pts = e.get("points", []) - _merge[f"points_vals_{i}"] = np.array( - [p for p, _ in pts], dtype=np.float64 - ) - _merge[f"points_mvs_{i}"] = np.array( - [m for _, m in pts], dtype=np.float64 - ) - _merge[f"slope_{i}"] = np.float64(e.get("slope", 0.0)) - _merge[f"intercept_{i}"] = np.float64(e.get("intercept", 0.0)) - _merge[f"r2_{i}"] = np.float64(e.get("r2", 0.0)) - _merge[f"unit_{i}"] = np.array(e.get("unit", "pX"), dtype=object) - _merge[f"notes_{i}"] = np.array(e.get("notes", ""), dtype=object) - np.savez_compressed(path, **_merge) - - # ---- 电极选择 ---- - - def _rebuild_combo(self) -> None: - values = list(self._data["electrodes"].keys()) - self._electrode_combo["values"] = values - cur = self._data.get("current", "") - if cur in values: - self._electrode_combo.set(cur) - elif values: - self._electrode_combo.set(values[0]) - self._load_current() - - def _on_select(self) -> None: - name = self._electrode_combo.get() - if name: - self._data["current"] = name - self._save_data() - self._load_current() - - def _load_current(self) -> None: - name = self._electrode_combo.get() - if not name or name not in self._data["electrodes"]: - self._clear_table() - self._result_label.config(text=i18n.tr("calib.not_calibrated")) - self._plot_widget.clear() - return - e = self._data["electrodes"][name] - self._unit_var.set(e.get("unit", "pX")) - self._notes_var.set(e.get("notes", "")) - # 填充表格 - self._clear_table() - for val, mv in e.get("points", []): - self._table.insert("", "end", values=(f"{val:.2f}", f"{mv:.1f}")) - self._flow.set_active(1 if e.get("points") else 0) - self._recalc() - - def _clear_table(self) -> None: - for item in self._table.get_children(): - self._table.delete(item) - - # ---- 电极管理 ---- - - def _add_electrode(self) -> None: - name = simpledialog.askstring( - i18n.tr("calib.add_title"), i18n.tr("calib.add_name"), parent=self - ) - if not name or not name.strip(): - return - name = name.strip() - if name in self._data["electrodes"]: - name = f"{name} ({len(self._data['electrodes']) + 1})" - self._data["electrodes"][name] = { - "unit": "pX", - "notes": "", - "points": [], - "slope": 0.0, - "intercept": 0.0, - "r2": 0.0, - } - self._data["current"] = name - self._rebuild_combo() - self._save_data() - - def _del_electrode(self) -> None: - name = self._electrode_combo.get() - if not name or name not in self._data["electrodes"]: - return - if len(self._data["electrodes"]) <= 1: - return # 至少保留一个 - answer = Messagebox.yesno( - i18n.tr("confirm.del_elec_msg", name=name), - i18n.tr("confirm.del_elec_title"), - parent=self.winfo_toplevel(), - alert=True, - ) - if answer != "Yes": - return - del self._data["electrodes"][name] - self._data["current"] = next(iter(self._data["electrodes"].keys())) - self._rebuild_combo() - self._save_data() - - # ---- 实时电压 ---- - - def _on_adc(self, data: tuple) -> None: - raw, _pump2_pos = data - self._current_mv = (raw * 3300.0 / 65535) - 1100.0 - self._live_label.config(text=f"{self._current_mv:.1f} mV") - - # ---- 确认 / 撤销 ---- - - def _confirm(self) -> None: - name = self._electrode_combo.get() - if not name: - return - try: - val = float(self._val_input.get()) - except (ValueError, tk.TclError): - return - mv = self._current_mv - self._table.insert("", "end", values=(f"{val:.2f}", f"{mv:.1f}")) - self._dirty = True - self._flow.set_active(1) - self._recalc() - - def _undo(self) -> None: - items = self._table.get_children() - if items: - self._table.delete(items[-1]) - self._dirty = True - self._flow.set_active(1 if self._table.get_children() else 0) - self._recalc() - - # ---- 读取数据 ---- - - def _get_points(self) -> tuple[np.ndarray, np.ndarray]: - mvs, vals = [], [] - for item in self._table.get_children(): - try: - row = self._table.item(item, "values") - val = float(row[0]) - mv = float(row[1]) - except (ValueError, TypeError, IndexError): - continue - vals.append(val) - mvs.append(mv) - return np.array(mvs, dtype=np.float64), np.array(vals, dtype=np.float64) - - # ---- 计算 ---- - - def _mark_dirty(self, *args: object) -> None: - self._dirty = True - - def _recalc(self) -> None: - mvs, vals = self._get_points() - unit = self._unit_var.get() or "pX" - self._plot_widget.set_ylabel(unit) - if len(vals) < 2: - self._result_label.config(text=i18n.tr("calib.need_two")) - self._plot_widget.clear() - return - slope, intercept, r2 = _linreg(mvs, vals) - self._result_label.config( - text=i18n.tr( - "calib.electrode_fit", - unit=unit, - intercept=f"{intercept:.4f}", - slope=f"{slope:.6f}", - r2=f"{r2:.6f}", - ) - ) - margin = max(20, (mvs.max() - mvs.min()) * 0.2) - x_fit = np.linspace(mvs.min() - margin, mvs.max() + margin, 200) - y_fit = slope * x_fit + intercept - self._plot_widget.update_data(mvs, vals, x_fit, y_fit) - - # ---- 保存 ---- - - def _save(self) -> None: - name = self._electrode_combo.get() - if not name: - return - self._recalc() - mvs, vals = self._get_points() - if len(vals) < 2: - slope, intercept, r2 = 0.0, 0.0, 0.0 - else: - slope, intercept, r2 = _linreg(mvs, vals) - self._data["electrodes"][name] = { - "unit": self._unit_var.get(), - "notes": self._notes_var.get(), - "points": [(float(v), float(m)) for v, m in zip(vals, mvs)], - "slope": slope, - "intercept": intercept, - "r2": r2, - } - self._save_data() - self._dirty = False - self._flow.set_active(2) - self._result_label.config( - text=i18n.tr( - "calib.saved_electrode", - name=name, - unit=self._unit_var.get(), - intercept=f"{intercept:.4f}", - slope=f"{slope:.6f}", - r2=f"{r2:.6f}", - ) - ) - - -# ====================================================================== -# 电极校准绘图(matplotlib blit) -# ====================================================================== - - -class _PHCalibPlot(_BlitPlot): - """电极校准散点 + 拟合线。""" - - def __init__(self, parent: tk.Misc, title: str = "", **kwargs) -> None: - super().__init__(parent, title=title, **kwargs) - self._set_xlabel(i18n.tr("calib.th_mv")) - self._set_ylabel("pX") - self._ax.grid(True, alpha=0.25) - - t = themes.current_tokens() - self._scatter = self._ax.scatter( - [], [], c=t.plot_scatter_ph, s=50, zorder=3 - ) - (self._fit_line,) = self._ax.plot( - [], [], color=t.plot_fit, linewidth=2, zorder=2 - ) - - self._artists = [self._fit_line, self._scatter] - self._capture_bg() - - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - def _apply_i18n(self) -> None: - self._set_title(i18n.tr("calib.electrode_curve")) - self._set_xlabel(i18n.tr("calib.th_mv")) - self.refresh() - - def _apply_theme(self) -> None: - t = themes.current_tokens() - self._scatter.set_facecolor(t.plot_scatter_ph) - self._fit_line.set_color(t.plot_fit) - self._request_full_redraw() - self.refresh() - - def set_ylabel(self, label: str) -> None: - self._set_ylabel(label) - - def update_data( - self, - xs: np.ndarray, - ys: np.ndarray, - x_fit: np.ndarray, - y_fit: np.ndarray, - ) -> None: - self._scatter.set_offsets(np.column_stack([xs, ys])) - self._fit_line.set_data(x_fit, y_fit) - - all_x = np.concatenate([xs, x_fit]) - all_y = np.concatenate([ys, y_fit]) - x_margin = (all_x.max() - all_x.min()) * 0.1 + 1 - y_margin = (all_y.max() - all_y.min()) * 0.1 + 0.1 - self._ax.set_xlim( - float(all_x.min()) - x_margin, float(all_x.max()) + x_margin - ) - self._ax.set_ylim( - float(all_y.min()) - y_margin, float(all_y.max()) + y_margin - ) - self._request_full_redraw() - self.refresh() - - def clear(self) -> None: - self._scatter.set_offsets(np.zeros((0, 2))) - self._fit_line.set_data([], []) - self._request_full_redraw() - self.refresh() - - -# ====================================================================== -# 光谱重建矩阵热力图 -# ====================================================================== - - -class SpectralMatrixWidget(ttk.Frame): - """光谱重建矩阵热力图 (721 波长 × 10 通道)。""" - - def __init__(self, parent: tk.Misc | None = None) -> None: - super().__init__(parent) - inner = ttk.Frame(self) - inner.pack(fill="both", expand=True, padx=10, pady=10) - - from DataProcessor.reconstructor import is_available - - self._matrix: np.ndarray | None = None - - if not is_available(): - self._no_data_label = ttk.Label( - inner, text=i18n.tr("calib.matrix_not_loaded"), style="Muted.TLabel" - ) - self._no_data_label.pack() - i18n.subscribe(self._apply_i18n) - return - - # Load matrix from calibre.npz - _p = os.path.join(DATA_DIR, "calibre.npz") - _d = np.load(_p, allow_pickle=True) - matrix = _d["spectral_matrix"] # (721, 10) - wls = _d["spectral_wavelengths"] # (721,) - ch_names = [ - "F1(415)", "F2(445)", "F3(480)", "F4(515)", "F5(555)", - "F6(590)", "F7(630)", "F8(680)", "Clear", "NIR(910)", - ] - self._matrix = matrix - - # ---- 热力图 ---- - self._plot_widget = _MatrixPlot( - inner, title=self._matrix_title(matrix) - ) - self._plot_widget.pack(fill="both", expand=True) - self._plot_widget.set_data(matrix, wls, ch_names) - - # ---- 说明 ---- - self._info_label = ttk.Label( - inner, - text=self._matrix_info(matrix), - wraplength=640, - style="Subtle.TLabel", - ) - self._info_label.pack(fill="x", pady=(6, 0)) - - i18n.subscribe(self._apply_i18n) - - @staticmethod - def _matrix_title(matrix: np.ndarray) -> str: - return i18n.tr("calib.matrix_title", wl=matrix.shape[0], ch=matrix.shape[1]) - - @staticmethod - def _matrix_info(matrix: np.ndarray) -> str: - return i18n.tr( - "calib.matrix_info", - rows=matrix.shape[0], - cols=matrix.shape[1], - vmin=f"{matrix.min():.4f}", - vmax=f"{matrix.max():.4f}", - ) - - def _apply_i18n(self) -> None: - if self._matrix is None: - self._no_data_label.config(text=i18n.tr("calib.matrix_not_loaded")) - return - self._plot_widget.set_title(self._matrix_title(self._matrix)) - self._info_label.config(text=self._matrix_info(self._matrix)) - - -class _MatrixPlot(_BlitPlot): - """光谱矩阵热力图(matplotlib imshow)。""" - - def __init__(self, parent: tk.Misc, title: str = "", **kwargs) -> None: - super().__init__(parent, title=title, **kwargs) - self._set_xlabel(i18n.tr("calib.matrix_channel")) - self._set_ylabel(i18n.tr("plot.wavelength")) - self._img = None - self._artists = [] - - i18n.subscribe(self._apply_i18n) - - def _apply_i18n(self) -> None: - self._set_xlabel(i18n.tr("calib.matrix_channel")) - self._set_ylabel(i18n.tr("plot.wavelength")) - self.refresh() - - def set_title(self, title: str) -> None: - self._set_title(title) - self.refresh() - - def set_data( - self, - matrix: np.ndarray, - wls: np.ndarray, - ch_names: list[str], - ) -> None: - # 转置为 (10, 721):行=通道,列=波长 - data = matrix.T - self._img = self._ax.imshow( - data, - aspect="auto", - origin="lower", - extent=(-0.5, 9.5, float(wls[0]), float(wls[-1])), - cmap="viridis", - interpolation="nearest", - ) - self._ax.set_xticks(range(10)) - self._ax.set_xticklabels(ch_names, rotation=45, ha="right", fontsize=8) - self._ax.set_xlim(-0.5, 9.5) - self._ax.set_ylim(float(wls[0]), float(wls[-1])) - self._fig.colorbar(self._img, ax=self._ax) - self._request_full_redraw() - self._capture_bg() - - -# ====================================================================== -# 校准选项卡 -# ====================================================================== - - -class CalibrationTab(ttk.Frame): - """校准选项卡 — 进样泵 / 滴定泵 / 电极 / 光谱矩阵。""" - - def __init__(self, com: ProtocolHandler, parent: tk.Misc | None = None) -> None: - super().__init__(parent) - inner = ttk.Frame(self) - inner.pack(fill="both", expand=True) - - self._tabs = ttk.Notebook(inner) - self._tabs.pack(fill="both", expand=True) - - self._pump1 = PumpCalibWidget(1, com, parent=self._tabs) - self._pump2 = PumpCalibWidget(2, com, parent=self._tabs) - self._ph = PHCalibWidget(com, parent=self._tabs) - self._matrix = SpectralMatrixWidget(parent=self._tabs) - - self._tabs.add(self._pump1, text=i18n.tr("calib.tab_pump1")) - self._tabs.add(self._pump2, text=i18n.tr("calib.tab_pump2")) - self._tabs.add(self._ph, text=i18n.tr("calib.tab_electrode")) - self._tabs.add(self._matrix, text=i18n.tr("calib.tab_matrix")) - - i18n.subscribe(self._apply_i18n) - - def _apply_i18n(self) -> None: - self._tabs.tab(0, text=i18n.tr("calib.tab_pump1")) - self._tabs.tab(1, text=i18n.tr("calib.tab_pump2")) - self._tabs.tab(2, text=i18n.tr("calib.tab_electrode")) - self._tabs.tab(3, text=i18n.tr("calib.tab_matrix")) - - @property - def plots(self) -> list: - """返回所有绘图 widget,供主题切换使用。""" - result = [] - for w in (self._pump1, self._pump2, self._ph): - if hasattr(w, "_plot_widget"): - result.append(w._plot_widget) - if hasattr(self._matrix, "_plot_widget"): - result.append(self._matrix._plot_widget) - return result - - -__all__ = ["CalibrationTab"] diff --git a/TController/src/gui/i18n.py b/TController/src/gui/i18n.py deleted file mode 100644 index 73503de..0000000 --- a/TController/src/gui/i18n.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -i18n 模块 — 轻量运行时国际化(zh_CN / en_US)。 - -词条文件位于 gui/locales/.json(嵌套结构,加载时展平为点分键)。 - -用法:: - - from gui.i18n import tr, set_language, subscribe - - label = ttk.Label(parent, text=tr("toolbar.connect")) - subscribe(my_refresh_callback) # 语言切换后刷新静态文本 - set_language("en_US") # 切换并通知所有订阅者 - -带参数的词条使用命名占位符:: - - tr("status.endpoint_t1", vol="1.234") -""" - -from __future__ import annotations - -import json -import os -from collections.abc import Callable - -_LOCALES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales") - -# 语言代码 → 该语言下的自称(显示用,不翻译) -LANGS: dict[str, str] = { - "zh_CN": "简体中文", - "en_US": "English", -} - -DEFAULT_LANG = "zh_CN" -FALLBACK_LANG = "zh_CN" - - -def _flatten(node: dict, prefix: str = "") -> dict[str, str]: - """嵌套 dict → {'a.b.c': 'text'}。""" - out: dict[str, str] = {} - for k, v in node.items(): - key = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict): - out.update(_flatten(v, key)) - else: - out[key] = str(v) - return out - - -class _Translator: - def __init__(self) -> None: - self._lang = DEFAULT_LANG - self._dicts: dict[str, dict[str, str]] = {} - self._subs: list[Callable[[], None]] = [] - for code in LANGS: - self._load(code) - - def _load(self, code: str) -> None: - path = os.path.join(_LOCALES_DIR, f"{code}.json") - try: - with open(path, encoding="utf-8") as f: - self._dicts[code] = _flatten(json.load(f)) - except (OSError, json.JSONDecodeError): - self._dicts[code] = {} - - # ---- 语言 ---- - - @property - def language(self) -> str: - return self._lang - - def set_language(self, code: str) -> bool: - """切换语言;若有变化则通知订阅者。返回是否实际切换。""" - if code not in LANGS or code == self._lang: - return False - self._lang = code - self._notify() - return True - - # ---- 翻译 ---- - - def tr(self, key: str, **kwargs: object) -> str: - table = self._dicts.get(self._lang, {}) - text = table.get(key) - if text is None: - text = self._dicts.get(FALLBACK_LANG, {}).get(key, key) - if kwargs: - try: - return text.format(**kwargs) - except (KeyError, IndexError, ValueError): - return text - return text - - # ---- 订阅(语言切换后刷新静态文本)---- - - def subscribe(self, cb: Callable[[], None]) -> None: - if cb not in self._subs: - self._subs.append(cb) - - def unsubscribe(self, cb: Callable[[], None]) -> None: - if cb in self._subs: - self._subs.remove(cb) - - def _notify(self) -> None: - for cb in list(self._subs): - try: - cb() - except Exception: # 单个控件刷新失败不应阻断其它控件 - import traceback - - traceback.print_exc() - - -_inst = _Translator() - -tr = _inst.tr -set_language = _inst.set_language -subscribe = _inst.subscribe -unsubscribe = _inst.unsubscribe - - -def current_language() -> str: - return _inst.language - - -__all__ = [ - "DEFAULT_LANG", - "LANGS", - "current_language", - "set_language", - "subscribe", - "tr", - "unsubscribe", -] diff --git a/TController/src/gui/locales/en_US.json b/TController/src/gui/locales/en_US.json deleted file mode 100644 index ca4cbc3..0000000 --- a/TController/src/gui/locales/en_US.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "app": { - "title": "Multimodal Automatic Titration Controller" - }, - "common": { - "start": "Start", - "stop": "Stop", - "save": "Save", - "record": "Record", - "undo_last": "Undo Last", - "undo": "Undo", - "clear": "Clear", - "ready": "Ready" - }, - "pump": { - "inject": "Sample Pump", - "titrate": "Titrant Pump" - }, - "toolbar": { - "port": "Port:", - "baud": "Baud Rate:", - "more": "More", - "connect": "Connect", - "disconnect": "Disconnect", - "sample_volume": "Sample Volume (mL):", - "start": "Start Titration", - "stop": "Stop Titration", - "emergency_stop": "E-STOP", - "reset_mcu": "Reset MCU", - "record": "Record Data", - "language": "Language:", - "theme": "Theme:", - "group_conn": "Connection", - "group_run": "Titration Control", - "group_dev": "Device", - "group_view": "View" - }, - "phases": { - "ready": "Ready", - "injecting": "Injecting", - "titrating": "Titrating", - "detecting": "Endpoint Detection", - "done": "Done" - }, - "gauge": { - "title": "Titration Degree", - "t1": "T=1" - }, - "overlay": { - "disconnected": "Device not connected — live data unavailable", - "waiting": "Waiting for data…" - }, - "msg": { - "connected": "Connected to {port}", - "disconnected": "Device disconnected" - }, - "confirm": { - "reset_title": "Reset MCU", - "reset_msg": "Resetting the MCU stops all running pumps and aborts the current titration. Continue?", - "del_elec_title": "Delete Electrode", - "del_elec_msg": "Delete electrode \"{name}\"? Its calibration data will be removed as well." - }, - "tooltip": { - "start": "Start titration (F5)", - "estop": "Emergency stop (Esc)" - }, - "theme": { - "light": "Light", - "dark": "Dark", - "system": "Follow System" - }, - "tabs": { - "titration": "Titration", - "calibration": "Calibration", - "maintenance": "Maintenance" - }, - "states": { - "idle": "Idle", - "injecting": "Injecting…", - "titrating": "Titrating…", - "degree1": "Endpoint T=1", - "titrating2": "Titrating to T=2…", - "done": "Done", - "error": "Error" - }, - "conn": { - "connected": "Connected", - "disconnected": "Disconnected", - "hint": "Notice", - "select_port": "Please select a serial port", - "connect_first": "Please connect the serial port first" - }, - "status": { - "error_fmt": "Error: {msg}", - "emergency_stopped": "Emergency stop — MCU reset", - "reset_done": "Reset complete (MCU + host)", - "manual_stopped": "Manually stopped", - "manual_stopped_at": "Manual stop @ {vol} mL", - "endpoint_t1": "Endpoint T=1 @ {vol} mL", - "endpoint_t1_conflict": "Endpoint T=1 @ {vol} mL (potential only, spectral failed the gate)", - "endpoint_final": "Endpoint: {vol} mL", - "progress": "T=1 @ {ep} mL | T={t} vol={vol} mL", - "detect": "Detected: {vol} mL, confidence={conf}", - "candidate": "Candidate: {vol} mL; waiting for potential/KF consensus", - "injecting": "Injecting {vol} mL …", - "pump_done": "Pump {id} done pos={pos}", - "pump2_vol": "Pump 2 vol={vol} mL +{diff}", - "exported": "Data exported: {file}", - "recording_on": "Data recording started", - "recording_off": "Data recording stopped", - "nak": "NAK" - }, - "results": { - "title": "Titration Calculation", - "electrode": "Electrode", - "raw_potential": "Raw Potential (V)", - "stoich": "Stoichiometry", - "n_std": "Standard (n₁):", - "n_analyte": "Analyte (n₂):", - "conc": "Standard Concentration", - "c_std": "C₁ (mol/L):", - "sample": "Analyte Solution", - "sample_volume": "Sample Volume (mL):", - "current_voltage": "Current Voltage:", - "inject": "Injection Progress", - "inject_done": "Injection complete", - "waiting": "Waiting to start", - "eta": "ETA {eta}", - "result": "Results", - "endpoint": "Endpoint Volume", - "endpoint_unit": "mL", - "cx": "Cₓ Concentration", - "cx_unit": "mol/L", - "diagnostics": "Online Reliability", - "diag_status": "Status", - "diag_quality": "Data quality", - "diag_consistency": "Modal difference", - "diag_nis": "NIS", - "diag_std": "Endpoint std", - "diag_delay": "Spectral delay" - }, - "plot": { - "spectrum": "Real-time Spectrum", - "wavelength": "Wavelength (nm)", - "intensity": "Intensity (a.u.)", - "potential": "Potential–Volume", - "px": "{unit}–Volume", - "time": "Time (s)", - "potential_v": "Potential (V)", - "volume": "Volume (mL)", - "degree": "Titration degree (T)", - "alpha": "Smoothing α:" - }, - "calib": { - "tab_pump1": "Sample Pump", - "tab_pump2": "Titrant Pump", - "tab_electrode": "Electrode Calibration", - "tab_matrix": "Spectral Matrix", - "jog": "Jog 10000 Pulses", - "volume": "Volume (mL):", - "not_connected": "Serial port not connected", - "pump_running": "Pump running…", - "jog_done": "Jog complete, position: {pos} pulses — enter the dispensed volume", - "invalid_volume": "Invalid volume input", - "volume_positive": "Volume must be greater than 0", - "recorded": "Recorded ({n}): {pulses} pulses → {vol} mL", - "undone": "Undone, {n} points remaining", - "cleared": "Cleared", - "not_fitted": "Not fitted yet", - "need_two": "At least 2 points required", - "fit": "V = {slope} × pulses (R² = {r2}) | Points: {n}", - "need_two_save": "At least 2 points required before saving", - "saved": "Saved: {path}", - "loaded": "Loaded {n} calibration points", - "no_data": "No pump calibration data", - "load_failed": "Failed to read calibration file", - "pump_curve": "Pump {id} Calibration Curve", - "th_idx": "#", - "th_pulses": "Pulses", - "th_volume": "Volume (mL)", - "electrode": "Electrode:", - "unit": "Unit:", - "notes": "Notes:", - "current_potential": "Current Potential:", - "std_value": "Standard Value:", - "confirm": "Confirm & Record", - "th_std": "Standard Value", - "th_mv": "Potential (mV)", - "not_calibrated": "Not calibrated", - "electrode_curve": "Electrode Calibration Curve", - "add_title": "Add Electrode", - "add_name": "Electrode name:", - "electrode_fit": "{unit} = {intercept} + ({slope}) × E(mV) (R² = {r2})", - "saved_electrode": "Saved — {name}: {unit} = {intercept} + ({slope}) × E (R² = {r2})", - "matrix_title": "Spectral Reconstruction Matrix ({wl}λ × {ch}ch)", - "matrix_not_loaded": "Calibration data not loaded", - "matrix_info": "Each column corresponds to one AS7341 channel (F1–F8, Clear, NIR); each row corresponds to a wavelength (380–1100 nm).\nMatrix size: {rows}λ × {cols}ch, value range: [{vmin}, {vmax}]", - "matrix_channel": "Channel", - "flow": "Workflow", - "pump_s1": "Jog pump", - "pump_s2": "Measure & record volume", - "pump_s3": "Save calibration", - "ph_s1": "Enter standard value", - "ph_s2": "Record potential", - "ph_s3": "Save calibration" - }, - "maint": { - "title": "Maintenance", - "subtitle": "All operations below use FreeRun mode (runs continuously until manually stopped).\nAfter starting, watch the tubing and click \"Stop\" when finished.", - "select_pump": "Select Pump:", - "running": "{pumps} running… watch the tubing and click Stop when done", - "stopped": "Stopped", - "offline": "Not connected — controls disabled", - "empty_title": "Empty Tubing", - "empty_info": "Place one end of the tubing into the waste beaker, then start the pump to expel residual liquid.\nWatch until the tubing is empty, then click \"Stop\".", - "fill_title": "Fill Tubing (Prime Before Titration)", - "fill_info": "Sample pump: place the inlet in the analyte solution to purge air and water.\nTitrant pump: place the inlet in the titrant to purge air and water.\n\nVerify tubing connections before starting to avoid cross-contamination.\nOnce the liquid flows steadily with no bubbles, click \"Stop\".", - "wash_title": "Wash Tubing (Deionized Water)", - "wash_info": "Place the tubing inlet in deionized water and the outlet in the waste beaker.\nRun the pump to rinse the tubing — at least 30 seconds is recommended.\nOnce the outflow runs clear, click \"Stop\".\n\nIf you plan to run a titration immediately afterwards, use \"Fill Tubing\" to displace residual water." - } -} diff --git a/TController/src/gui/locales/zh_CN.json b/TController/src/gui/locales/zh_CN.json deleted file mode 100644 index 09bab03..0000000 --- a/TController/src/gui/locales/zh_CN.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "app": { - "title": "多模态自动滴定控制器" - }, - "common": { - "start": "启动", - "stop": "停止", - "save": "保存", - "record": "记录", - "undo_last": "撤销最后", - "undo": "撤销", - "clear": "清空", - "ready": "就绪" - }, - "pump": { - "inject": "进样泵", - "titrate": "滴定泵" - }, - "toolbar": { - "port": "端口:", - "baud": "波特率:", - "more": "更多", - "connect": "连接", - "disconnect": "断开", - "sample_volume": "进样体积 (mL):", - "start": "开始滴定", - "stop": "停止滴定", - "emergency_stop": "急停", - "reset_mcu": "复位MCU", - "record": "记录数据", - "language": "语言:", - "theme": "主题:", - "group_conn": "连接", - "group_run": "滴定控制", - "group_dev": "设备", - "group_view": "显示" - }, - "phases": { - "ready": "就绪", - "injecting": "进样", - "titrating": "滴定", - "detecting": "终点检测", - "done": "完成" - }, - "gauge": { - "title": "滴定度", - "t1": "T=1" - }, - "overlay": { - "disconnected": "设备未连接 — 连接后显示实时数据", - "waiting": "等待数据…" - }, - "msg": { - "connected": "已连接 {port}", - "disconnected": "设备已断开" - }, - "confirm": { - "reset_title": "复位 MCU", - "reset_msg": "复位 MCU 将停止所有运行中的泵,并中止当前滴定。继续吗?", - "del_elec_title": "删除电极", - "del_elec_msg": "删除电极「{name}」?其校准数据将一并移除。" - }, - "tooltip": { - "start": "开始滴定 (F5)", - "estop": "紧急停止 (Esc)" - }, - "theme": { - "light": "浅色", - "dark": "深色", - "system": "跟随系统" - }, - "tabs": { - "titration": "滴定", - "calibration": "校准", - "maintenance": "维护" - }, - "states": { - "idle": "空闲", - "injecting": "进样中…", - "titrating": "滴定中…", - "degree1": "终点 T=1", - "titrating2": "滴定至 T=2…", - "done": "完成", - "error": "错误" - }, - "conn": { - "connected": "已连接", - "disconnected": "未连接", - "hint": "提示", - "select_port": "请选择串口端口", - "connect_first": "请先连接串口" - }, - "status": { - "error_fmt": "错误: {msg}", - "emergency_stopped": "已紧急停止,MCU 复位", - "reset_done": "已复位(MCU + 上位机)", - "manual_stopped": "手动停止", - "manual_stopped_at": "手动停止 @ {vol} mL", - "endpoint_t1": "终点 T=1 @ {vol} mL", - "endpoint_t1_conflict": "终点 T=1 @ {vol} mL(仅电位支撑,光谱未过一致性门控)", - "endpoint_final": "终点: {vol} mL", - "progress": "T=1 @ {ep} mL | T={t} vol={vol} mL", - "detect": "检测: {vol} mL, 置信度={conf}", - "candidate": "候选终点: {vol} mL,等待电位/KF 共识", - "injecting": "进样 {vol} mL …", - "pump_done": "泵{id}完成 pos={pos}", - "pump2_vol": "泵2 vol={vol} mL +{diff}", - "exported": "数据已导出: {file}", - "recording_on": "数据记录已开始", - "recording_off": "数据记录已停止", - "nak": "NAK" - }, - "results": { - "title": "滴定计算", - "electrode": "电极", - "raw_potential": "原始电位 (V)", - "stoich": "化学计量数", - "n_std": "标准液 (n₁):", - "n_analyte": "待测液 (n₂):", - "conc": "标准液浓度", - "c_std": "C₁ (mol/L):", - "sample": "待测液", - "sample_volume": "取样体积 (mL):", - "current_voltage": "当前电压:", - "inject": "进样进度", - "inject_done": "进样完成", - "waiting": "等待开始", - "eta": "ETA {eta}", - "result": "结果", - "endpoint": "终点体积", - "endpoint_unit": "mL", - "cx": "Cₓ 浓度", - "cx_unit": "mol/L", - "diagnostics": "在线可靠性", - "diag_status": "状态", - "diag_quality": "数据质量", - "diag_consistency": "模态差异", - "diag_nis": "NIS", - "diag_std": "终点标准差", - "diag_delay": "光谱滞后" - }, - "plot": { - "spectrum": "实时光谱", - "wavelength": "Wavelength (nm)", - "intensity": "Intensity (a.u.)", - "potential": "电位–体积", - "px": "{unit}–体积", - "time": "Time (s)", - "potential_v": "Potential (V)", - "volume": "Volume (mL)", - "degree": "Titration degree (T)", - "alpha": "平滑 α:" - }, - "calib": { - "tab_pump1": "进样泵", - "tab_pump2": "滴定泵", - "tab_electrode": "电极校准", - "tab_matrix": "光谱矩阵", - "jog": "点动 10000 脉冲", - "volume": "体积 (mL):", - "not_connected": "串口未连接", - "pump_running": "泵运行中…", - "jog_done": "点动完成,当前位置: {pos} 脉冲,请输入体积", - "invalid_volume": "体积输入无效", - "volume_positive": "体积必须大于 0", - "recorded": "记录 ({n}): {pulses} 脉冲 → {vol} mL", - "undone": "撤销,剩余 {n} 点", - "cleared": "已清空", - "not_fitted": "尚未拟合", - "need_two": "至少需要 2 个点", - "fit": "V = {slope} × 脉冲 (R² = {r2}) | 点数: {n}", - "need_two_save": "至少需要 2 个点才能保存", - "saved": "已保存: {path}", - "loaded": "已加载 {n} 个校准点", - "no_data": "暂无泵校准数据", - "load_failed": "校准文件读取失败", - "pump_curve": "泵 {id} 校准曲线", - "th_idx": "#", - "th_pulses": "脉冲数", - "th_volume": "体积 (mL)", - "electrode": "电极:", - "unit": "单位:", - "notes": "备注:", - "current_potential": "当前电位:", - "std_value": "标准值:", - "confirm": "确认记录", - "th_std": "标准值", - "th_mv": "电位 (mV)", - "not_calibrated": "尚未校准", - "electrode_curve": "电极校准曲线", - "add_title": "添加电极", - "add_name": "电极名称:", - "electrode_fit": "{unit} = {intercept} + ({slope}) × E(mV) (R² = {r2})", - "saved_electrode": "已保存 — {name}: {unit} = {intercept} + ({slope}) × E (R² = {r2})", - "matrix_title": "光谱重建矩阵 ({wl}λ × {ch}ch)", - "matrix_not_loaded": "校准数据未加载", - "matrix_info": "每列对应一个 AS7341 通道 (F1–F8, Clear, NIR),每行对应一个波长 (380–1100 nm)。\n矩阵尺寸: {rows}λ × {cols}ch, 值范围: [{vmin}, {vmax}]", - "matrix_channel": "Channel", - "flow": "流程", - "pump_s1": "点动泵", - "pump_s2": "量取并记录体积", - "pump_s3": "保存校准", - "ph_s1": "设置标准值", - "ph_s2": "确认记录电位", - "ph_s3": "保存校准" - }, - "maint": { - "title": "维护操作", - "subtitle": "以下操作均使用 FreeRun 模式(持续运行直到手动停止)。\n启动后请肉眼观察管路状态,确认完成后点击「停止」。", - "select_pump": "选择泵:", - "running": "{pumps} 运行中… 请观察,完成后点击停止", - "stopped": "已停止", - "offline": "串口未连接,操作已禁用", - "empty_title": "排空管路", - "empty_info": "将管路一端放入废液杯,启动泵排空管内残留液体。\n肉眼观察管内液体排空后,点击「停止」。", - "fill_title": "充满管路(滴定前排气)", - "fill_info": "进样泵:将入口放入待测液中,排空管内空气和水。\n滴定泵:将入口放入滴定液中,排空管内空气和水。\n\n启动前确认管路连接正确,避免液体交叉污染。\n待液体连续流出、管内无气泡后,点击「停止」。", - "wash_title": "清洗管路(去离子水)", - "wash_info": "将管路入口端放入去离子水中,出口端放入废液杯。\n启动泵冲洗管路内部,建议冲洗 30 秒以上。\n肉眼观察出水干净后,点击「停止」。\n\n清洗后如需立即使用,请用「充满管路」排出残留水份。" - } -} diff --git a/TController/src/gui/main_window.py b/TController/src/gui/main_window.py deleted file mode 100644 index ba233a2..0000000 --- a/TController/src/gui/main_window.py +++ /dev/null @@ -1,1206 +0,0 @@ -"""滴定控制主窗口(ttkbootstrap)— 控制台式命令栏 + 工作流相位 + i18n。""" - -from __future__ import annotations - -import os -import time -import tkinter as tk -from datetime import datetime -from enum import Enum -from tkinter import messagebox -from typing import Literal - -import numpy as np -import openpyxl -import ttkbootstrap as ttk -from Communication import ProtocolHandler -from DataProcessor import EndpointDetector, steps_from_volume, volume_from_steps -from DataProcessor import reconstruct as _reconstruct -from DataProcessor._path import CALIBRE_PATH -from ttkbootstrap.dialogs import Messagebox - -from gui import i18n, themes -from gui.calibration_tab import CalibrationTab -from gui.maintenance_tab import MaintenanceTab -from gui.potential_widget import PotentialWidget -from gui.results_panel import ResultsPanel -from gui.settings import load_settings, save_settings -from gui.spectrum_widget import SpectrumWidget -from gui.themes import UI_FONT -from gui.widgets import Card, MessageBar, PhaseStepper, StatusDot, TGauge, Tooltip - - -class TitrationState(Enum): - IDLE = "idle" - INJECTING = "injecting" - TITRATING = "titrating" - DEGREE_1 = "degree1" - TITRATING_2 = "titrating2" - DONE = "done" - ERROR = "error" - - -# 状态 → 芯片样式 -_CHIP_STYLE: dict[TitrationState, str] = { - TitrationState.IDLE: "ChipIdle", - TitrationState.INJECTING: "ChipRun", - TitrationState.TITRATING: "ChipRun", - TitrationState.TITRATING_2: "ChipRun", - TitrationState.DEGREE_1: "ChipWarn", - TitrationState.DONE: "ChipOk", - TitrationState.ERROR: "ChipErr", -} - -# 状态 → 相位步进索引(ERROR 保持当前相位,仅标红) -_PHASE_INDEX: dict[TitrationState, int] = { - TitrationState.IDLE: 0, - TitrationState.INJECTING: 1, - TitrationState.TITRATING: 2, - TitrationState.DEGREE_1: 3, - TitrationState.TITRATING_2: 3, - TitrationState.DONE: 4, - TitrationState.ERROR: 0, -} - -_PHASES = [ - ("ready", "phases.ready"), - ("injecting", "phases.injecting"), - ("titrating", "phases.titrating"), - ("detecting", "phases.detecting"), - ("done", "phases.done"), -] - - -# ====================================================================== - - -class MainWindow(ttk.Frame): - """滴定控制主窗口。 - - 工作流: - 空闲 → [开始] → 进样泵 MaxCount → 滴定泵 FreeRun - → 终点检测 T=1 → 继续 FreeRun → T=2 → 停止 - """ - - def __init__( - self, - parent: tk.Misc, - theme_mode: str = "system", - **kwargs, - ) -> None: - super().__init__(parent, **kwargs) - - # ---- 串口通信 ---- - self._com = ProtocolHandler() - self._com.on("connected", self._on_connected) - self._com.on("disconnected", self._on_disconnected) - self._com.on("error", self._on_com_error) - self._com.on("spectral", self._on_spectral_data) - self._com.on("adc", self._on_adc_data) - self._com.on("pump_done", self._on_pump_done) - self._com.on("pump1_progress", self._on_pump1_progress) - self._com.on("pump2_progress", self._on_pump2_progress) - self._com.on("nak", self._on_nak) - - self._pump1_volume: float = 0.0 # 泵 1 体积(mL) - self._pump2_volume: float = 0.0 # 泵 2 体积(mL) - - # ---- 滴定检测 ---- - self._detector = EndpointDetector() - self._state = TitrationState.IDLE - self._phase_idx = 0 - self._endpoint_volume: float | None = None - self._t0: float = 0.0 # 连接时刻 - self._t1_time: float = 0.0 - self._recon_wls: np.ndarray | None = None - self._adc_buffer: list[int] = [] - self._adc_counter = 0 - - # ---- 数据记录 ---- - saved_rec = load_settings().get("record", True) - self._recording = saved_rec - self._rec_spectral: list[tuple[float, list[int]]] = [] - self._rec_recon: list[tuple[float, np.ndarray]] = [] - self._rec_recon_wls: np.ndarray | None = None - self._rec_potential: list[tuple[float, float, float, float]] = [] - self._rec_raw_adc: list[tuple[float, int, float]] = [] - self._rec_features: list[dict] = [] - self._rec_ewma_v: float | None = None - - # ---- UI ---- - self._theme_mode = theme_mode if theme_mode in themes.MODES else "system" - self._connected = False - self._rec_var = tk.BooleanVar(value=saved_rec) - self._group_captions: list[tuple[ttk.Label, str]] = [] - self._build_toolbar() - # 状态栏先于中央区打包(side=bottom),确保窗口高度不足时不被挤压 - self._build_status() - self._build_central() - self._load_electrodes() - self._bind_shortcuts() - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - # 初始空状态提示 - self._spectrum_widget.set_overlay("overlay.disconnected") - self._potential_widget.set_overlay("overlay.disconnected") - - # ---- 定时器(root.after 递归调度)---- - self._running = True - self._heartbeat_active = False - # 100ms 刷新间隔(10fps):电位曲线足够流畅,CPU 占用比 80ms 低 20% - self._schedule_after(100, self._refresh_plots) - self._schedule_after(2000, self._scan_ports) - self._scan_ports() - self._schedule_after(500, self._run_detection) - # 心跳定时器由连接/断开控制 - - def _schedule_after(self, ms: int, callback) -> None: - """安全的 after 调度:窗口关闭后不再触发。""" - - def _wrapper(): - if not self._running: - return - callback() - self._schedule_after(ms, callback) - - self.after(ms, _wrapper) - - def _schedule_heartbeat(self) -> None: - def _heartbeat(): - if not self._running or not self._heartbeat_active: - return - self._send_heartbeat() - self.after(1000, _heartbeat) - - if self._heartbeat_active: - self.after(1000, _heartbeat) - - # ================================================================ - # UI 构建 - # ================================================================ - - def _group( - self, - parent: tk.Misc, - caption_key: str, - side: Literal["left", "right", "top", "bottom"] = "left", - padx: tuple[int, int] = (0, 8), - ) -> ttk.Frame: - """带标题的工具栏分组卡片(已入包),返回控件行容器。""" - card = Card(parent, tone="surface") - card.pack(side=side, padx=padx) - body = ttk.Frame(card, style="Toolbar.TFrame", padding=(10, 4)) - body.pack(fill="both", expand=True) - cap = ttk.Label(body, text=i18n.tr(caption_key), style="GroupCaption.TLabel") - cap.pack(anchor="w") - row = ttk.Frame(body, style="Toolbar.TFrame") - row.pack(anchor="w", pady=(2, 3)) - self._group_captions.append((cap, caption_key)) - return row - - def _build_toolbar(self) -> None: - tb = ttk.Frame(self, style="Toolbar.TFrame", padding=(8, 6)) - tb.pack(fill="x") - - # ---- 连接组 ---- - conn_row = self._group(tb, "toolbar.group_conn") - - self._status_dot = StatusDot(conn_row) - self._status_dot.pack(side="left", padx=(0, 4)) - - self._port_label = ttk.Label(conn_row, text=i18n.tr("toolbar.port"), style="GroupLabel.TLabel") - self._port_label.pack(side="left", padx=(0, 4)) - self._port_cb = ttk.Combobox(conn_row, state="readonly", width=16) - self._port_cb.pack(side="left") - self._port_data: dict[str, str] = {} # 显示文本 → 设备路径 - - self._more_btn = ttk.Menubutton( - conn_row, text=i18n.tr("toolbar.more"), width=5, bootstyle="outline" - ) - self._more_menu = tk.Menu(self._more_btn, tearoff=0) - self._more_btn["menu"] = self._more_menu - self._more_btn.pack(side="left", padx=4) - - self._baud_label = ttk.Label(conn_row, text=i18n.tr("toolbar.baud"), style="GroupLabel.TLabel") - self._baud_label.pack(side="left", padx=(8, 4)) - self._baud_cb = ttk.Combobox( - conn_row, - values=["115200"], - state="readonly", - width=6, - ) - self._baud_cb.set(str(load_settings()["baud"])) - self._baud_cb.pack(side="left") - - self._conn_btn = ttk.Button( - conn_row, - text=i18n.tr("toolbar.connect"), - bootstyle="primary", - command=self._toggle_connect, - ) - self._conn_btn.pack(side="left", padx=(8, 0)) - - # ---- 滴定控制组 ---- - run_row = self._group(tb, "toolbar.group_run") - - self._vol_label = ttk.Label( - run_row, text=i18n.tr("toolbar.sample_volume"), style="GroupLabel.TLabel" - ) - self._vol_label.pack(side="left", padx=(0, 4)) - self._vol_spin = ttk.Spinbox( - run_row, from_=1.0, to=999.0, increment=0.1, format="%.1f", width=5 - ) - self._vol_spin.set(5.0) - self._vol_spin.pack(side="left") - - self._start_btn = ttk.Button( - run_row, - text=i18n.tr("toolbar.start"), - bootstyle="success", - padding=(10, 4), - command=self._start_titration, - ) - self._start_btn.pack(side="left", padx=(10, 4)) - self._start_btn.state(["disabled"]) - Tooltip(self._start_btn, lambda: i18n.tr("tooltip.start")) - - self._manual_stop_btn = ttk.Button( - run_row, - text=i18n.tr("toolbar.stop"), - bootstyle="outline", - command=self._manual_stop, - ) - self._manual_stop_btn.pack(side="left", padx=(0, 6)) - self._manual_stop_btn.state(["disabled"]) - - # ---- 设备组 ---- - dev_row = self._group(tb, "toolbar.group_dev", padx=(0, 0)) - - self._reset_btn = ttk.Button( - dev_row, - text=i18n.tr("toolbar.reset_mcu"), - bootstyle="outline", - command=self._reset_mcu, - ) - self._reset_btn.pack(side="left") - - # ---- 急停区(右端独立危险区)---- - estop_zone = Card(tb, tone="danger") - estop_zone.pack(side="right", padx=(8, 0)) - estop_body = ttk.Frame(estop_zone, style="EstopZoneBody.TFrame", padding=(12, 4)) - estop_body.pack(fill="both", expand=True) - self._estop_cap = ttk.Label( - estop_body, text=i18n.tr("toolbar.emergency_stop"), style="EstopZone.TLabel" - ) - self._estop_cap.pack(anchor="w") - self._stop_btn = ttk.Button( - estop_body, - text=i18n.tr("toolbar.emergency_stop"), - bootstyle="danger", - padding=(14, 6), - command=self._emergency_stop, - ) - self._stop_btn.pack(pady=(1, 3)) - self._stop_btn.state(["disabled"]) - Tooltip(self._stop_btn, lambda: i18n.tr("tooltip.estop")) - - # ---- 显示组(语言 / 主题)---- - view_row = self._group(tb, "toolbar.group_view", side="right", padx=(8, 0)) - - self._lang_cb = ttk.Combobox(view_row, state="readonly", width=8) - self._lang_cb.pack(side="left", padx=(0, 4)) - self._rebuild_lang_combo() - self._lang_cb.bind("<>", self._on_lang_changed) - - self._theme_cb = ttk.Combobox(view_row, state="readonly", width=8) - self._theme_cb.pack(side="left") - self._rebuild_theme_combo() - self._theme_cb.bind("<>", self._on_theme_changed) - - def _rebuild_theme_combo(self) -> None: - """按当前语言重建主题下拉框(保持选中项)。""" - keys = {"theme.light": "light", "theme.dark": "dark", "theme.system": "system"} - self._theme_display_to_mode = {i18n.tr(k): m for k, m in keys.items()} - self._theme_cb["values"] = list(self._theme_display_to_mode.keys()) - for disp, mode in self._theme_display_to_mode.items(): - if mode == self._theme_mode: - self._theme_cb.set(disp) - break - - def _rebuild_lang_combo(self) -> None: - """重建语言下拉框(显示各语言自称)。""" - self._lang_display_to_code = {name: code for code, name in i18n.LANGS.items()} - self._lang_cb["values"] = list(self._lang_display_to_code.keys()) - self._lang_cb.set(i18n.LANGS[i18n.current_language()]) - - def _build_central(self) -> None: - self._main_tabs = ttk.Notebook(self) - self._main_tabs.pack(fill="both", expand=True, padx=4, pady=(2, 0)) - - # ---- 滴定标签页 ---- - titrate_tab = ttk.Frame(self._main_tabs) - - # 工作流相位 + 滴定度规 - top = ttk.Frame(titrate_tab) - top.pack(fill="x", padx=8) - self._stepper = PhaseStepper(top, _PHASES) - self._stepper.pack(side="left", fill="x", expand=True) - ttk.Separator(top, orient="vertical").pack(side="left", fill="y", padx=10, pady=8) - self._tgauge = TGauge(top, width=340) - self._tgauge.pack(side="right", padx=(6, 4)) - - paned = tk.PanedWindow( - titrate_tab, orient="horizontal", sashwidth=4 - ) - paned.pack(fill="both", expand=True) - - left = ttk.Frame(paned) - left.pack(fill="both", expand=True) - - self._spectrum_widget = SpectrumWidget(left) - self._spectrum_widget.pack(fill="both", expand=True, pady=(0, 2)) - - self._potential_widget = PotentialWidget(left) - self._potential_widget.pack(fill="both", expand=True) - - paned.add(left, minsize=400, stretch="always") - - self._results_panel = ResultsPanel( - paned, - record_var=self._rec_var, - record_command=self._on_recording_toggled, - ) - paned.add(self._results_panel, minsize=260, stretch="never") - - self._main_tabs.add(titrate_tab, text=i18n.tr("tabs.titration")) - - # ---- 校准标签页 ---- - self._calib_tab = CalibrationTab(self._com, parent=self._main_tabs) - self._main_tabs.add(self._calib_tab, text=i18n.tr("tabs.calibration")) - - self._maintenance_tab = MaintenanceTab( - self._com, parent=self._main_tabs - ) - self._main_tabs.add(self._maintenance_tab, text=i18n.tr("tabs.maintenance")) - self._maintenance_tab.set_connected(False) - - def _build_status(self) -> None: - sb = ttk.Frame(self, style="Statusbar.TFrame", padding=(10, 5)) - sb.pack(fill="x", side="bottom") - - self._chip = ttk.Label(sb, style="ChipIdle.TLabel", text=i18n.tr("states.idle")) - self._chip.pack(side="left") - - self._msg = MessageBar(sb) - self._msg.pack(side="left", fill="x", expand=True, padx=(12, 0)) - - self._activity = ttk.Label(sb, text="", style="Status.TLabel", font=(UI_FONT, 8)) - self._activity.pack(side="right", padx=(12, 10)) - - self._conn_dot = StatusDot(sb) - self._conn_dot.pack(side="right", padx=(0, 4)) - self._conn_label = ttk.Label(sb, text="", style="Conn.TLabel") - self._conn_label.pack(side="right") - self._update_conn_label() - - def _bind_shortcuts(self) -> None: - """全局快捷键:Esc 急停,F5 开始滴定。""" - top = self.winfo_toplevel() - top.bind("", lambda _e: self._shortcut_estop()) - top.bind("", lambda _e: self._shortcut_start()) - - def _shortcut_estop(self) -> None: - if self._connected: - self._emergency_stop() - - def _shortcut_start(self) -> None: - if "disabled" not in self._start_btn.state(): - self._start_titration() - - # ---- i18n / 主题刷新 ---- - - def _apply_i18n(self) -> None: - self.winfo_toplevel().title(i18n.tr("app.title")) - for cap, key in self._group_captions: - cap.config(text=i18n.tr(key)) - self._port_label.config(text=i18n.tr("toolbar.port")) - self._baud_label.config(text=i18n.tr("toolbar.baud")) - self._more_btn.config(text=i18n.tr("toolbar.more")) - self._conn_btn.config( - text=i18n.tr("toolbar.disconnect" if self._connected else "toolbar.connect") - ) - self._vol_label.config(text=i18n.tr("toolbar.sample_volume")) - self._start_btn.config(text=i18n.tr("toolbar.start")) - self._manual_stop_btn.config(text=i18n.tr("toolbar.stop")) - self._stop_btn.config(text=i18n.tr("toolbar.emergency_stop")) - self._estop_cap.config(text=i18n.tr("toolbar.emergency_stop")) - self._reset_btn.config(text=i18n.tr("toolbar.reset_mcu")) - self._main_tabs.tab(0, text=i18n.tr("tabs.titration")) - self._main_tabs.tab(1, text=i18n.tr("tabs.calibration")) - self._main_tabs.tab(2, text=i18n.tr("tabs.maintenance")) - # 状态芯片与连接标签(状态枚举名来自 i18n) - self._set_chip(i18n.tr(f"states.{self._state.value}"), _CHIP_STYLE[self._state]) - self._update_conn_label() - # 下拉框选项随语言重建 - self._rebuild_theme_combo() - self._rebuild_lang_combo() - - def _apply_theme(self) -> None: - self._update_conn_label() - themes.set_native_titlebar(themes.current_key() == "dark", self.winfo_toplevel()) - - def _set_chip(self, text: str, style: str) -> None: - self._chip.config(text=text, style=f"{style}.TLabel") - - def _update_conn_label(self) -> None: - t = themes.current_tokens() - if self._connected: - disp = self._port_cb.get() - port = self._port_data.get(disp, disp) - self._conn_label.config( - text=port if port else i18n.tr("conn.connected"), foreground=t.fg - ) - self._conn_dot.set_state("ok") - self._status_dot.set_state("ok") - else: - self._conn_label.config(text=i18n.tr("conn.disconnected"), foreground=t.fg_muted) - self._conn_dot.set_state("off") - self._status_dot.set_state("off") - - # ================================================================ - # 串口连接 - # ================================================================ - - def _scan_ports(self) -> None: - import serial.tools.list_ports - - current = self._port_cb.get() - self._port_data.clear() - values = [] - for p in serial.tools.list_ports.comports(): - if p.vid is not None: - desc = p.description or "" - label = f"{p.device} ({desc})" if desc else p.device - values.append(label) - self._port_data[label] = p.device - - self._port_cb["values"] = values - if values: - if current in values: - self._port_cb.set(current) - else: - # 优先恢复上次连接的端口 - last = load_settings()["last_port"] - restored = next( - (lbl for lbl, dev in self._port_data.items() if dev == last), None - ) - self._port_cb.set(restored or values[0]) - - # 无 vid 的端口放入"更多"菜单 - self._more_menu.delete(0, "end") - bare = [p.device for p in serial.tools.list_ports.comports() if p.vid is None] - if bare: - self._more_btn.state(["!disabled"]) - for dev in bare: - self._more_menu.add_command( - label=dev, - command=lambda d=dev: self._on_bare_port(d), - ) - else: - self._more_btn.state(["disabled"]) - - def _on_bare_port(self, dev: str) -> None: - label = dev - self._port_data[label] = dev - values = list(self._port_cb["values"]) - if label not in values: - values.append(label) - self._port_cb["values"] = values - self._port_cb.set(label) - - def _toggle_connect(self) -> None: - if self._com.is_open: - self._com.send_abort(0xFF) - self._com.disconnect() - else: - disp = self._port_cb.get() - port = self._port_data.get(disp, disp) - if not port: - messagebox.showwarning(i18n.tr("conn.hint"), i18n.tr("conn.select_port")) - return - self._com.reconfigure(port, int(self._baud_cb.get())) - self._com.connect() - - def _on_connected(self, _data: object = None) -> None: - self._connected = True - self._conn_btn.config( - text=i18n.tr("toolbar.disconnect"), bootstyle="outline" - ) - self._t0 = time.monotonic() - self._update_conn_label() - self._start_btn.state(["!disabled"]) - self._stop_btn.state(["!disabled"]) # 急停:连接后始终可用 - self._com.enable_watchdog() - self._heartbeat_active = True - self._schedule_heartbeat() - self._maintenance_tab.set_connected(True) - # 记住端口,恢复空状态 - disp = self._port_cb.get() - port = self._port_data.get(disp, disp) - save_settings(last_port=port, baud=int(self._baud_cb.get())) - self._spectrum_widget.set_overlay("overlay.waiting") - self._potential_widget.set_overlay("overlay.waiting") - self._msg.show("success", i18n.tr("msg.connected", port=port), sticky=True) - - def _on_disconnected(self, _data: object = None) -> None: - self._connected = False - self._conn_btn.config(text=i18n.tr("toolbar.connect"), bootstyle="primary") - self._update_conn_label() - self._start_btn.state(["disabled"]) - self._stop_btn.state(["disabled"]) - self._set_state(TitrationState.IDLE) - self._heartbeat_active = False - self._maintenance_tab.set_connected(False) - self._spectrum_widget.set_overlay("overlay.disconnected") - self._potential_widget.set_overlay("overlay.disconnected") - self._tgauge.set_value(None) - self._msg.show("warn", i18n.tr("msg.disconnected"), sticky=True) - - def _on_com_error(self, msg: str) -> None: - self._msg.show("error", i18n.tr("status.error_fmt", msg=msg), sticky=True) - self._set_state(TitrationState.ERROR) - - def _on_nak(self, _data: object = None) -> None: - self._activity.config(text=i18n.tr("status.nak")) - - def _send_heartbeat(self) -> None: - if self._com.is_open: - self._com.send_heartbeat() - - # ================================================================ - # 数据回调 - # ================================================================ - - def _on_spectral_data(self, vals: list[int]) -> None: - t = time.monotonic() - self._t0 - # 始终重建并显示 - if self._recon_wls is None: - try: - self._recon_wls, _ = _reconstruct(vals) - except Exception: - pass - if self._recon_wls is not None: - try: - _, spec = _reconstruct(vals) - self._spectrum_widget.update_spectrum(self._recon_wls, spec) - if self._recording: - self._rec_spectral.append((t, list(vals))) - if self._rec_recon_wls is None: - self._rec_recon_wls = self._recon_wls.copy() - self._rec_recon.append((t, spec.copy())) - except Exception: - pass - # 仅在滴定期间馈入检测器 - if self._state in ( - TitrationState.TITRATING, - TitrationState.TITRATING_2, - TitrationState.DEGREE_1, - ): - detector_spectrum = np.array(vals, dtype=np.float64) - if self._recon_wls is not None: - try: - _, detector_spectrum = _reconstruct(vals) - self._detector.set_spectrum_axis(self._recon_wls) - except (ValueError, FileNotFoundError): - detector_spectrum = np.array(vals, dtype=np.float64) - self._detector.feed_spectrum( - self._pump2_volume, detector_spectrum, t=t - ) - - def _on_adc_data(self, data: tuple) -> None: - raw, pump2_pos = data - t = time.monotonic() - self._t0 - v = raw * 3.3 / 65535 - 1.1 - # 更新 Pump2 体积(基于固件实际步数) - old_vol = self._pump2_volume - self._pump2_volume = volume_from_steps(pump2_pos) - self._update_gauge() - # 泵体积变化时更新活动区 - if self._state in ( - TitrationState.TITRATING, - TitrationState.TITRATING_2, - TitrationState.DEGREE_1, - ): - diff = self._pump2_volume - old_vol - if diff > 0.001: - self._activity.config( - text=i18n.tr( - "status.pump2_vol", - vol=f"{self._pump2_volume:.4f}", - diff=f"{diff:.4f}", - ) - ) - # 缓存用于泵 report 间平均 - self._adc_buffer.append(raw) - self._results_panel.set_current_voltage(v) - # 记录原始 ADC 数据 - if self._recording: - self._rec_raw_adc.append((t, raw, self._pump2_volume)) - # 每次均馈入检测器(保持检测精度) - if self._state in ( - TitrationState.TITRATING, - TitrationState.TITRATING_2, - TitrationState.DEGREE_1, - ): - vol = self._pump2_volume - self._detector.feed_potential(vol, t, v) - self._adc_counter += 1 - - def _on_pump1_progress(self, pos: int) -> None: - self._pump1_volume = volume_from_steps(pos) - self._results_panel.update_inject_progress(pos, volume=self._pump1_volume) - self._flush_adc_buffer() - - def _on_pump2_progress(self, pos: int) -> None: - self._pump2_volume = volume_from_steps(pos) - self._update_gauge() - self._flush_adc_buffer() - - def _flush_adc_buffer(self) -> None: - """将缓存内 ADC 读数平均后更新到电位曲线。""" - if not self._adc_buffer: - return - t = time.monotonic() - self._t0 - raw_avg = round(sum(self._adc_buffer) / len(self._adc_buffer)) - v = raw_avg * 3.3 / 65535 - 1.1 - vol = ( - self._pump1_volume - if self._state == TitrationState.INJECTING - else self._pump2_volume - ) - self._potential_widget.append(t, raw_avg, volume=vol) - # 记录电位数据点(含 EWMA 平滑) - if self._recording: - if self._rec_ewma_v is None: - self._rec_ewma_v = v - else: - self._rec_ewma_v = 0.15 * v + 0.85 * self._rec_ewma_v - self._rec_potential.append((t, v, self._rec_ewma_v, vol)) - self._adc_buffer.clear() - - def _on_pump_done(self, data: tuple) -> None: - pump_id, position = data - self._activity.config(text=i18n.tr("status.pump_done", id=pump_id, pos=position)) - if pump_id == 1 and self._state == TitrationState.INJECTING: - # 进样完成 -> 隐藏进度条,清空进样数据,启动滴定泵 - self._results_panel.hide_inject_progress() - self._potential_widget.reset() - self._potential_widget.set_titrating(True) - self._set_state(TitrationState.TITRATING) - self._com.send_frerun(2) - - # ================================================================ - # 滴定控制 - # ================================================================ - - def _update_gauge(self) -> None: - if self._endpoint_volume and self._state in ( - TitrationState.TITRATING, - TitrationState.DEGREE_1, - TitrationState.TITRATING_2, - TitrationState.DONE, - ): - self._tgauge.set_value(self._pump2_volume / self._endpoint_volume) - else: - self._tgauge.set_value(None) - - def _set_state(self, state: TitrationState) -> None: - self._state = state - self._set_chip(i18n.tr(f"states.{state.value}"), _CHIP_STYLE[state]) - if state is TitrationState.ERROR: - self._stepper.set_phase(self._phase_idx, error=True) - else: - self._phase_idx = _PHASE_INDEX[state] - self._stepper.set_phase( - self._phase_idx, done_all=state is TitrationState.DONE - ) - - def _start_titration(self) -> None: - if not self._com.is_open: - messagebox.showwarning(i18n.tr("conn.hint"), i18n.tr("conn.connect_first")) - return - - # 复位检测器与所有数据 - self._detector.reset() - self._endpoint_volume = None - self._t1_time = 0.0 - self._potential_widget.set_titrating(False) - self._potential_widget.reset() - self._results_panel.reset_endpoint() - self._tgauge.set_value(None) - self._adc_counter = 0 - # AMPD 在 T=2 统一执行,无需中间标记 - self._pump1_volume = 0.0 - self._pump2_volume = 0.0 - # 清空记录缓冲区,防止上次滴定数据混入 - if self._recording: - self._rec_spectral.clear() - self._rec_recon.clear() - self._rec_recon_wls = None - self._rec_potential.clear() - self._rec_raw_adc.clear() - self._rec_features.clear() - self._rec_ewma_v = None - try: - vol_ml = float(self._vol_spin.get()) - except (ValueError, tk.TclError): - vol_ml = 5.0 - self._msg.show("info", i18n.tr("status.injecting", vol=f"{vol_ml:.1f}"), sticky=True) - self._results_panel.set_sample_volume(vol_ml) - # 进样体积 → MaxCount 步数(标定公式换算) - steps = steps_from_volume(vol_ml) - self._results_panel.show_inject_progress(steps, target_vol=vol_ml) - self._start_btn.state(["disabled"]) - self._com.send_maxcount(1, steps) - self._set_state(TitrationState.INJECTING) - self._potential_widget.set_titrating(True) - self._manual_stop_btn.state(["!disabled"]) - - def _emergency_stop(self) -> None: - self._com.send_reset() - self._set_state(TitrationState.IDLE) - self._start_btn.state(["!disabled"]) - self._manual_stop_btn.state(["disabled"]) - self._msg.show("warn", i18n.tr("status.emergency_stopped"), sticky=True) - self._potential_widget.set_titrating(False) - self._potential_widget.reset() - self._results_panel.reset_endpoint() - self._tgauge.set_value(None) - - def _manual_stop(self) -> None: - """手动停止滴定:停泵、精修终点、保存数据,不复位 MCU。""" - self._manual_stop_btn.state(["disabled"]) - - # 停止滴定泵 - self._com.send_frestop(2) - - # 如果已检测到 T=1,用 AMPD 精修终点 - if self._endpoint_volume: - ref = self._detector.refine_with_ampd() - if ref is not None: - self._endpoint_volume = ref - self._results_panel.set_endpoint(ref) - self._potential_widget.set_endpoint(ref) - - self._potential_widget.set_titrating(False) - self._set_state(TitrationState.DONE) - self._set_chip( - i18n.tr("status.manual_stopped_at", vol=f"{self._endpoint_volume:.4f}"), - "ChipWarn", - ) - self._msg.show( - "warn", - i18n.tr("status.manual_stopped_at", vol=f"{self._endpoint_volume:.4f}"), - sticky=True, - ) - - # 保存数据 - result = self._detector.detect() - if result is not None: - self._on_titration_complete(result) - else: - self._potential_widget.set_titrating(False) - self._set_state(TitrationState.DONE) - self._set_chip(i18n.tr("status.manual_stopped"), "ChipWarn") - self._msg.show("warn", i18n.tr("status.manual_stopped"), sticky=True) - - self._start_btn.state(["!disabled"]) - - def _reset_mcu(self) -> None: - """复位 MCU 并重置上位机状态(需确认)。""" - answer = Messagebox.yesno( - i18n.tr("confirm.reset_msg"), - i18n.tr("confirm.reset_title"), - parent=self.winfo_toplevel(), - alert=True, - ) - if answer != "Yes": - return - self._com.send_reset() - self._detector.reset() - self._set_state(TitrationState.IDLE) - self._start_btn.state(["!disabled"]) - self._manual_stop_btn.state(["disabled"]) - self._potential_widget.set_titrating(False) - self._potential_widget.reset() - self._results_panel.reset_endpoint() - self._endpoint_volume = None - self._t1_time = 0.0 - self._tgauge.set_value(None) - self._msg.show("info", i18n.tr("status.reset_done"), sticky=True) - - # ================================================================ - # 定时任务 - # ================================================================ - - def _refresh_plots(self) -> None: - # 排空通信事件队列 - self._com.poll() - self._potential_widget.refresh() - - def _run_detection(self) -> None: - if self._state not in ( - TitrationState.TITRATING, - TitrationState.TITRATING_2, - TitrationState.DEGREE_1, - ): - return - - diagnostics = self._detector.diagnostics() - self._results_panel.set_reliability(diagnostics["reliability"]) - if self._recording: - self._record_feature_diagnostics(diagnostics) - - result = self._detector.detect() - if result is None: - return - - vol = result["volume"] - self._activity.config( - text=i18n.tr("status.detect", vol=f"{vol:.3f}", conf=result["confidence"]) - ) - self._results_panel.set_reliability(result.get("reliability", {})) - - if self._state == TitrationState.TITRATING: - # 判据是"报告的体积有电位证据支撑",而不是枚举 method 名字。consensus 已由 - # KF 融合双模态;potential_only 与 conflict 报告的都是电位终点(conflict 即 - # "双模态都确认但未过 NIS 门控,退回电位")。只有 spectral_only 不能控泵—— - # 它没有电极证据。原先按 method 名白名单漏掉了 conflict:两模态持续不一致时 - # T=1 永不触发,滴定死锁而泵无限运行。 - method = result.get("method") - reliability = result.get("reliability", {}) - can_control = method == "consensus" or ( - method in ("potential_only", "conflict") - and reliability.get("potential_evidence", False) - ) - if not can_control: - self._activity.config( - text=i18n.tr("status.candidate", vol=f"{vol:.3f}") - ) - return - # 首次到达终点 T=1 - self._endpoint_volume = vol - self._potential_widget.set_endpoint(vol) - self._results_panel.set_endpoint(vol) - self._set_state(TitrationState.DEGREE_1) - key = ( - "status.endpoint_t1_conflict" - if method == "conflict" - else "status.endpoint_t1" - ) - self._set_chip(i18n.tr(key, vol=f"{vol:.3f}"), "ChipWarn") - self._msg.show("warn", i18n.tr(key, vol=f"{vol:.3f}"), sticky=True) - - elif self._state in (TitrationState.DEGREE_1, TitrationState.TITRATING_2): - # 显示当前滴定进度 - if self._endpoint_volume: - t_val = self._pump2_volume / self._endpoint_volume - self._set_chip( - i18n.tr( - "status.progress", - ep=f"{self._endpoint_volume:.3f}", - t=f"{t_val:.2f}", - vol=f"{self._pump2_volume:.3f}", - ), - "ChipRun", - ) - # 继续滴定到 T=2(用实际累积体积) - if ( - self._endpoint_volume - and self._pump2_volume >= 2.0 * self._endpoint_volume - ): - self._com.send_frestop(2) - # T=2 停泵后用 AMPD 从完整导数曲线精确定位终点 - ref = self._detector.refine_with_ampd() - if ref is not None: - self._endpoint_volume = ref - self._results_panel.set_endpoint(ref) - self._potential_widget.set_endpoint(ref) - self._set_state(TitrationState.DONE) - self._potential_widget.set_titrating(False) - self._set_chip( - i18n.tr("status.endpoint_final", vol=f"{self._endpoint_volume:.4f}"), - "ChipOk", - ) - self._msg.show( - "success", - i18n.tr("status.endpoint_final", vol=f"{self._endpoint_volume:.4f}"), - sticky=True, - ) - self._start_btn.state(["!disabled"]) - self._manual_stop_btn.state(["disabled"]) - self._on_titration_complete(result) - else: - if self._state == TitrationState.DEGREE_1: - self._set_state(TitrationState.TITRATING_2) - - # ================================================================ - # 语言 / 主题切换 - # ================================================================ - - def _on_lang_changed(self, _event: object = None) -> None: - code = self._lang_display_to_code.get(self._lang_cb.get()) - if code and code != i18n.current_language(): - save_settings(language=code) - i18n.set_language(code) - - def _on_theme_changed(self, _event: object = None) -> None: - mode = self._theme_display_to_mode.get(self._theme_cb.get(), "system") - self._theme_mode = mode - save_settings(theme_mode=mode) - themes.apply_theme(mode, plots=self._theme_plots()) - - def _theme_plots(self) -> list: - plots = [ - self._spectrum_widget, - self._potential_widget, - ] - plots.extend(self._calib_tab.plots) - return [p for p in plots if p is not None] - - # ================================================================ - # 数据导出 - # ================================================================ - - def _record_feature_diagnostics(self, diagnostics: dict) -> None: - features = diagnostics.get("spectral_features", {}) - kf = diagnostics.get("kf") or {} - reliability = diagnostics.get("reliability", {}) - self._rec_features.append( - { - "volume": features.get("volume"), - "js_local": features.get("js_local", 0.0), - "js_speed": features.get("js_speed", 0.0), - "js_base": features.get("js_base", 0.0), - "cross_curvature": features.get("cross_curvature", 0.0), - "spectral_state": diagnostics.get("spectral_state", ""), - "event_maturity": features.get("event_maturity", 0.0), - "recovery_frames": features.get("recovery_frames", 0), - "innovation": kf.get("innovation"), - "nis": kf.get("nis"), - "reliability": reliability.get("status", ""), - # 记录事件计数与替换次数,导出后可复核光谱终点是否被更强事件顶替过。 - "events": reliability.get("spectral_events", 0), - "superseded": reliability.get("spectral_superseded", 0), - } - ) - - def _on_titration_complete(self, result: dict) -> None: - """滴定完成时更新 Cx 并自动导出数据。""" - # 确保 Cx 基于最新终点体积重新计算 - if self._endpoint_volume: - self._results_panel.set_endpoint(self._endpoint_volume) - if not self._recording: - return - self._export_recording(result) - - def _export_recording(self, result: dict) -> None: - """将记录的数据写入 ExpResults/ 目录下的 xlsx 文件。""" - - out_dir = os.path.join(os.path.dirname(__file__), "..", "..", "ExpResults") - os.makedirs(out_dir, exist_ok=True) - - c_std = self._results_panel._spin_value(self._results_panel._c_std) - ts = datetime.now().strftime("%y-%m-%d-%H-%M-%S") # noqa: DTZ005 文件名时间戳用本地时间 - filename = f"std_conc_{c_std}_{ts}.xlsx" - filepath = os.path.join(out_dir, filename) - - wb = openpyxl.Workbook() - - # ---- Sheet 1: Raw Spectrum ---- - ws = wb.active - ws.title = "Raw Spectrum" - ws.append( - [ - "Time (s)", - "F1(415)", "F2(445)", "F3(480)", "F4(515)", - "F5(555)", "F6(590)", "F7(630)", "F8(680)", - "Clear", "NIR(910)", - ] - ) - for t, vals in self._rec_spectral: - ws.append([round(t, 3)] + vals) - - # ---- Sheet 2: Reconstructed Spectrum ---- - if self._rec_recon_wls is not None and self._rec_recon: - ws2 = wb.create_sheet("Reconstructed Spectrum") - header = ["Wavelength (nm)"] + [ - f"t={round(t, 3)}s" for t, _ in self._rec_recon - ] - ws2.append(header) - wls = self._rec_recon_wls - for i in range(len(wls)): - row = [round(float(wls[i]), 2)] - for _, spec in self._rec_recon: - row.append(round(float(spec[i]), 4)) - ws2.append(row) - - # ---- Sheet 3: Potential (Filtered) ---- - ws3 = wb.create_sheet("Potential") - ws3.append( - ["Time (s)", "Raw Voltage (V)", "Filtered Voltage (V)", "Volume (mL)"] - ) - for t, rv, fv, vol in self._rec_potential: - ws3.append([round(t, 3), round(rv, 6), round(fv, 6), round(vol, 6)]) - - # ---- Sheet 4: Feature Diagnostics ---- - if self._rec_features: - wsf = wb.create_sheet("Feature Diagnostics") - wsf.append( - [ - "Volume (mL)", - "JS Local", - "JS Speed", - "JS Baseline", - "Cross Curvature", - "Spectral State", - "Event Maturity", - "Recovery Frames", - "Innovation", - "NIS", - "Reliability", - "Events", - "Superseded", - ] - ) - for row in self._rec_features: - wsf.append( - [ - row.get("volume"), - row.get("js_local"), - row.get("js_speed"), - row.get("js_base"), - row.get("cross_curvature"), - row.get("spectral_state"), - row.get("event_maturity"), - row.get("recovery_frames"), - row.get("innovation"), - row.get("nis"), - row.get("reliability"), - row.get("events"), - row.get("superseded"), - ] - ) - - # ---- Sheet 5: Titration Results ---- - ws4 = wb.create_sheet("Titration Results") - ws4.append(["Parameter", "Value"]) - ws4.append( - [ - "Endpoint Volume (mL)", - round(self._endpoint_volume or result.get("volume", 0), 4), - ] - ) - ws4.append(["Confidence", result.get("confidence", "")]) - ws4.append(["Method", result.get("method", "")]) - if result.get("potential"): - ws4.append( - [ - "Potential Endpoint (mL)", - round(result["potential"].get("volume", 0), 4), - ] - ) - ws4.append(["Min dV/dt", result["potential"].get("min_dvdt", "")]) - if result.get("spectral"): - ws4.append( - [ - "Spectral Endpoint (mL)", - round(result["spectral"].get("volume", 0), 4), - ] - ) - ws4.append(["Max CE", result["spectral"].get("max_ce", "")]) - ws4.append(["Max JS", result["spectral"].get("max_js", "")]) - ws4.append(["JS Local", result["spectral"].get("js_local", "")]) - ws4.append(["JS Speed", result["spectral"].get("js_speed", "")]) - ws4.append(["JS Baseline", result["spectral"].get("js_base", "")]) - ws4.append(["Cross Curvature", result["spectral"].get("cross_curvature", "")]) - ws4.append(["Event Maturity", result["spectral"].get("event_maturity", "")]) - ws4.append(["C_std (mol/L)", c_std]) - ws4.append( - ["n_std (标准液)", self._results_panel._spin_value(self._results_panel._n_std)] - ) - ws4.append( - [ - "n_analyte (待测液)", - self._results_panel._spin_value(self._results_panel._n_analyte), - ] - ) - ws4.append(["V_sample (mL)", self._results_panel._v_sample_label.cget("text")]) - ws4.append(["Cx (mol/L)", self._results_panel._c_x_label.cget("text")]) - if self._endpoint_volume: - ws4.append(["Refined Endpoint (mL)", round(self._endpoint_volume, 4)]) - - wb.save(filepath) - self._msg.show("success", i18n.tr("status.exported", file=filename)) - - # ================================================================ - - def _on_recording_toggled(self) -> None: - on = self._rec_var.get() - self._recording = on - save_settings(record=on) - if on: - self._rec_spectral.clear() - self._rec_recon.clear() - self._rec_recon_wls = None - self._rec_potential.clear() - self._rec_raw_adc.clear() - self._rec_features.clear() - self._rec_ewma_v = None - self._msg.show("info", i18n.tr("status.recording_on")) - else: - self._msg.show("info", i18n.tr("status.recording_off")) - - # ---- 电极选择 ---- - - def _load_electrodes(self) -> None: - path = CALIBRE_PATH - electrodes = [] - if os.path.isfile(path): - try: - import numpy as _np - - data = _np.load(path, allow_pickle=True) - if "n_electrodes" in data: - n = int(data["n_electrodes"]) - names = list(data["names"]) - for i in range(n): - name = names[i] - unit = str(data[f"unit_{i}"]) - slope = float(data[f"slope_{i}"]) - intercept = float(data[f"intercept_{i}"]) - electrodes.append((name, slope, intercept, unit)) - except Exception: - pass - self._results_panel.set_electrodes(electrodes) - self._results_panel.on_electrode_changed(self._on_electrode_changed) - - def _on_electrode_changed(self, data: object) -> None: - if data is None: - self._potential_widget.clear_calibration() - else: - _name, slope, intercept, unit = data # type: ignore[misc] - self._potential_widget.set_calibration(unit, slope, intercept) - self._potential_widget.refresh() - - def on_close(self) -> None: - """窗口关闭时清理资源(由顶层调用)。""" - self._running = False - self._heartbeat_active = False - if self._com.is_open: - self._com.send_reset() - self._com.shutdown() - - -__all__ = ["MainWindow", "TitrationState"] diff --git a/TController/src/gui/maintenance_tab.py b/TController/src/gui/maintenance_tab.py deleted file mode 100644 index 1ccff9b..0000000 --- a/TController/src/gui/maintenance_tab.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -维护标签页 — 排空管路 / 充满管路 / 清洗管路 - -所有操作均为 FreeRun 模式,用户肉眼确认后手动停止。 -""" - -from __future__ import annotations - -import tkinter as tk - -import ttkbootstrap as ttk -from Communication import ProtocolHandler - -from gui import i18n, themes - - -def _pump_name(pump_id: int) -> str: - return i18n.tr("pump.inject" if pump_id == 1 else "pump.titrate") - - -class _OperationPanel(ttk.LabelFrame): - """单个维护操作面板:泵复选框 + 启停 + 说明。""" - - def __init__( - self, - title_key: str, - info_key: str, - com: ProtocolHandler, - parent: tk.Misc | None = None, - ) -> None: - super().__init__(parent, text=i18n.tr(title_key)) - self._title_key = title_key - self._info_key = info_key - self._com = com - self._running = False - - inner = ttk.Frame(self, padding=(12, 10)) - inner.pack(fill="both", expand=True) - - # 泵选择(复选框,可多选) - ctrl = ttk.Frame(inner) - ctrl.pack(fill="x") - - self._select_label = ttk.Label(ctrl, text=i18n.tr("maint.select_pump"), style="Muted.TLabel") - self._select_label.pack(side="left") - - self._cb1_var = tk.BooleanVar(value=True) - self._cb1 = ttk.Checkbutton(ctrl, text=i18n.tr("pump.inject"), variable=self._cb1_var) - self._cb1.pack(side="left", padx=(10, 0)) - self._cb2_var = tk.BooleanVar(value=True) - self._cb2 = ttk.Checkbutton(ctrl, text=i18n.tr("pump.titrate"), variable=self._cb2_var) - self._cb2.pack(side="left", padx=(10, 0)) - - btn_frame = ttk.Frame(inner) - btn_frame.pack(fill="x", pady=(10, 0)) - - self._start_btn = ttk.Button( - btn_frame, text=i18n.tr("common.start"), bootstyle="success", command=self._start - ) - self._start_btn.pack(side="left") - - self._stop_btn = ttk.Button( - btn_frame, text=i18n.tr("common.stop"), bootstyle="outline", command=self._stop - ) - self._stop_btn.pack(side="left", padx=(8, 0)) - self._stop_btn.state(["disabled"]) - - # 运行状态提示 - self._status_label = ttk.Label(inner, text="", style="Subtle.TLabel") - self._status_label.pack(fill="x", pady=(8, 0)) - - # 分隔 - ttk.Separator(inner, orient="horizontal").pack(fill="x", pady=8) - - # 操作说明 - self._info_label = ttk.Label( - inner, text=i18n.tr(info_key), wraplength=600, style="Subtle.TLabel" - ) - self._info_label.pack(fill="x") - - i18n.subscribe(self._apply_i18n) - - # ---- i18n ---- - - def _apply_i18n(self) -> None: - self.config(text=i18n.tr(self._title_key)) - self._select_label.config(text=i18n.tr("maint.select_pump")) - self._cb1.config(text=i18n.tr("pump.inject")) - self._cb2.config(text=i18n.tr("pump.titrate")) - self._start_btn.config(text=i18n.tr("common.start")) - self._stop_btn.config(text=i18n.tr("common.stop")) - self._info_label.config(text=i18n.tr(self._info_key)) - if self._running: - self._set_running_status() - else: - self._status_label.config(text="") - - def _set_running_status(self) -> None: - pumps = [] - if self._cb1_var.get(): - pumps.append(_pump_name(1)) - if self._cb2_var.get(): - pumps.append(_pump_name(2)) - t = themes.current_tokens() - self._status_label.config( - text=i18n.tr("maint.running", pumps=" + ".join(pumps)), - foreground=t.success, - font=(themes.UI_FONT, themes.UI_SIZE, "bold"), - ) - - # ---- 操作 ---- - - def _start(self) -> None: - pumps = [] - if self._cb1_var.get(): - pumps.append(1) - if self._cb2_var.get(): - pumps.append(2) - if not pumps: - return - for p in pumps: - self._com.send_frerun(p) - self._running = True - self._start_btn.state(["disabled"]) - self._stop_btn.state(["!disabled"]) - self._set_running_status() - - def _stop(self) -> None: - # 停止所有泵(运行时可能记不住选了哪些,干脆停止全部) - self._com.send_frestop(0xFF) - self._running = False - self._start_btn.state(["!disabled"]) - self._stop_btn.state(["disabled"]) - self._status_label.config( - text=i18n.tr("maint.stopped"), - foreground="", - font=(themes.UI_FONT, themes.UI_SIZE), - ) - - def set_connected(self, connected: bool) -> None: - """串口状态变化时启用/禁用控件。""" - if connected: - self._start_btn.state(["!disabled"]) - self._status_label.config(text="", foreground="") - else: - self._running = False - self._start_btn.state(["disabled"]) - self._stop_btn.state(["disabled"]) - # 明确的离线引导 - self._status_label.config( - text=i18n.tr("maint.offline"), - foreground=themes.current_tokens().accent, - font=(themes.UI_FONT, themes.UI_SIZE), - ) - - -class MaintenanceTab(ttk.Frame): - """维护标签页。""" - - def __init__(self, com: ProtocolHandler, parent: tk.Misc | None = None) -> None: - super().__init__(parent) - self._com = com - - inner = ttk.Frame(self) - inner.pack(fill="both", expand=True, padx=12, pady=12) - - self._title = ttk.Label(inner, text=i18n.tr("maint.title"), style="Section.TLabel") - self._title.pack(anchor="w") - - self._subtitle = ttk.Label( - inner, - text=i18n.tr("maint.subtitle"), - wraplength=640, - style="Muted.TLabel", - ) - self._subtitle.pack(anchor="w", pady=(4, 12)) - - # ---- 排空管路 ---- - self._empty_panel = _OperationPanel( - "maint.empty_title", "maint.empty_info", com=com, parent=inner - ) - self._empty_panel.pack(fill="x", pady=(0, 8)) - - # ---- 充满管路(滴定前) ---- - self._fill_panel = _OperationPanel( - "maint.fill_title", "maint.fill_info", com=com, parent=inner - ) - self._fill_panel.pack(fill="x", pady=(0, 8)) - - # ---- 清洗管路 ---- - self._wash_panel = _OperationPanel( - "maint.wash_title", "maint.wash_info", com=com, parent=inner - ) - self._wash_panel.pack(fill="x", pady=(0, 8)) - - i18n.subscribe(self._apply_i18n) - - def _apply_i18n(self) -> None: - self._title.config(text=i18n.tr("maint.title")) - self._subtitle.config(text=i18n.tr("maint.subtitle")) - - def set_connected(self, connected: bool) -> None: - """串口连接状态变化时同步更新各面板。""" - self._empty_panel.set_connected(connected) - self._fill_panel.set_connected(connected) - self._wash_panel.set_connected(connected) - - -__all__ = ["MaintenanceTab"] diff --git a/TController/src/gui/potential_widget.py b/TController/src/gui/potential_widget.py deleted file mode 100644 index 573f5fe..0000000 --- a/TController/src/gui/potential_widget.py +++ /dev/null @@ -1,322 +0,0 @@ -"""电位–体积/滴定度实时曲线(在线 EWMA 滤波,matplotlib blit 加速)。""" - -from __future__ import annotations - -import tkinter as tk - -import numpy as np -import ttkbootstrap as ttk -from DataProcessor import PUMP_SLOPE -from matplotlib.colors import to_rgb -from matplotlib.patches import Polygon - -from gui import i18n, themes -from gui._plot import _BlitPlot - -VREF = 3.3 -ADC_MAX = 65535 -ELECTRODE_OFFSET = 1.1 - -_FILL_ALPHA = 0.14 - - -class _EWMA: - """因果指数移动平均。""" - - __slots__ = ("_a", "_v") - - def __init__(self, alpha: float = 0.15) -> None: - self._a = alpha - self._v: float | None = None - - def __call__(self, x: float) -> float: - if self._v is None: - self._v = float(x) - else: - self._v = self._a * float(x) + (1.0 - self._a) * self._v - return self._v - - def reset(self) -> None: - self._v = None - - @property - def value(self) -> float | None: - return self._v - - -class PotentialWidget(_BlitPlot): - """电位–体积/滴定度曲线(在线 EWMA 平滑)。""" - - def __init__(self, parent: tk.Misc, **kwargs) -> None: - super().__init__(parent, title=i18n.tr("plot.potential"), **kwargs) - - self._titrating = False - self._times: list[float] = [] - self._volts_raw: list[float] = [] - self._volts_sm: list[float] = [] - self._volumes: list[float] = [] - self._endpoint_volume: float | None = None - self._cal_unit: str | None = None - self._cal_slope: float | None = None - self._cal_intercept: float | None = None - self._endpoint_line = None - - self._ewma = _EWMA(0.15) - - # ── 顶部控制栏 ─────────────────────────────────────────────── - self._canvas.get_tk_widget().pack_forget() - top = ttk.Frame(self) - top.pack(fill="x", padx=8, pady=(4, 0)) - - self._alpha_label = ttk.Label(top, text=i18n.tr("plot.alpha")) - self._alpha_label.pack(side="left") - - self._alpha_var = tk.StringVar(value="0.15") - self._alpha_spin = ttk.Spinbox( - top, - from_=0.01, - to=1.0, - increment=0.05, - textvariable=self._alpha_var, - width=5, - format="%.2f", - command=self._on_alpha_changed, - ) - self._alpha_spin.pack(side="left", padx=(4, 8)) - - self._info_label = ttk.Label(top, text="", style="Muted.TLabel") - self._info_label.pack(side="left") - - # ── 绘图区 ─────────────────────────────────────────────────── - self._canvas.get_tk_widget().pack(fill="both", expand=True) - self._set_xlabel(i18n.tr("plot.time")) - self._set_ylabel(i18n.tr("plot.potential_v")) - self._ax.set_xlim(0, 60) - self._ax.set_ylim(-0.5, 1.5) - self._ax.grid(True, alpha=0.25) - - # 平滑后主曲线 - t = themes.current_tokens() - (self._sm_curve,) = self._ax.plot( - [], [], color=t.plot_potential, linewidth=1.5 - ) - - # 填充区域 - self._fill = Polygon( - np.zeros((0, 2)), - facecolor=self._fill_color(), - edgecolor="none", - ) - self._ax.add_patch(self._fill) - - self._artists = [self._fill, self._sm_curve] - self._capture_bg() - - self._overlay_key: str | None = None - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - # ── 空状态覆盖层 ─────────────────────────────────────── - - def set_overlay(self, key: str | None) -> None: - """按 i18n key 设置/清除空状态提示。""" - self._overlay_key = key - if key is None: - self.hide_overlay() - else: - self.show_overlay(i18n.tr(key)) - - # ── 主题 / 语言 ──────────────────────────────────────────── - - @staticmethod - def _fill_color() -> tuple: - t = themes.current_tokens() - return (*to_rgb(t.plot_potential), _FILL_ALPHA) - - def _apply_i18n(self) -> None: - self._alpha_label.config(text=i18n.tr("plot.alpha")) - if self._overlay_visible and self._overlay_key: - self._overlay.config(text=i18n.tr(self._overlay_key)) - # refresh 内部会通过 _set_title/_set_xlabel/_set_ylabel 缓存写入 - self.refresh() - - def _apply_theme(self) -> None: - t = themes.current_tokens() - self._sm_curve.set_color(t.plot_potential) - self._fill.set_facecolor(self._fill_color()) - if self._endpoint_line is not None: - self._endpoint_line.set_color(t.plot_endpoint) - self._request_full_redraw() - self.refresh() - - # ── 控制 ──────────────────────────────────────────────────────── - - def _on_alpha_changed(self) -> None: - try: - a = float(self._alpha_var.get()) - except ValueError: - return - self._ewma = _EWMA(a) - # 重算全部历史 - self._volts_sm.clear() - for v in self._volts_raw: - self._volts_sm.append(self._ewma(v)) - self._info_label.config(text=f"α={a:.2f}") - - # ── 数据馈入 ──────────────────────────────────────────────────── - - def append(self, t: float, adc_raw: int, volume: float | None = None) -> None: - v = adc_raw * VREF / ADC_MAX - ELECTRODE_OFFSET - self.hide_overlay() # 有数据后移除空状态提示 - self._times.append(t) - self._volts_raw.append(v) - self._volts_sm.append(self._ewma(v)) - self._volumes.append(volume if volume is not None else t * PUMP_SLOPE * 1000) - - # ── 状态 ──────────────────────────────────────────────────────── - - def set_titrating(self, on: bool) -> None: - self._titrating = on - if on: - self._endpoint_volume = None - - def set_calibration(self, unit: str, slope: float, intercept: float) -> None: - self._cal_unit = unit - self._cal_slope = slope - self._cal_intercept = intercept - - def clear_calibration(self) -> None: - self._cal_unit = None - self._cal_slope = None - self._cal_intercept = None - - def set_endpoint(self, volume: float) -> None: - self._endpoint_volume = volume - if self._endpoint_line is not None: - self._endpoint_line.remove() - self._endpoint_line = None - t = themes.current_tokens() - self._endpoint_line = self._ax.axvline( - x=1.0 if volume > 0 else 0, - color=t.plot_endpoint, - linewidth=2, - linestyle="--", - ) - self._set_xlabel(i18n.tr("plot.degree")) - self._ax.set_xlim(0, 2.5) - if self._endpoint_line not in self._artists: - self._artists.append(self._endpoint_line) - self._request_full_redraw() - - # ── 刷新 ──────────────────────────────────────────────────────── - - def refresh(self) -> None: - # 标题和 Y 轴标签(缓存写入,仅变化时触发 full redraw) - if self._cal_slope is not None: - unit = self._cal_unit or "pX" - self._set_title(i18n.tr("plot.px", unit=unit)) - self._set_ylabel(unit) - else: - self._set_title(i18n.tr("plot.potential")) - self._set_ylabel(i18n.tr("plot.potential_v")) - - # X 轴标签 - if self._endpoint_volume is not None and self._endpoint_volume > 0: - self._set_xlabel(i18n.tr("plot.degree")) - else: - self._set_xlabel(i18n.tr("plot.time")) - - if not self._times: - self._sm_curve.set_data([], []) - # 空数据:仅在首次或校准状态变化时全量重绘,避免未连接时 - # 每 80ms 都 canvas.draw()(原实现每帧 _request_full_redraw) - target_ylim = (0, 14) if self._cal_slope is not None else (-0.5, 1.5) - if tuple(self._ax.get_ylim()) != target_ylim: - self._ax.set_ylim(target_ylim) - self._request_full_redraw() - self._blit() - return - - # 计算 x - if self._endpoint_volume is not None and self._endpoint_volume > 0: - x_vals = [v / self._endpoint_volume for v in self._volumes] - elif self._titrating: - x_vals = list(self._volumes) - self._set_xlabel(i18n.tr("plot.volume")) - else: - x_vals = list(self._times) - - # 平滑值 - if self._cal_slope is not None: - assert self._cal_intercept is not None - y_sm = [ - self._cal_intercept + self._cal_slope * v * 1000.0 - for v in self._volts_sm - ] - else: - y_sm = self._volts_sm - - self._sm_curve.set_data(x_vals, y_sm) - - # 更新填充区域 - if len(x_vals) > 0: - verts = np.column_stack( - [ - np.concatenate([x_vals, x_vals[::-1]]), - np.concatenate([y_sm, np.zeros_like(y_sm)]), - ] - ) - self._fill.set_xy(verts) - - # Y 范围(整数对齐量化,减少 full redraw 触发频率) - if len(y_sm) > 0: - y_min = min(y_sm) - y_max = max(y_sm) - if y_max > y_min: - padding = max(0.1, (y_max - y_min) * 0.15) - # 量化到 0.1 精度:数据小幅波动不再触发 set_ylim - new_ylim = ( - round(y_min - padding, 1), - round(y_max + padding, 1), - ) - if new_ylim != tuple(self._ax.get_ylim()): - self._ax.set_ylim(new_ylim) - self._request_full_redraw() - - # X 范围(整数对齐量化) - if len(x_vals) > 0: - x_max = max(x_vals) * 1.15 - if self._endpoint_volume is not None and self._endpoint_volume > 0: - x_max = max(x_max, 1.0) - else: - x_max = max(x_max, 0.5) - # 量化到 0.5 精度:滴定中体积缓慢增长,不再每帧触发 set_xlim - new_xlim = (0, round(x_max * 2) / 2) - if new_xlim != tuple(self._ax.get_xlim()): - self._ax.set_xlim(new_xlim) - self._request_full_redraw() - - self._blit() - - def reset(self) -> None: - self._times.clear() - self._volts_raw.clear() - self._volts_sm.clear() - self._volumes.clear() - self._ewma.reset() - self._endpoint_volume = None - if self._endpoint_line is not None: - self._endpoint_line.remove() - self._endpoint_line = None - if self._endpoint_line in self._artists: - self._artists.remove(self._endpoint_line) - self._set_xlabel(i18n.tr("plot.time")) - self._ax.set_xlim(0, 5) - self._sm_curve.set_data([], []) - self._request_full_redraw() - - def shutdown(self) -> None: - pass - - -__all__ = ["PotentialWidget"] diff --git a/TController/src/gui/results_panel.py b/TController/src/gui/results_panel.py deleted file mode 100644 index 2db7740..0000000 --- a/TController/src/gui/results_panel.py +++ /dev/null @@ -1,392 +0,0 @@ -"""滴定计算结果面板(ttkbootstrap)— KPI 卡片式结果展示。""" - -from __future__ import annotations - -import tkinter as tk -from collections.abc import Callable - -import ttkbootstrap as ttk - -from gui import i18n -from gui.themes import MONO_FONT -from gui.widgets import Card - - -class ResultsPanel(ttk.Frame): - """右侧计算面板:参数输入 + 进样进度 + KPI 结果卡片。""" - - def __init__( - self, - parent: tk.Misc, - record_var: tk.BooleanVar | None = None, - record_command=None, - **kwargs, - ) -> None: - super().__init__(parent, **kwargs) - - layout = ttk.Frame(self) - layout.pack(fill="both", expand=True, padx=10, pady=10) - - # ---- 标题行:标题 + 记录开关 ---- - title_row = ttk.Frame(layout) - title_row.pack(fill="x", pady=(0, 6)) - self._title = ttk.Label( - title_row, text=i18n.tr("results.title"), style="Section.TLabel" - ) - self._title.pack(side="left") - if record_var is not None: - self._rec_cb = ttk.Checkbutton( - title_row, - text=i18n.tr("toolbar.record"), - variable=record_var, - command=record_command, - ) - self._rec_cb.pack(side="right") - else: - self._rec_cb = None - - # ---- 电极选择 ---- - self._electrode_label = ttk.Label(layout, text=i18n.tr("results.electrode"), style="Muted.TLabel") - self._electrode_label.pack(anchor="w") - self._electrode_combo = ttk.Combobox(layout, state="readonly") - self._electrode_combo.pack(fill="x", pady=(2, 8)) - self._electrode_combo.bind("<>", self._on_combo_changed) - self._electrode_data: list[tuple] = [] - self._electrode_values: list[str] = [] - self._electrode_map: dict[str, tuple | None] = {} - self._on_electrode_cb: Callable[[object], None] | None = None - - # ---- 化学计量数 ---- - self._stoich_group = ttk.LabelFrame(layout, text=i18n.tr("results.stoich")) - self._stoich_group.pack(fill="x", pady=(0, 6)) - stoich_inner = ttk.Frame(self._stoich_group, padding=(8, 6)) - stoich_inner.pack(fill="both", expand=True) - - self._n_std = self._make_spin(stoich_inner, 0.1, 100.0, 1.0, 0.1, 1) - self._n_std_label = self._add_form_row(stoich_inner, i18n.tr("results.n_std"), self._n_std) - - self._n_analyte = self._make_spin(stoich_inner, 0.1, 100.0, 1.0, 0.1, 1) - self._n_analyte_label = self._add_form_row( - stoich_inner, i18n.tr("results.n_analyte"), self._n_analyte - ) - - # ---- 标准液浓度 ---- - self._conc_group = ttk.LabelFrame(layout, text=i18n.tr("results.conc")) - self._conc_group.pack(fill="x", pady=(0, 6)) - conc_inner = ttk.Frame(self._conc_group, padding=(8, 6)) - conc_inner.pack(fill="both", expand=True) - - self._c_std = self._make_spin(conc_inner, 0.0001, 100.0, 0.1, 0.01, 4) - self._c_std_label = self._add_form_row(conc_inner, i18n.tr("results.c_std"), self._c_std) - - # ---- 待测液 ---- - self._sample_group = ttk.LabelFrame(layout, text=i18n.tr("results.sample")) - self._sample_group.pack(fill="x", pady=(0, 6)) - sample_inner = ttk.Frame(self._sample_group, padding=(8, 6)) - sample_inner.pack(fill="both", expand=True) - - self._v_sample_label = ttk.Label( - sample_inner, text="—", font=(MONO_FONT, 10) - ) - self._v_sample_rowlabel = self._add_form_row( - sample_inner, i18n.tr("results.sample_volume"), self._v_sample_label - ) - - self._v_now_label = ttk.Label( - sample_inner, text="— V", font=(MONO_FONT, 10) - ) - self._v_now_rowlabel = self._add_form_row( - sample_inner, i18n.tr("results.current_voltage"), self._v_now_label - ) - - # ---- 进样进度(默认隐藏)---- - self._inject_group = ttk.LabelFrame(layout, text=i18n.tr("results.inject")) - inject_inner = ttk.Frame(self._inject_group, padding=(8, 6)) - inject_inner.pack(fill="both", expand=True) - self._inject_bar = ttk.Progressbar( - inject_inner, maximum=100, bootstyle="info-striped" - ) - self._inject_bar.pack(fill="x", pady=(2, 0)) - self._inject_text_label = ttk.Label( - inject_inner, text="", anchor="center", style="Subtle.TLabel" - ) - self._inject_text_label.pack(fill="x", pady=(2, 0)) - self._eta_label = ttk.Label( - inject_inner, text="", anchor="center", style="Subtle.TLabel" - ) - self._eta_label.pack(fill="x") - - # ---- KPI 卡片:终点体积 ---- - self._ep_card = Card(layout, tone="alt") - self._ep_card.pack(fill="x", pady=(2, 6)) - ep_body = ttk.Frame(self._ep_card, style="Kpi.TFrame", padding=(10, 8)) - ep_body.pack(fill="both", expand=True) - self._ep_caption = ttk.Label( - ep_body, text=i18n.tr("results.endpoint"), style="Kpi.TLabel" - ) - self._ep_caption.pack(anchor="w") - ep_value_row = ttk.Frame(ep_body, style="Kpi.TFrame") - ep_value_row.pack(anchor="w") - self._v_ep_label = ttk.Label( - ep_value_row, text="—", style="KpiAccent.TLabel" - ) - self._v_ep_label.pack(side="left") - ttk.Label( - ep_value_row, text=" " + i18n.tr("results.endpoint_unit"), style="KpiUnit.TLabel" - ).pack(side="left", pady=(6, 0)) - self._ep_unit_label = ep_value_row.winfo_children()[-1] - - # ---- KPI 卡片:Cₓ ---- - self._cx_card = Card(layout, tone="alt") - self._cx_card.pack(fill="x", pady=(0, 6)) - cx_body = ttk.Frame(self._cx_card, style="Kpi.TFrame", padding=(10, 8)) - cx_body.pack(fill="both", expand=True) - self._cx_caption = ttk.Label( - cx_body, text=i18n.tr("results.cx"), style="Kpi.TLabel" - ) - self._cx_caption.pack(anchor="w") - cx_value_row = ttk.Frame(cx_body, style="Kpi.TFrame") - cx_value_row.pack(anchor="w") - self._c_x_label = ttk.Label( - cx_value_row, text="—", style="KpiSuccess.TLabel" - ) - self._c_x_label.pack(side="left") - ttk.Label( - cx_value_row, text=" " + i18n.tr("results.cx_unit"), style="KpiUnit.TLabel" - ).pack(side="left", pady=(6, 0)) - self._cx_unit_label = cx_value_row.winfo_children()[-1] - - # ---- 在线可靠性诊断 ---- - self._diag_group = ttk.LabelFrame(layout, text=i18n.tr("results.diagnostics")) - self._diag_group.pack(fill="x", pady=(0, 6)) - diag_inner = ttk.Frame(self._diag_group, padding=(8, 6)) - diag_inner.pack(fill="both", expand=True) - self._diag_status = self._add_diag_row(diag_inner, "results.diag_status") - self._diag_quality = self._add_diag_row(diag_inner, "results.diag_quality") - self._diag_consistency = self._add_diag_row(diag_inner, "results.diag_consistency") - self._diag_nis = self._add_diag_row(diag_inner, "results.diag_nis") - self._diag_std = self._add_diag_row(diag_inner, "results.diag_std") - self._diag_delay = self._add_diag_row(diag_inner, "results.diag_delay") - - # Internal state - self._endpoint_volume: float | None = None - self._sample_volume: float = 0.0 - self._inject_target: int = 0 - self._inject_target_vol: float = 0.0 - - i18n.subscribe(self._apply_i18n) - - # ---- i18n ---- - - def _apply_i18n(self) -> None: - self._title.config(text=i18n.tr("results.title")) - if self._rec_cb is not None: - self._rec_cb.config(text=i18n.tr("toolbar.record")) - self._electrode_label.config(text=i18n.tr("results.electrode")) - self._stoich_group.config(text=i18n.tr("results.stoich")) - self._n_std_label.config(text=i18n.tr("results.n_std")) - self._n_analyte_label.config(text=i18n.tr("results.n_analyte")) - self._conc_group.config(text=i18n.tr("results.conc")) - self._c_std_label.config(text=i18n.tr("results.c_std")) - self._sample_group.config(text=i18n.tr("results.sample")) - self._v_sample_rowlabel.config(text=i18n.tr("results.sample_volume")) - self._v_now_rowlabel.config(text=i18n.tr("results.current_voltage")) - self._inject_group.config(text=i18n.tr("results.inject")) - self._diag_group.config(text=i18n.tr("results.diagnostics")) - # 在线诊断行的标题由行内 label 保存,值标签不参与翻译。 - for widget, key in ( - (self._diag_status, "results.diag_status"), - (self._diag_quality, "results.diag_quality"), - (self._diag_consistency, "results.diag_consistency"), - (self._diag_nis, "results.diag_nis"), - (self._diag_std, "results.diag_std"), - (self._diag_delay, "results.diag_delay"), - ): - label = widget.master.winfo_children()[0] - label.config(text=i18n.tr(key)) - self._ep_caption.config(text=i18n.tr("results.endpoint")) - self._ep_unit_label.config(text=" " + i18n.tr("results.endpoint_unit")) - self._cx_caption.config(text=i18n.tr("results.cx")) - self._cx_unit_label.config(text=" " + i18n.tr("results.cx_unit")) - # 电极下拉框首项(Raw Potential) - if self._electrode_values: - raw = i18n.tr("results.raw_potential") - old_first = self._electrode_values[0] - if old_first != raw: - self._electrode_values[0] = raw - self._electrode_map[raw] = self._electrode_map.pop(old_first) - self._electrode_combo["values"] = self._electrode_values - if self._electrode_combo.get() == old_first: - self._electrode_combo.set(raw) - - # ---- 工具 ---- - - @staticmethod - def _make_spin( - parent: tk.Misc, - lo: float, - hi: float, - init: float, - step: float, - decimals: int, - ) -> ttk.Spinbox: - fmt = f"%.{decimals}f" - sb = ttk.Spinbox( - parent, - from_=lo, - to=hi, - increment=step, - format=fmt, - width=10, - ) - sb.set(init) - return sb - - @staticmethod - def _add_form_row(parent: tk.Misc, label: str, widget: tk.Widget) -> ttk.Label: - row = ttk.Frame(parent) - row.pack(fill="x", pady=2) - row.grid_columnconfigure(0, weight=1) - lbl = ttk.Label(row, text=label) - lbl.grid(row=0, column=0, sticky="w") - widget.grid(in_=row, row=0, column=1, sticky="e", padx=(8, 0)) - return lbl - - def _spin_value(self, sb: ttk.Spinbox) -> float: - try: - return float(sb.get()) - except (ValueError, tk.TclError): - return 0.0 - - @staticmethod - def _add_diag_row(parent: tk.Misc, label_key: str) -> ttk.Label: - row = ttk.Frame(parent) - row.pack(fill="x", pady=1) - row.grid_columnconfigure(0, weight=1) - caption = ttk.Label(row, text=i18n.tr(label_key), style="Muted.TLabel") - caption.grid(row=0, column=0, sticky="w") - value = ttk.Label(row, text="—", font=(MONO_FONT, 9)) - value.grid(row=0, column=1, sticky="e", padx=(8, 0)) - return value - - def set_reliability(self, reliability: dict | None) -> None: - """Update the compact causal reliability readout.""" - data = reliability or {} - quality = data.get("data_quality") or {} - consistency = data.get("modal_consistency") or {} - status = data.get("status") or "—" - last_frame = quality.get("last_frame", "—") - self._diag_status.config(text=str(status)) - self._diag_quality.config(text=str(last_frame)) - agreement = consistency.get("agreement_mL") - self._diag_consistency.config( - text="—" if agreement is None else f"{float(agreement):.4f} mL" - ) - nis = data.get("nis") - self._diag_nis.config(text="—" if nis is None else f"{float(nis):.2f}") - endpoint_std = data.get("endpoint_std") - self._diag_std.config( - text="—" if endpoint_std is None else f"{float(endpoint_std):.4f} mL" - ) - delay = data.get("spectral_delay") - self._diag_delay.config( - text="—" if delay is None else f"{float(delay):+.4f} mL" - ) - - # ---- 公开接口 ---- - - def set_electrodes(self, electrode_list: list[tuple]) -> None: - self._electrode_data = electrode_list - raw = i18n.tr("results.raw_potential") - self._electrode_values = [raw] - self._electrode_map = {raw: None} - for name, slope, intercept, unit in electrode_list: - disp = f"{name} ({unit})" - self._electrode_values.append(disp) - self._electrode_map[disp] = (name, slope, intercept, unit) - self._electrode_combo["values"] = self._electrode_values - if self._electrode_values: - self._electrode_combo.set(self._electrode_values[0]) - - def on_electrode_changed(self, cb: Callable[[object], None]) -> None: - """注册电极选择变化回调。""" - self._on_electrode_cb = cb - - def _on_combo_changed(self, _event: object = None) -> None: - disp = self._electrode_combo.get() - data = self._electrode_map.get(disp) - if self._on_electrode_cb is not None: - self._on_electrode_cb(data) - - def set_sample_volume(self, vol: float) -> None: - self._sample_volume = vol - self._v_sample_label.config(text=f"{vol:.2f}") - self._recalc() - - def set_endpoint(self, volume: float) -> None: - self._endpoint_volume = volume - self._v_ep_label.config(text=f"{volume:.4f}") - self._recalc() - - def reset_endpoint(self) -> None: - self._endpoint_volume = None - self._v_ep_label.config(text="—") - self._c_x_label.config(text="—") - - def set_current_voltage(self, v: float) -> None: - self._v_now_label.config(text=f"{v:.4f} V") - - # ---- 进样进度 ---- - - def show_inject_progress(self, target_steps: int, target_vol: float = 0.0) -> None: - self._inject_target = target_steps - self._inject_target_vol = target_vol - self._inject_bar["value"] = 0 - self._inject_text_label.config(text=f"0.000 / {target_vol:.3f} mL") - self._eta_label.config(text="") - self._inject_group.pack(fill="x", pady=(0, 6), before=self._ep_card) - - def update_inject_progress(self, pos: int, volume: float = 0.0) -> None: - if self._inject_target <= 0: - return - pct = min(100, int(pos * 100 / self._inject_target)) - self._inject_bar["value"] = pct - tv = self._inject_target_vol - remaining = max(0, self._inject_target - pos) - eta_sec = remaining // 1000 - if pct < 100 and remaining > 0: - self._inject_text_label.config(text=f"{volume:.3f} / {tv:.3f} mL ({pct}%)") - self._eta_label.config( - text=i18n.tr("results.eta", eta=f"{eta_sec // 60:02d}:{eta_sec % 60:02d}") - ) - else: - self._inject_text_label.config(text=i18n.tr("results.inject_done")) - self._eta_label.config(text="") - - def hide_inject_progress(self) -> None: - self._inject_text_label.config(text="") - self._eta_label.config(text="") - self._inject_group.pack_forget() - self._inject_bar["value"] = 0 - - # ---- 内部 ---- - - def _recalc(self) -> None: - if self._endpoint_volume is None or self._endpoint_volume <= 0: - self._c_x_label.config(text="—") - return - V_ep = self._endpoint_volume - V_sample = self._sample_volume - if V_sample <= 0: - self._c_x_label.config(text="—") - return - - n_std = self._spin_value(self._n_std) - n_analyte = self._spin_value(self._n_analyte) - C_std = self._spin_value(self._c_std) - - Cx = (C_std * V_ep * n_analyte) / (V_sample * n_std) - self._c_x_label.config(text=f"{Cx:.6f}") - - -__all__ = ["ResultsPanel"] diff --git a/TController/src/gui/settings.py b/TController/src/gui/settings.py deleted file mode 100644 index bc2a581..0000000 --- a/TController/src/gui/settings.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -用户设置持久化 — language / theme_mode / baud / record / last_port。 - -保存于 data/settings.json(与 calibre.npz 同目录,开发/打包模式均可写)。 -""" - -from __future__ import annotations - -import json -import os - -from DataProcessor._path import CALIBRE_PATH - -from gui.i18n import DEFAULT_LANG, LANGS - -SETTINGS_PATH = os.path.join(os.path.dirname(CALIBRE_PATH), "settings.json") - -DEFAULTS: dict = { - "language": DEFAULT_LANG, - "theme_mode": "system", # light | dark | system - "baud": 115200, - "record": True, - "last_port": "", # 上次成功连接的串口设备路径 -} - - -def load_settings() -> dict: - """读取设置(缺失/损坏时回退默认值)。""" - merged = dict(DEFAULTS) - try: - with open(SETTINGS_PATH, encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict): - for k in DEFAULTS: - if k in data: - merged[k] = data[k] - except (OSError, json.JSONDecodeError): - pass - # 合法性兜底 - if merged["language"] not in LANGS: - merged["language"] = DEFAULT_LANG - if merged["theme_mode"] not in ("light", "dark", "system"): - merged["theme_mode"] = "system" - if merged["baud"] != 115200: - merged["baud"] = 115200 - return merged - - -def save_settings(**patch: object) -> None: - """合并写入设置(单项更新时不覆盖其它键)。""" - data = load_settings() - for k, v in patch.items(): - if k in DEFAULTS: - data[k] = v - try: - with open(SETTINGS_PATH, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - except OSError: - pass - - -__all__ = ["DEFAULTS", "SETTINGS_PATH", "load_settings", "save_settings"] diff --git a/TController/src/gui/spectrum_widget.py b/TController/src/gui/spectrum_widget.py deleted file mode 100644 index ba1f101..0000000 --- a/TController/src/gui/spectrum_widget.py +++ /dev/null @@ -1,111 +0,0 @@ -"""实时光谱曲线控件(matplotlib blit 加速,i18n + 主题感知)。""" - -from __future__ import annotations - -import tkinter as tk - -import numpy as np -from matplotlib.colors import to_rgb -from matplotlib.patches import Polygon - -from gui import i18n, themes -from gui._plot import _BlitPlot - -_FILL_ALPHA = 0.18 - - -class SpectrumWidget(_BlitPlot): - """AS7341 全光谱实时曲线 (380–1100 nm)。""" - - def __init__(self, parent: tk.Misc, **kwargs) -> None: - super().__init__(parent, title=i18n.tr("plot.spectrum"), **kwargs) - - self._set_xlabel(i18n.tr("plot.wavelength")) - self._set_ylabel(i18n.tr("plot.intensity")) - self._ax.set_xlim(380, 1100) - self._ax.set_ylim(0, 1) - self._ax.grid(True, alpha=0.25) - - # 填充区域(Polygon,原地更新,blit 友好) - self._fill = Polygon( - np.zeros((0, 2)), - facecolor=self._fill_color(), - edgecolor="none", - ) - self._ax.add_patch(self._fill) - - # 主曲线 - t = themes.current_tokens() - (self._line,) = self._ax.plot( - [380], [0], color=t.plot_spectrum, linewidth=2 - ) - - self._artists = [self._fill, self._line] - self._capture_bg() - - self._overlay_key: str | None = None - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._apply_theme) - - # ── 空状态覆盖层 ─────────────────────────────────────── - - def set_overlay(self, key: str | None) -> None: - """按 i18n key 设置/清除空状态提示。""" - self._overlay_key = key - if key is None: - self.hide_overlay() - else: - self.show_overlay(i18n.tr(key)) - - # ── 主题 / 语言 ──────────────────────────────────────────── - - @staticmethod - def _fill_color() -> tuple: - t = themes.current_tokens() - return (*to_rgb(t.plot_spectrum), _FILL_ALPHA) - - def _apply_i18n(self) -> None: - self._set_title(i18n.tr("plot.spectrum")) - self._set_xlabel(i18n.tr("plot.wavelength")) - self._set_ylabel(i18n.tr("plot.intensity")) - self.refresh() - if self._overlay_visible and self._overlay_key: - self._overlay.config(text=i18n.tr(self._overlay_key)) - - def _apply_theme(self) -> None: - t = themes.current_tokens() - self._line.set_color(t.plot_spectrum) - self._fill.set_facecolor(self._fill_color()) - self._request_full_redraw() - self.refresh() - - # ── 数据馈入 ─────────────────────────────────────────────── - - def update_spectrum( - self, wavelengths: np.ndarray, spectrum: np.ndarray - ) -> None: - """更新光谱数据并重绘。""" - self.hide_overlay() # 有数据后移除空状态提示 - self._line.set_data(wavelengths, spectrum) - - if len(wavelengths) > 0: - verts = np.column_stack( - [ - np.concatenate([wavelengths, wavelengths[::-1]]), - np.concatenate([spectrum, np.zeros_like(spectrum)]), - ] - ) - self._fill.set_xy(verts) - - # 自动 Y 范围(量化到 0.05 精度,减少 full redraw 频率) - y_max = float(np.max(spectrum)) if len(spectrum) > 0 else 1.0 - if y_max > 0: - new_ylim = (0, round(y_max * 1.15 / 0.05) * 0.05) - if new_ylim != tuple(self._ax.get_ylim()): - self._ax.set_ylim(new_ylim) - self._request_full_redraw() - - self.refresh() - - -__all__ = ["SpectrumWidget"] diff --git a/TController/src/gui/themes.py b/TController/src/gui/themes.py deleted file mode 100644 index f55765b..0000000 --- a/TController/src/gui/themes.py +++ /dev/null @@ -1,471 +0,0 @@ -""" -主题管理模块 — 仪器级灰阶配色(instrument-grade grayscale)。 - -设计原则:界面主体为中性灰阶,色彩克制地保留给语义状态 -(成功/警告/危险)与数据曲线,主操作为石墨色实心按钮。 - - Light: bg #F3F4F5 / surface #FFFFFF / primary #3A4149(石墨) - Dark: bg #1C1E21 / surface #24262A / primary #B8BEC6(浅石墨) - -自定义 ttkbootstrap 主题(instrument-light / instrument-dark)在 -本模块导入时注册到 USER_THEMES,使按钮、下拉框、进度条等全部 -bootstyle 控件与界面令牌保持同一调色板。控件通过 subscribe() -订阅主题变化以更新直接写入的语义色(foreground 等)。 -""" - -from __future__ import annotations - -import platform -import sys -import tkinter as tk -from collections.abc import Callable -from dataclasses import dataclass - -import ttkbootstrap -from matplotlib import font_manager, rcParams -from ttkbootstrap.themes.user import USER_THEMES - -# ── 字体 ───────────────────────────────────────────────────── - - -def ui_font_family() -> str: - system = platform.system() - if system == "Windows": - # Segoe UI 不含 CJK 字形,tk 走 GDI 字体链接回退(雅黑字形 + - # Segoe 度量)会把中文挤压错位;直接用中英混排的雅黑 UI 字体 - available = {f.name for f in font_manager.fontManager.ttflist} - for name in ("Microsoft YaHei UI", "Microsoft YaHei", "SimHei"): - if name in available: - return name - return "Microsoft YaHei UI" - if system == "Darwin": - return "PingFang SC" - return "Noto Sans CJK SC" - - -def mono_font_family() -> str: - system = platform.system() - if system == "Windows": - return "Consolas" - if system == "Darwin": - return "Menlo" - return "DejaVu Sans Mono" - - -UI_FONT = ui_font_family() -MONO_FONT = mono_font_family() -UI_SIZE = 9 - - -# ── 设计令牌 ───────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Tokens: - """一套主题的语义色令牌。""" - - # 表面与文本 - bg: str - surface: str - surface_alt: str - fg: str - fg_muted: str - border: str - muted: str - # 品牌与语义色 - primary: str - on_primary: str - secondary: str - accent: str - success: str - danger: str - danger_soft: str - on_filled: str # 语义填充色(success/accent/danger)上的文字色 - # 图表曲线 - plot_spectrum: str - plot_potential: str - plot_endpoint: str - plot_scatter: str - plot_scatter_ph: str - plot_fit: str - - -LIGHT = Tokens( - bg="#F3F4F5", - surface="#FFFFFF", - surface_alt="#ECEDEF", - fg="#1F2328", - fg_muted="#5C626A", - border="#D4D7DB", - muted="#E4E6E9", - primary="#3A4149", - on_primary="#FFFFFF", - secondary="#6E747B", - accent="#9A6B1E", - success="#2E6B4F", - danger="#A63D33", - danger_soft="#F5EAE9", - on_filled="#FFFFFF", - plot_spectrum="#3E5C77", - plot_potential="#4E7A5F", - plot_endpoint="#A63D33", - plot_scatter="#3E5C77", - plot_scatter_ph="#6B5B7E", - plot_fit="#A63D33", -) - -DARK = Tokens( - bg="#1C1E21", - surface="#24262A", - surface_alt="#2B2E33", - fg="#E3E5E8", - fg_muted="#9BA1A8", - border="#3D424A", - muted="#31353B", - primary="#B8BEC6", - on_primary="#1B1D20", - secondary="#7A818A", - accent="#C09A5B", - success="#5E9C77", - danger="#C05A4E", - danger_soft="#372B29", - on_filled="#1B1D20", - plot_spectrum="#8FA9BF", - plot_potential="#83A98F", - plot_endpoint="#CC7B6F", - plot_scatter="#8FA9BF", - plot_scatter_ph="#A08FB3", - plot_fit="#CC7B6F", -) - - -# ── ttkbootstrap 自定义主题(与令牌同 palette,导入时注册)────────── - -_TTKB_THEMES: dict[str, dict] = { - "instrument-light": { - "type": "light", - "colors": { - "primary": LIGHT.primary, - "secondary": LIGHT.secondary, - "success": LIGHT.success, - "info": "#546B7A", - "warning": LIGHT.accent, - "danger": LIGHT.danger, - "light": "#F4F5F6", - "dark": "#21252A", - "bg": LIGHT.bg, - "fg": LIGHT.fg, - "selectbg": "#3F4750", - "selectfg": "#FFFFFF", - "border": "#C9CDD2", - "inputfg": LIGHT.fg, - "inputbg": "#FFFFFF", - "active": "#E3E5E8", - }, - }, - "instrument-dark": { - "type": "dark", - "colors": { - "primary": DARK.primary, - "secondary": DARK.secondary, - "success": DARK.success, - "info": "#6E8B99", - "warning": DARK.accent, - "danger": DARK.danger, - "light": "#2A2D31", - "dark": "#E2E4E7", - "bg": DARK.bg, - "fg": DARK.fg, - "selectbg": "#4A5058", - "selectfg": "#F2F3F4", - "border": DARK.border, - "inputfg": DARK.fg, - "inputbg": DARK.surface, - "active": "#34383E", - }, - }, -} - -for _name, _def in _TTKB_THEMES.items(): - USER_THEMES.setdefault(_name, _def) - - -@dataclass(frozen=True) -class Theme: - key: str - ttkb_theme: str - tokens: Tokens - - -THEMES: dict[str, Theme] = { - "light": Theme(key="light", ttkb_theme="instrument-light", tokens=LIGHT), - "dark": Theme(key="dark", ttkb_theme="instrument-dark", tokens=DARK), -} - -MODES = ("light", "dark", "system") - -_current_key = "light" -_subs: list[Callable[[], None]] = [] - - -def current_tokens() -> Tokens: - return THEMES[_current_key].tokens - - -def current_key() -> str: - """当前实际生效的主题 key('light' | 'dark')。""" - return _current_key - - -# ── matplotlib 中文字体设置 ──────────────────────────────────── - - -def _setup_matplotlib_fonts() -> None: - """配置 matplotlib 中文字体(解决 Glyph missing 警告)。""" - candidates = [] - system = platform.system() - if system == "Windows": - candidates = ["Microsoft YaHei", "SimHei", "Microsoft JhengHei"] - elif system == "Darwin": - candidates = ["PingFang SC", "Heiti SC", "STHeiti"] - else: - candidates = ["WenQuanYi Micro Hei", "Noto Sans CJK SC", "Droid Sans Fallback"] - - available = {f.name for f in font_manager.fontManager.ttflist} - for name in candidates: - if name in available: - rcParams["font.sans-serif"] = [name, "DejaVu Sans"] - rcParams["axes.unicode_minus"] = False - return - rcParams["axes.unicode_minus"] = False - - -# ── 系统暗色检测 ───────────────────────────────────────────── - - -def _system_is_dark() -> bool: - system = platform.system() - if system == "Windows": - try: - import winreg - - key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", - ) - value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") - winreg.CloseKey(key) - return value == 0 - except Exception: - return False - elif system == "Darwin": - import os - - return os.environ.get("USER_UI_THEME", "").lower() == "dark" - return False - - -def resolve_theme(mode: str) -> Theme: - key = ( - "dark" - if mode == "system" and _system_is_dark() - else (mode if mode in THEMES else "light") - ) - return THEMES[key] - - -# ── Windows 原生标题栏/边框 ────────────────────────────────── - - -def set_native_titlebar(dark: bool, root: tk.Misc) -> None: - """切换 Windows 原生窗口边框(非客户区)的暗色模式。 - - tkinter/ttk 只绘制客户区,标题栏由 DWM 绘制,需通过 - DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE) 显式切换。 - 注意(Win10 实测 21H2):DWM 只在窗口映射时读取该属性—— - 启动时须在 deiconify 之前调用;已映射的窗口需要 - hide→set→show 循环才能在运行时切换。Win11 无此限制。 - """ - if platform.system() != "Windows": - return - try: - import ctypes - - u32 = ctypes.windll.user32 - dwm = ctypes.windll.dwmapi - # withdrawn 且尚未处理事件时,框架窗口可能尚未创建,先强制空闲处理 - root.update_idletasks() - hwnd = u32.GetParent(root.winfo_id()) or int(root.wm_frame(), 16) - - mapped = bool(root.winfo_viewable()) - needs_remap = mapped and sys.getwindowsversion().build < 22000 - if needs_remap: - u32.ShowWindow(hwnd, 0) # SW_HIDE - - value = ctypes.c_int(1 if dark else 0) - # 20 = Win10 18985+/Win11;19 = 早期 Win10 - for attr in (20, 19): - if dwm.DwmSetWindowAttribute( - hwnd, attr, ctypes.byref(value), ctypes.sizeof(value) - ) == 0: - break - # 强制非客户区重绘 - u32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0004 | 0x0020) - - if needs_remap: - u32.ShowWindow(hwnd, 5) # SW_SHOW - u32.SetForegroundWindow(hwnd) - except Exception: - pass - - -# ── 自定义样式(每次主题切换后重新配置)────────────────────── - - -def _configure_styles(style: ttkbootstrap.Style, t: Tokens) -> None: - ui = (UI_FONT, UI_SIZE) - mono_big = (MONO_FONT, 15, "bold") - - style.configure(".", font=ui) - - # 工具栏 / 状态栏 - style.configure("Toolbar.TFrame", background=t.surface) - style.configure("Toolbar.TLabel", background=t.surface, foreground=t.fg_muted, font=ui) - style.configure("Statusbar.TFrame", background=t.surface) - style.configure("Status.TLabel", background=t.surface, foreground=t.fg_muted, font=ui) - style.configure( - "Conn.TLabel", background=t.surface, foreground=t.fg_muted, font=(UI_FONT, UI_SIZE, "bold") - ) - - # 状态芯片(状态栏左侧) - chips = { - "ChipIdle": (t.muted, t.fg_muted), - "ChipRun": (t.primary, t.on_primary), - "ChipWarn": (t.accent, t.on_filled), - "ChipOk": (t.success, t.on_filled), - "ChipErr": (t.danger, t.on_filled), - } - for name, (bgc, fgc) in chips.items(): - style.configure( - f"{name}.TLabel", - background=bgc, - foreground=fgc, - font=(UI_FONT, UI_SIZE, "bold"), - padding=(10, 3), - ) - - # KPI 卡片(结果面板) - style.configure("Kpi.TFrame", background=t.surface_alt, borderwidth=1, relief="solid") - style.configure("Kpi.TLabel", background=t.surface_alt, foreground=t.fg_muted, font=(UI_FONT, 8)) - style.configure("KpiAccent.TLabel", background=t.surface_alt, foreground=t.accent, font=mono_big) - style.configure("KpiSuccess.TLabel", background=t.surface_alt, foreground=t.success, font=mono_big) - style.configure("KpiUnit.TLabel", background=t.surface_alt, foreground=t.fg_muted, font=(UI_FONT, 8)) - - # 文本层级 - style.configure("Section.TLabel", foreground=t.fg, font=(UI_FONT, 11, "bold")) - style.configure("Muted.TLabel", foreground=t.fg_muted, font=ui) - style.configure("Subtle.TLabel", foreground=t.fg_muted, font=(UI_FONT, 8)) - style.configure("TLabelframe.Label", foreground=t.fg) - - # 工具栏分组卡片 / 急停区 - style.configure("GroupCard.TFrame", background=t.surface, borderwidth=1, relief="solid") - style.configure( - "GroupCaption.TLabel", background=t.surface, foreground=t.fg_muted, font=(UI_FONT, 8) - ) - style.configure("GroupLabel.TLabel", background=t.surface, foreground=t.fg, font=ui) - style.configure("EstopZone.TFrame", background=t.danger_soft, borderwidth=1, relief="solid") - style.configure("EstopZoneBody.TFrame", background=t.danger_soft) - style.configure("EstopZone.TLabel", background=t.danger_soft, foreground=t.danger, font=(UI_FONT, 8)) - - # 图表空状态覆盖层 - style.configure("Overlay.TLabel", background=t.surface, foreground=t.fg_muted, font=(UI_FONT, 11)) - - # 选项卡 / 表格 - style.configure("TNotebook.Tab", padding=(14, 7), font=ui) - style.configure("Treeview", rowheight=26) - style.configure("Treeview.Heading", font=(UI_FONT, UI_SIZE - 1, "bold")) - - -# ── 应用主题 ───────────────────────────────────────────────── - - -def apply_theme(mode: str, plots: list | None = None) -> str: - """应用主题,返回实际生效的 key ('light'|'dark')。 - - Args: - mode: 'light' | 'dark' | 'system' - plots: _BlitPlot 实例列表,用于更新 matplotlib 配色 - """ - global _current_key - theme = resolve_theme(mode) - _current_key = theme.key - - style = ttkbootstrap.Style.get_instance() - if style is not None: - style.theme_use(theme.ttkb_theme) - _configure_styles(style, theme.tokens) - - if plots: - _apply_plot_colors(theme.tokens, plots) - - for cb in tuple(_subs): - try: - cb() - except Exception: - import traceback - - traceback.print_exc() - - return theme.key - - -def _apply_plot_colors(t: Tokens, plots: list) -> None: - """更新所有 matplotlib 图表的背景/文本配色。""" - for pw in plots: - if pw is None: - continue - ax = getattr(pw, "_ax", None) - fig = getattr(pw, "_fig", None) - if ax is not None: - ax.set_facecolor(t.surface) - ax.tick_params(colors=t.fg_muted) - for spine in ax.spines.values(): - spine.set_edgecolor(t.border) - ax.xaxis.label.set_color(t.fg_muted) - ax.yaxis.label.set_color(t.fg_muted) - ax.title.set_color(t.fg) - if fig is not None: - fig.set_facecolor(t.surface) - if hasattr(pw, "_request_full_redraw"): - pw._request_full_redraw() - if hasattr(pw, "refresh"): - pw.refresh() - - -def subscribe(cb: Callable[[], None]) -> None: - """订阅主题变化(切换完成后回调,用于更新语义色)。""" - if cb not in _subs: - _subs.append(cb) - - -def unsubscribe(cb: Callable[[], None]) -> None: - if cb in _subs: - _subs.remove(cb) - - -__all__ = [ - "DARK", - "LIGHT", - "MODES", - "MONO_FONT", - "THEMES", - "UI_FONT", - "UI_SIZE", - "Tokens", - "_setup_matplotlib_fonts", - "apply_theme", - "current_tokens", - "resolve_theme", - "subscribe", - "unsubscribe", -] diff --git a/TController/src/gui/widgets.py b/TController/src/gui/widgets.py deleted file mode 100644 index 80e6e8c..0000000 --- a/TController/src/gui/widgets.py +++ /dev/null @@ -1,485 +0,0 @@ -""" -自定义交互组件 — 阶段指示器 / 滴定度规 / 状态点 / 工具提示 / 消息条 / 工作流引导。 - -所有组件均为主题与语言感知(通过 themes.subscribe / i18n.subscribe)。 -""" - -from __future__ import annotations - -import tkinter as tk -from collections.abc import Callable -from typing import ClassVar - -import ttkbootstrap as ttk - -from gui import i18n, themes - - -def _mix(hex_a: str, hex_b: str, f: float) -> str: - """线性混合两个 hex 颜色,f=0 → a,f=1 → b。""" - a = hex_a.lstrip("#") - b = hex_b.lstrip("#") - ra, ga, ba = (int(a[i : i + 2], 16) for i in (0, 2, 4)) - rb, gb, bb = (int(b[i : i + 2], 16) for i in (0, 2, 4)) - r = int(ra + (rb - ra) * f) - g = int(ga + (gb - ga) * f) - bl = int(ba + (bb - ba) * f) - return f"#{r:02x}{g:02x}{bl:02x}" - - -# ====================================================================== -# 卡片容器(tk.Frame highlight 边框,主题感知) -# ====================================================================== - - -class Card(tk.Frame): - """带 1px 边框的表面卡片。ttk Frame 的 relief/borderwidth 在 - ttkbootstrap 主题引擎下不生效,故用 tk.Frame 的 highlight 描边。""" - - _TONES: ClassVar[dict[str, str]] = { - "surface": "surface", "alt": "surface_alt", "danger": "danger_soft" - } - - def __init__(self, parent: tk.Misc, tone: str = "surface", **kwargs) -> None: - self._tone_key = tone - t = themes.current_tokens() - super().__init__( - parent, - background=self._tone_color(t), - highlightthickness=1, - highlightbackground=t.border, - highlightcolor=t.border, - bd=0, - **kwargs, - ) - themes.subscribe(self._apply_theme) - - def _tone_color(self, t: themes.Tokens) -> str: - return getattr(t, self._TONES[self._tone_key]) - - def _apply_theme(self) -> None: - t = themes.current_tokens() - self.configure( - background=self._tone_color(t), - highlightbackground=t.border, - highlightcolor=t.border, - ) - - -# ====================================================================== -# 状态点(连接指示,激活时呼吸) -# ====================================================================== - - -class StatusDot(tk.Canvas): - """12px 状态点:off / ok(呼吸)/ err。""" - - SIZE = 12 - - def __init__(self, parent: tk.Misc, **kwargs) -> None: - super().__init__( - parent, - width=self.SIZE, - height=self.SIZE, - bd=0, - highlightthickness=0, - **kwargs, - ) - self._state = "off" - self._phase = False - self._job: str | None = None - self._oval = self.create_oval(1, 1, self.SIZE - 1, self.SIZE - 1, fill="", outline="") - themes.subscribe(self._apply_theme) - self._paint() - - def set_state(self, state: str) -> None: - """state: 'off' | 'ok' | 'err'。""" - if state == self._state: - return - self._state = state - if state == "ok" and self._job is None: - self._loop() - elif state != "ok" and self._job is not None: - self.after_cancel(self._job) - self._job = None - self._paint() - - def _loop(self) -> None: - self._phase = not self._phase - self._paint() - self._job = self.after(800, self._loop) - - def _paint(self) -> None: - t = themes.current_tokens() - self.configure(background=t.surface) - if self._state == "ok": - color = t.success if self._phase else _mix(t.success, t.surface, 0.55) - elif self._state == "err": - color = t.danger - else: - color = _mix(t.fg_muted, t.surface, 0.5) - self.itemconfig(self._oval, fill=color, outline="") - - def _apply_theme(self) -> None: - self._paint() - - -# ====================================================================== -# 工具提示 -# ====================================================================== - - -class Tooltip: - """悬停 500ms 后显示的小提示框(支持动态文本回调)。""" - - def __init__(self, widget: tk.Widget, text: str | Callable[[], str]) -> None: - self._w = widget - self._text = text - self._tip: tk.Toplevel | None = None - self._job: str | None = None - widget.bind("", self._schedule, add="+") - widget.bind("", self._hide, add="+") - widget.bind("", self._hide, add="+") - - def _schedule(self, _event: object = None) -> None: - if self._job is not None: - self._w.after_cancel(self._job) - self._job = self._w.after(500, self._show) - - def _show(self) -> None: - self._job = None - text = self._text() if callable(self._text) else self._text - if not text: - return - tip = tk.Toplevel(self._w) - tip.wm_overrideredirect(True) - tip.wm_attributes("-topmost", True) - label = tk.Label( - tip, - text=text, - background="#212529", - foreground="#F8F9FA", - font=(themes.UI_FONT, themes.UI_SIZE - 1), - padx=8, - pady=4, - ) - label.pack() - self._w.update_idletasks() - x = self._w.winfo_rootx() + self._w.winfo_width() // 2 - y = self._w.winfo_rooty() + self._w.winfo_height() + 4 - tip.wm_geometry(f"+{x}+{y}") - self._tip = tip - - def _hide(self, _event: object = None) -> None: - if self._job is not None: - self._w.after_cancel(self._job) - self._job = None - if self._tip is not None: - self._tip.destroy() - self._tip = None - - -# ====================================================================== -# 工作流阶段指示器 -# ====================================================================== - - -class PhaseStepper(tk.Canvas): - """横向阶段指示器:已完成 ✓ / 当前高亮 / 待执行。""" - - HEIGHT = 54 - - def __init__(self, parent: tk.Misc, phases: list[tuple[str, str]], **kwargs) -> None: - super().__init__( - parent, height=self.HEIGHT, bd=0, highlightthickness=0, **kwargs - ) - self._phases = phases - self._active = 0 - self._done_all = False - self._error = False - self.bind("", lambda _e: self._draw()) - i18n.subscribe(self._draw) - themes.subscribe(self._apply_theme) - - def set_phase(self, index: int, error: bool = False, done_all: bool = False) -> None: - if (index, error, done_all) == (self._active, self._error, self._done_all): - return - self._active = index - self._error = error - self._done_all = done_all - self._draw() - - def _apply_theme(self) -> None: - self._draw() - - def _draw(self) -> None: - t = themes.current_tokens() - self.configure(background=t.bg) - self.delete("all") - w = self.winfo_width() - if w < 40: - return - n = len(self._phases) - cy = 19 - x0, x1 = 60, w - 60 - step = (x1 - x0) / max(1, n - 1) - - font_num = (themes.UI_FONT, 8, "bold") - font_lbl = (themes.UI_FONT, themes.UI_SIZE - 1) - font_lbl_on = (themes.UI_FONT, themes.UI_SIZE - 1, "bold") - - # 连接线(左侧步骤已完成则着色) - for i in range(n - 1): - xa = x0 + i * step - xb = x0 + (i + 1) * step - color = t.success if (self._done_all or i < self._active) else t.border - self.create_line(xa + 13, cy, xb - 13, cy, fill=color, width=2) - - for i, (_key, ikey) in enumerate(self._phases): - cx = x0 + i * step - label = i18n.tr(ikey) - if self._done_all or i < self._active: - # 完成 - self.create_oval(cx - 11, cy - 11, cx + 11, cy + 11, fill=t.success, outline=t.success) - self.create_text(cx, cy, text="✓", fill=t.on_filled, font=font_num) - self.create_text(cx, cy + 24, text=label, fill=t.fg_muted, font=font_lbl) - elif i == self._active: - ring = t.danger if self._error else t.primary - self.create_oval(cx - 14, cy - 14, cx + 14, cy + 14, outline=ring, width=2) - self.create_oval(cx - 11, cy - 11, cx + 11, cy + 11, fill=ring, outline=ring) - fg = t.on_filled if self._error else t.on_primary - self.create_text(cx, cy, text=str(i + 1), fill=fg, font=font_num) - self.create_text(cx, cy + 24, text=label, fill=ring, font=font_lbl_on) - else: - self.create_oval(cx - 11, cy - 11, cx + 11, cy + 11, fill=t.surface, outline=t.border, width=1) - self.create_text(cx, cy, text=str(i + 1), fill=t.fg_muted, font=font_num) - self.create_text(cx, cy + 24, text=label, fill=t.fg_muted, font=font_lbl) - - -# ====================================================================== -# 滴定度规(T = 0..2) -# ====================================================================== - - -class TGauge(tk.Canvas): - """滴定度进度规:0 → 2,T=1 标记终点。""" - - HEIGHT = 54 - MAX = 2.0 - - def __init__(self, parent: tk.Misc, width: int = 320, **kwargs) -> None: - super().__init__( - parent, width=width, height=self.HEIGHT, bd=0, highlightthickness=0, **kwargs - ) - self._value: float | None = None - self.bind("", lambda _e: self._draw()) - i18n.subscribe(self._draw) - themes.subscribe(self._apply_theme) - - def set_value(self, value: float | None) -> None: - if value == self._value: - return - self._value = value - self._draw() - - def _apply_theme(self) -> None: - self._draw() - - def _draw(self) -> None: - t = themes.current_tokens() - self.configure(background=t.bg) - self.delete("all") - w = self.winfo_width() or 320 - x0, x1 = 10, w - 86 - cy = 26 - th = 8 - - # 标题 - self.create_text( - x0, 8, anchor="w", text=i18n.tr("gauge.title"), fill=t.fg_muted, - font=(themes.UI_FONT, themes.UI_SIZE - 1), - ) - - # 轨道 - self.create_rectangle(x0, cy - th // 2, x1, cy + th // 2, fill=t.muted, outline="") - - # 填充 - if self._value is not None: - v = max(0.0, min(self.MAX, self._value)) - fx = x0 + (x1 - x0) * v / self.MAX - fill = t.success if v >= self.MAX else t.primary - self.create_rectangle(x0, cy - th // 2, fx, cy + th // 2, fill=fill, outline="") - else: - fx = x0 - - # T=1 标记 - mx = x0 + (x1 - x0) * 0.5 - self.create_line(mx, cy - 9, mx, cy + 9, fill=t.accent, width=2) - self.create_text( - mx, cy - 15, text=i18n.tr("gauge.t1"), fill=t.accent, - font=(themes.UI_FONT, themes.UI_SIZE - 1, "bold"), - ) - - # 刻度 0 / 2 - self.create_text(x0, cy + 14, text="0", fill=t.fg_muted, font=(themes.UI_FONT, 8)) - self.create_text(x1, cy + 14, text="2", fill=t.fg_muted, font=(themes.UI_FONT, 8)) - - # 数值 - if self._value is not None: - text = f"T = {self._value:.2f}" - color = t.success if self._value >= self.MAX else t.fg - else: - text = "T = —" - color = t.fg_muted - self.create_text( - x1 + 40, cy, text=text, fill=color, font=(themes.MONO_FONT, 12, "bold"), - ) - - -# ====================================================================== -# 消息条(严重度着色 + 瞬态自动清除) -# ====================================================================== - - -class MessageBar(ttk.Frame): - """状态栏消息区:图标按严重度着色,非粘性消息 6s 后回退。""" - - GLYPHS: ClassVar[dict[str, str]] = { - "info": "●", "success": "✓", "warn": "!", "error": "✕" - } - _TRANSIENT_MS = 6000 - - def __init__(self, parent: tk.Misc, **kwargs) -> None: - super().__init__(parent, style="Statusbar.TFrame", **kwargs) - self._icon = ttk.Label(self, text="", style="Status.TLabel", width=2) - self._icon.pack(side="left", padx=(0, 6)) - self._text = ttk.Label(self, text="", style="Status.TLabel") - self._text.pack(side="left") - - self._sticky_kind: str | None = None - self._sticky_text = "" - self._job: str | None = None - themes.subscribe(self._repaint_current) - - def _color(self, kind: str) -> str: - t = themes.current_tokens() - return { - "info": t.fg_muted, - "success": t.success, - "warn": t.accent, - "error": t.danger, - }[kind] - - def _paint(self, kind: str, text: str) -> None: - self._icon.config(text=self.GLYPHS[kind], foreground=self._color(kind)) - self._text.config(text=text) - - def _repaint_current(self) -> None: - current = self._text.cget("text") - if not current: - return - kind: str = ( - self._sticky_kind if self._sticky_kind is not None - else getattr(self, "_cur_kind", "info") - ) if self._job is None else getattr(self, "_cur_kind", "info") - self._icon.config(foreground=self._color(kind)) - - def show(self, kind: str, text: str, sticky: bool = False) -> None: - if self._job is not None: - self.after_cancel(self._job) - self._job = None - self._cur_kind = kind - if sticky: - self._sticky_kind = kind - self._sticky_text = text - self._paint(kind, text) - if not sticky: - self._job = self.after(self._TRANSIENT_MS, self._expire) - - def _expire(self) -> None: - self._job = None - if self._sticky_kind is not None: - self._cur_kind = self._sticky_kind - self._paint(self._sticky_kind, self._sticky_text) - else: - self._icon.config(text="") - self._text.config(text="") - - def set_sticky(self, kind: str | None, text: str = "") -> None: - """设置/清除粘性基线消息。""" - self._sticky_kind = kind - self._sticky_text = text - if kind is None and self._job is None: - self._icon.config(text="") - self._text.config(text="") - - -# ====================================================================== -# 工作流引导(校准页步骤条) -# ====================================================================== - - -class WorkflowHint(ttk.Frame): - """编号步骤条:done ✓ / active 高亮 / pending 灰。""" - - def __init__(self, parent: tk.Misc, steps: list[str], **kwargs) -> None: - super().__init__(parent, **kwargs) - self._steps = steps - self._active = 0 - self._dots: list[tk.Canvas] = [] - self._labels: list[ttk.Label] = [] - - for i, key in enumerate(steps): - if i: - ttk.Label(self, text="——", style="Subtle.TLabel").pack( - side="left", padx=4 - ) - dot = tk.Canvas(self, width=18, height=18, bd=0, highlightthickness=0) - dot.pack(side="left") - self._dots.append(dot) - lbl = ttk.Label(self, text=i18n.tr(key), style="Subtle.TLabel") - lbl.pack(side="left", padx=(4, 0)) - self._labels.append(lbl) - - i18n.subscribe(self._apply_i18n) - themes.subscribe(self._paint) - - def _apply_i18n(self) -> None: - for lbl, key in zip(self._labels, self._steps): - lbl.config(text=i18n.tr(key)) - self._paint() - - def set_active(self, idx: int) -> None: - if idx == self._active: - return - self._active = idx - self._paint() - - def _paint(self) -> None: - t = themes.current_tokens() - font = (themes.UI_FONT, 8, "bold") - for i, dot in enumerate(self._dots): - dot.configure(background=t.surface) - dot.delete("all") - if i < self._active: - dot.create_oval(1, 1, 17, 17, fill=t.success, outline=t.success) - dot.create_text(9, 9, text="✓", fill=t.on_filled, font=font) - elif i == self._active: - dot.create_oval(1, 1, 17, 17, fill=t.primary, outline=t.primary) - dot.create_text(9, 9, text=str(i + 1), fill=t.on_primary, font=font) - else: - dot.create_oval(1, 1, 17, 17, fill=t.surface, outline=t.border) - dot.create_text(9, 9, text=str(i + 1), fill=t.fg_muted, font=font) - style = "Muted.TLabel" if i != self._active else "Section.TLabel" - self._labels[i].config(style=style if i == self._active else "Subtle.TLabel") - - -__all__ = [ - "Card", - "MessageBar", - "PhaseStepper", - "StatusDot", - "TGauge", - "Tooltip", - "WorkflowHint", -] diff --git a/TController/src/main.py b/TController/src/main.py deleted file mode 100644 index 6ad7fe7..0000000 --- a/TController/src/main.py +++ /dev/null @@ -1,53 +0,0 @@ -"""AutoTitrator 控制器 — 入口(ttkbootstrap)。""" - -import sys - -import ttkbootstrap -from gui import i18n -from gui.main_window import MainWindow -from gui.settings import load_settings -from gui.themes import _setup_matplotlib_fonts, resolve_theme - - -def main() -> None: - # 配置 matplotlib 中文字体(解决 CJK Glyph missing 警告) - _setup_matplotlib_fonts() - - settings = load_settings() - i18n.set_language(settings["language"]) - theme_mode = settings["theme_mode"] - theme = resolve_theme(theme_mode) - - root = ttkbootstrap.Window(themename=theme.ttkb_theme) - # Win10 的 DWM 只在窗口映射时读取标题栏暗色属性:先隐藏, - # 待 apply_theme 设置完成后再显示,避免启动时标题栏闪白 - root.withdraw() - root.title(i18n.tr("app.title")) - root.geometry("1360x920") - root.minsize(1120, 760) - - mw = MainWindow(root, theme_mode=theme_mode) - mw.pack(fill="both", expand=True) - # 应用自定义样式与图表配色(theme_use 之后才能配置样式) - from gui.themes import apply_theme - - apply_theme(theme_mode, plots=mw._theme_plots()) - root.deiconify() - - if settings["record"] is False: - mw._rec_var.set(False) - mw._recording = False - if isinstance(settings.get("baud"), int): - mw._baud_cb.set(str(settings["baud"])) - - def _on_close() -> None: - mw.on_close() - root.destroy() - sys.exit(0) - - root.protocol("WM_DELETE_WINDOW", _on_close) - root.mainloop() - - -if __name__ == "__main__": - main() diff --git a/TController/tests/test_data_recording.py b/TController/tests/test_data_recording.py deleted file mode 100644 index 909729f..0000000 --- a/TController/tests/test_data_recording.py +++ /dev/null @@ -1,142 +0,0 @@ -"""测试数据记录逻辑:验证进样和滴定阶段数据的完整性""" - -import unittest - - -class TestDataRecording(unittest.TestCase): - """验证上位机数据记录在进样和滴定阶段的正确性""" - - def test_injection_data_preserved_after_pump_done(self): - """验证进样完成后电位数据不被清空""" - # 模拟数据记录场景 - rec_potential = [] - - # 模拟进样阶段记录数据 - for i in range(10): - t = i * 0.1 - v = 0.5 + i * 0.01 - vol = i * 0.05 - rec_potential.append((t, v, v * 0.95, vol)) - - self.assertEqual(len(rec_potential), 10, "进样阶段应记录 10 个数据点") - - # 模拟 _potential_widget.reset() —— 只清空显示,不清空记录 - # (这里不操作 rec_potential,因为实际代码中 reset() 不清空记录列表) - - # 验证记录缓冲区未被清空 - self.assertEqual(len(rec_potential), 10, "进样完成后记录缓冲区应保持完整") - - # 模拟滴定阶段追加数据 - for i in range(10, 20): - t = i * 0.1 - v = 0.6 + i * 0.01 - vol = (i - 10) * 0.05 - rec_potential.append((t, v, v * 0.95, vol)) - - self.assertEqual(len(rec_potential), 20, "滴定结束应包含进样和滴定两阶段数据") - - # 验证数据连续性 - self.assertAlmostEqual(rec_potential[9][0], 0.9, places=2, msg="进样最后一帧时间戳") - self.assertAlmostEqual(rec_potential[10][0], 1.0, places=2, msg="滴定第一帧时间戳") - - def test_recording_cleared_on_titration_start(self): - """验证启动滴定时清空上次记录""" - rec_potential = [] - - # 模拟上次滴定残留数据 - for i in range(5): - rec_potential.append((i * 0.1, 0.5, 0.48, i * 0.05)) - - self.assertEqual(len(rec_potential), 5, "上次滴定残留 5 个数据点") - - # 模拟 _start_titration() 中的清空逻辑 - recording = True - if recording: - rec_potential.clear() - - self.assertEqual(len(rec_potential), 0, "启动滴定时应清空记录缓冲区") - - def test_volume_switching_between_pumps(self): - """验证进样和滴定阶段的体积来源切换""" - from enum import Enum - - class TitrationState(Enum): - IDLE = 0 - INJECTING = 1 - TITRATING = 2 - - pump1_volume = 0.0 - pump2_volume = 0.0 - state = TitrationState.INJECTING - - # 进样阶段:使用泵 1 体积 - pump1_volume = 2.5 - vol = pump1_volume if state == TitrationState.INJECTING else pump2_volume - self.assertEqual(vol, 2.5, "进样阶段应使用泵 1 体积") - - # 切换到滴定阶段 - state = TitrationState.TITRATING - pump2_volume = 1.0 - vol = pump1_volume if state == TitrationState.INJECTING else pump2_volume - self.assertEqual(vol, 1.0, "滴定阶段应使用泵 2 体积") - - def test_recording_flag_initialization(self): - """验证记录开关初始化同步""" - # 模拟持久化设置 - saved_settings = {"record": True} - - # 错误的初始化方式(修复前) - recording_old = False - rec_var_old = True # UI 开关 - self.assertNotEqual(recording_old, rec_var_old, "修复前:内部标志与 UI 不一致") - - # 正确的初始化方式(修复后) - saved_rec = saved_settings.get("record", True) - recording_new = saved_rec - rec_var_new = saved_rec - self.assertEqual(recording_new, rec_var_new, "修复后:内部标志与 UI 一致") - self.assertTrue(recording_new, "默认应启用记录") - - -class TestExportedDataStructure(unittest.TestCase): - """验证导出 Excel 中的数据结构""" - - def test_potential_sheet_columns(self): - """验证电位工作表的列结构""" - # 当前列结构 - columns = ["Time (s)", "Raw Voltage (V)", "Filtered Voltage (V)", "Volume (mL)"] - self.assertEqual(len(columns), 4, "电位表应有 4 列") - - # 推荐的增强列结构(可选) - enhanced_columns = [ - "Time (s)", - "Phase", # 新增:Injecting / Titrating - "Raw Voltage (V)", - "Filtered Voltage (V)", - "Pump1 Volume (mL)", # 拆分:进样体积 - "Pump2 Volume (mL)" # 拆分:滴定体积 - ] - self.assertEqual(len(enhanced_columns), 6, "增强电位表应有 6 列") - - def test_phase_identification_by_time(self): - """验证通过时间戳识别阶段""" - # 模拟数据:前 10 帧为进样,后 10 帧为滴定 - rec_potential = [] - for i in range(20): - t = i * 0.1 - phase = "Injecting" if i < 10 else "Titrating" - rec_potential.append((t, 0.5, 0.48, i * 0.05, phase)) - - # 验证阶段标识 - self.assertEqual(rec_potential[5][4], "Injecting", "前半段应为进样") - self.assertEqual(rec_potential[15][4], "Titrating", "后半段应为滴定") - - # 统计各阶段数据点数 - injecting_count = sum(1 for _, _, _, _, p in rec_potential if p == "Injecting") - titrating_count = sum(1 for _, _, _, _, p in rec_potential if p == "Titrating") - self.assertEqual(injecting_count, 10, "进样阶段 10 个数据点") - self.assertEqual(titrating_count, 10, "滴定阶段 10 个数据点") - - -if __name__ == "__main__": - unittest.main() diff --git a/TController/tests/test_endpoint_reliability.py b/TController/tests/test_endpoint_reliability.py deleted file mode 100644 index 2ef3011..0000000 --- a/TController/tests/test_endpoint_reliability.py +++ /dev/null @@ -1,304 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -import numpy as np - -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) - -from DataProcessor.endpoint import EndpointDetector, _ampd_peak_idx -from DataProcessor.online_features import ( - EndpointFusionKF, - SpectralFeatureTracker, - cross_entropy, - cross_entropy_excess, - js_divergence, -) - - -def _baseline_frames(tracker: SpectralFeatureTracker) -> None: - for i in range(1, 9): - tracker.update(i * 0.05, np.ones(4)) - - -def _tracker_for_events(**overrides: float) -> SpectralFeatureTracker: - """Tracker tuned so a weak and a strong excursion both confirm.""" - kwargs: dict[str, float] = { - "js_enter": 0.1, - "js_exit": 0.03, - "baseline_enter": 0.001, - "baseline_frames": 4, - "baseline_max_volume": 0.4, - "confirm_frames": 3, - "min_event_volume": 0.05, - } - kwargs.update(overrides) - return SpectralFeatureTracker(**kwargs) # type: ignore[arg-type] - - -def _feed_excursion( - tracker: SpectralFeatureTracker, volume: float, amplitude: float, recovery: int = 30 -) -> tuple[float, dict]: - """Drive one rise-and-recover excursion; return the new volume and diagnostic.""" - diagnostic: dict = {} - for _ in range(3): - volume += 0.05 - diagnostic = tracker.update(volume, np.array([amplitude, 1.0, 1.0, 1.0])) - for _ in range(recovery): - volume += 0.05 - diagnostic = tracker.update(volume, np.ones(4)) - return volume, diagnostic - - -def test_js_is_symmetric_bounded_and_gain_invariant() -> None: - p = np.array([1.0, 2.0, 4.0, 8.0]) - q = np.array([2.0, 3.0, 5.0, 7.0]) - assert np.isclose(js_divergence(p, q), js_divergence(q, p)) - assert 0.0 <= js_divergence(p, q) <= np.log(2.0) - assert np.isclose(js_divergence(p, q), js_divergence(17.0 * p, 17.0 * q)) - - -def test_tracker_handles_invalid_and_repeated_volume_without_infinity() -> None: - tracker = SpectralFeatureTracker() - first = tracker.update(0.1, np.ones(4)) - repeated = tracker.update(0.1, np.array([2.0, 1.0, 1.0, 1.0])) - invalid = tracker.update(0.1, np.array([1.0, np.nan, 1.0, 1.0])) - assert first["valid_frame"] is True - assert repeated["volume_sync_valid"] is False - assert repeated["repeated_volume_count"] == 1 - assert np.isfinite(repeated["js_local"]) - assert np.isfinite(repeated["js_speed"]) - assert invalid["valid_frame"] is False - assert invalid["data_quality"] == "spectrum_nonfinite" - - -def test_cross_curvature_is_causal() -> None: - prefix = [ - np.array([1.0, 1.0, 2.0, 1.0, 1.0]), - np.array([1.0, 2.0, 2.0, 1.0, 1.0]), - np.array([1.0, 3.0, 2.0, 1.0, 1.0]), - ] - future = [ - np.array([1.0, 4.0, 1.0, 2.0, 1.0]), - np.array([2.0, 1.0, 1.0, 4.0, 1.0]), - ] - left = SpectralFeatureTracker(baseline_frames=3, baseline_max_volume=0.2) - right = SpectralFeatureTracker(baseline_frames=3, baseline_max_volume=0.2) - left_values = [left.update(i * 0.05, frame) for i, frame in enumerate(prefix, 1)] - right_values = [right.update(i * 0.05, frame) for i, frame in enumerate(prefix + future, 1)] - for before, after in zip(left_values, right_values[: len(prefix)]): - assert np.isclose(before["cross_curvature"], after["cross_curvature"]) - assert np.isclose(before["js_local"], after["js_local"]) - assert before["state"] == after["state"] - - -def test_peak_requires_recovery_before_confirmation() -> None: - tracker = SpectralFeatureTracker( - js_enter=0.2, - js_exit=0.03, - baseline_enter=0.001, - baseline_frames=4, - baseline_max_volume=0.4, - confirm_frames=3, - min_event_volume=0.05, - ) - _baseline_frames(tracker) - for volume in (0.45, 0.50, 0.55): - diagnostic = tracker.update(volume, np.array([100.0, 1.0, 1.0, 1.0])) - assert diagnostic["state"] == "IN_CHANGE" - diagnostic = tracker.update(0.60, np.ones(4)) - assert diagnostic["state"] == "IN_CHANGE" - for volume in tuple(np.arange(0.65, 2.05, 0.05)): - diagnostic = tracker.update(float(volume), np.ones(4)) - assert diagnostic["state"] == "END_CONFIRMED" - assert np.isfinite(float(diagnostic["candidate_volume"])) - - -def test_kf_gate_and_repeated_observation_are_stable() -> None: - kf = EndpointFusionKF(potential_std=0.01, spectral_std=0.01, nis_gate=3.84) - initial = kf.observe("potential", 1.0, token="potential-1") - accepted = kf.observe("spectral", 1.03, token="spectral-1") - repeated = kf.observe("spectral", 1.03, token="spectral-1") - rejected = kf.observe("potential", 2.0, token="potential-2") - assert initial["accepted"] is True - assert accepted["accepted"] is True - assert accepted["consistent"] is True - assert repeated == accepted - assert rejected["accepted"] is False - assert rejected["reason"] == "nis_gate" - assert np.isfinite(rejected["endpoint_std"]) - - -def test_detector_keeps_legacy_feed_and_result_keys() -> None: - detector = EndpointDetector(flow_rate=0.0061) - for index in range(1, 240): - volume = index * 0.01 - voltage = 1.0 - 0.8 * np.exp(-((volume - 1.0) ** 2) / (2.0 * 0.035**2)) - detector.feed_potential(volume, volume / 0.0061, voltage) - detector.feed_spectrum(volume, np.array([1.0, 2.0, 3.0, 4.0])) - result = detector.detect() - assert detector.potential_state == "END_CONFIRMED" - assert result is not None - assert {"volume", "time", "confidence", "method", "potential", "spectral"} <= set(result) - assert "reliability" in result - assert result["potential"] is not None - - -def test_detector_reset_retains_spectrum_configuration() -> None: - detector = EndpointDetector( - flow_rate=0.0061, - wavelengths=np.array([400.0, 500.0, 600.0, 700.0]), - ) - detector.feed_spectrum(0.1, np.ones(4)) - detector.reset() - diagnostic = detector.diagnostics() - assert diagnostic["spectral_features"]["sample_count"] == 0 - detector.feed_spectrum(0.1, np.ones(4)) - assert np.isfinite(detector.diagnostics()["spectral_features"]["cross_curvature"]) - - -def test_repeated_volume_holds_speed_instead_of_injecting_zero() -> None: - """Production reuses one pump volume for several spectra; speed must not decay. - - The volume-normalised speed is undefined when the step is zero, so those frames - hold the filter level. Feeding a zero would drag a live excursion below the - exit threshold and fake a recovery. - """ - tracker = SpectralFeatureTracker(baseline_frames=3, baseline_max_volume=0.2) - for index in range(1, 4): - tracker.update(index * 0.05, np.ones(4)) - advancing = tracker.update(0.25, np.array([4.0, 1.0, 1.0, 1.0])) - assert advancing["js_speed_smooth"] > 0.0 - for _ in range(5): - repeated = tracker.update(0.25, np.array([4.0, 1.0, 1.0, 1.0])) - assert repeated["volume_sync_valid"] is False - assert repeated["js_speed_smooth"] == advancing["js_speed_smooth"] - assert repeated["cross_curvature"] == advancing["cross_curvature"] - assert repeated["repeated_volume_count"] == 5 - # A later advancing frame resumes normalisation against the last synced frame. - resumed = tracker.update(0.30, np.array([4.0, 1.0, 1.0, 1.0])) - assert resumed["volume_sync_valid"] is True - - -def test_stronger_late_excursion_supersedes_an_early_transient() -> None: - """Regression for Paper/ExpData group B: the one-shot latch chose a transient.""" - tracker = _tracker_for_events() - _baseline_frames(tracker) - volume, weak = _feed_excursion(tracker, 0.40, 3.0) - assert weak["state"] == "END_CONFIRMED" - weak_candidate = weak["candidate_volume"] - volume, strong = _feed_excursion(tracker, volume, 100.0) - assert strong["state"] == "END_CONFIRMED" - assert strong["candidate_volume"] != weak_candidate - assert strong["candidate_volume"] > weak_candidate - assert strong["event_count"] == 2 - assert strong["superseded_count"] == 1 - assert strong["event_peak_speed"] > weak["event_peak_speed"] - assert tracker.endpoint_volume == strong["candidate_volume"] - assert len(tracker.events) == 2 - - -def test_supersede_ratio_suppresses_a_near_tie() -> None: - """Identical excursions, only the hysteresis differs: the endpoint must not flap.""" - loose = _tracker_for_events(supersede_ratio=1.5) - tight = _tracker_for_events(supersede_ratio=4.0) - outcomes = [] - for tracker in (loose, tight): - _baseline_frames(tracker) - volume, _ = _feed_excursion(tracker, 0.40, 3.0) - _, final = _feed_excursion(tracker, volume, 4.0) - outcomes.append(final) - assert outcomes[0]["superseded_count"] == 1 - assert outcomes[1]["superseded_count"] == 0 - assert outcomes[1]["candidate_volume"] < outcomes[0]["candidate_volume"] - # Both saw the same two excursions; only the reported winner differs. - assert outcomes[0]["event_count"] == outcomes[1]["event_count"] == 2 - - -def test_round_off_scale_divergence_is_not_normalised() -> None: - """js_speed divides by ~1e-8, so a divergence at the round-off floor must stay 0.""" - tracker = SpectralFeatureTracker() - tracker.update(0.05, np.ones(4)) - tiny = tracker.update(0.10, np.array([1.0 + 1e-9, 1.0, 1.0, 1.0])) - assert tiny["volume_sync_valid"] is True - assert tiny["js_speed"] == 0.0 - real = tracker.update(0.15, np.array([2.0, 1.0, 1.0, 1.0])) - assert real["js_speed"] > 0.0 - - -def test_kf_reset_lets_a_revised_endpoint_pair_refuse() -> None: - """A superseded spectral endpoint must be re-fusable, not blocked by dedup.""" - kf = EndpointFusionKF(potential_std=0.01, spectral_std=0.01) - kf.observe("potential", 2.1475, token=("potential", 2.1475)) - stale = kf.observe("spectral", 1.1805, token=("spectral", 1.1805)) - assert stale["accepted"] is False - assert stale["reason"] == "nis_gate" - assert kf.can_fuse is False - kf.reset() - kf.observe("potential", 2.1475, token=("potential", 2.1475)) - revised = kf.observe("spectral", 2.1489, token=("spectral", 2.1489)) - assert revised["accepted"] is True - assert revised["consistent"] is True - assert kf.can_fuse is True - - -def test_legacy_cross_entropy_mode_confirms_an_endpoint() -> None: - """cross_entropy(p, p) is the entropy of p, so the raw value never exits.""" - identical = np.array([1.0, 2.0, 3.0, 4.0]) - assert cross_entropy(identical, identical) > 1.0 - assert cross_entropy_excess(identical, identical) == 0.0 - assert cross_entropy_excess(identical, np.array([4.0, 3.0, 2.0, 1.0])) > 0.0 - - candidates = {} - for use_jsd in (True, False): - detector = EndpointDetector(flow_rate=0.0061, use_jsd=use_jsd) - for index in range(1, 300): - volume = index * 0.01 - voltage = 1.0 - 0.8 * np.exp(-((volume - 1.0) ** 2) / (2.0 * 0.035**2)) - detector.feed_potential(volume, volume / 0.0061, voltage) - amplitude = 1.0 + 60.0 * np.exp(-((volume - 1.0) ** 2) / (2.0 * 0.03**2)) - detector.feed_spectrum(volume, np.array([amplitude, 1.0, 1.0, 1.0])) - assert detector.spectral_state == "END_CONFIRMED" - candidates[use_jsd] = detector.diagnostics()["spectral_features"]["candidate_volume"] - assert candidates[False] == candidates[True] - - -def _ampd_reference(signal: np.ndarray) -> int | None: - """Dense O(N^2) AMPD, kept as the oracle for the reduced implementation.""" - sig = np.asarray(signal, dtype=np.float64) - N = sig.size - if N < 12: - return None - L = N // 2 - 1 - if L < 2: - return None - lms = np.zeros((L, N), dtype=np.int64) - for k in range(1, L + 1): - for i in range(k, N - k): - if sig[i] > sig[i - k] and sig[i] > sig[i + k]: - lms[k - 1, i] = 1 - sigma = int(np.argmin(lms.sum(axis=1))) - score = lms[sigma:].sum(axis=0) - best = int(np.argmax(score)) - return best if score[best] > 0 else None - - -def test_ampd_reduction_matches_the_dense_reference() -> None: - rng = np.random.default_rng(20260821) - cases = [ - np.zeros(40), - np.ones(40), - np.arange(40, dtype=np.float64), - np.arange(40, 0, -1, dtype=np.float64), - np.array([1.0, 2.0, 1.0]), - np.linspace(0.0, 1.0, 12), - ] - for length in (13, 25, 64, 137): - cases.append(rng.normal(size=length)) - peak = np.exp( - -((np.arange(length) - 0.6 * length) ** 2) / (2.0 * (length / 12.0) ** 2) - ) - cases.append(peak + 0.05 * rng.normal(size=length)) - for case in cases: - assert _ampd_peak_idx(case) == _ampd_reference(case) diff --git a/TController/tests/test_protocol.py b/TController/tests/test_protocol.py deleted file mode 100644 index 508cf8b..0000000 --- a/TController/tests/test_protocol.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parents[1] / "src")) - -from Communication.protocol import ( - ProtocolHandler, - _crc8, - _UplinkParser, -) - - -def test_crc_and_uplink_parser() -> None: - payload = bytes(range(11)) - frame = b"\xaa\x55\x20" + payload + bytes([_crc8(b"\x20" + payload)]) - assert _UplinkParser().feed(frame) == [(0x20, payload)] - - -def test_uplink_parser_resynchronizes_after_repeated_preamble() -> None: - payload = b"\x01" - frame = b"\xbb\xbb\xaa\x55\x00" + payload - frame += bytes([_crc8(b"\x00" + payload)]) - assert _UplinkParser().feed(frame) == [(0x00, payload)] - - -def test_ack_requires_matching_pending_command() -> None: - handler = ProtocolHandler() - handler._pending_cmd = b"pending" - handler._pending_cmd_id = 0x02 - handler._on_ack(0x05) - assert handler._pending_cmd == b"pending" - handler._on_ack(0x02) - assert handler._pending_cmd is None - - -def test_send_heartbeat_does_not_overwrite_pending_command() -> None: - handler = ProtocolHandler() - pending = b"pending" - handler._pending_cmd = pending - handler._pending_cmd_id = 0x02 - handler._reader.write = lambda _data: None - - handler.send_heartbeat() - - assert handler._pending_cmd == pending - assert handler._pending_cmd_id == 0x02 - - -def test_send_cmd_rejects_invalid_parameter_length() -> None: - handler = ProtocolHandler() - try: - handler.send_cmd(0x02, b"") - except ValueError: - pass - else: - raise AssertionError("invalid command parameters were accepted") From 4925633f953ff3a60afde5a10f40ec418c28e4dc Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 19:31:26 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E4=B8=8A=E4=BD=8D?= =?UTF-8?q?=E6=9C=BA)=EF=BC=9A=E6=9B=B4=E6=96=B0=20TController=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 73 ++++++++++++++++++++++++++----------------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8360962..d01844a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AutoTitrator-Free -多模态自动滴定控制器 —— STM32F103 裸机固件 + Python 上位机。 +多模态自动滴定控制器 —— STM32F103 裸机固件 + Rust/Tauri 上位机。 ## 项目概览 @@ -13,7 +13,7 @@ | 语言标准 | C++23,裸机,无 HAL / 无 RTOS / 无堆 | | 构建系统 | SCons | | 调试器 | ST-Link V2 (SWD) | -| 上位机 | Python 3.12+,ttkbootstrap + matplotlib | +| 上位机 | Rust/Tauri 2 + Next.js | | 授权 | [PolyForm Shield 1.0.0](LICENSE) | ## 目录结构 @@ -35,22 +35,16 @@ AutoTitrator-Free │ ├── hal/ # 外设 HAL 驱动 │ ├── device/ # 设备级驱动 │ └── protocol/ # 通信协议栈 -├── TController/ # Python 上位机 -│ ├── src/ -│ │ ├── main.py # GUI 入口 -│ │ ├── Communication/ # 串口通信与协议解析 -│ │ ├── DataProcessor/ # 终点检测、光谱重建、泵校准 -│ │ └── gui/ # ttkbootstrap UI 与 matplotlib 绘图 -│ ├── data/ # 校准数据 -│ └── scripts/ # 离线验证脚本 +├── TController/ # Rust/Tauri 上位机 +│ ├── crates/controller-core/ # 协议、检测、重建与工作流 +│ ├── app/src-tauri/ # Tauri 命令与后端状态 +│ ├── app/ui-next/ # Next.js 仪器工作台 +│ └── data/ # calibre.npz 与运行时状态 ├── scripts/ # 代码生成脚本 │ └── generate_stm32f103.py # 从 CMSIS-SVD 生成外设头文件 -├── requirements.txt # 上位机运行时依赖 -├── requirements-dev.txt # 开发/构建依赖 +├── requirements-dev.txt # 固件构建与寄存器生成依赖 ├── openocd.cfg # OpenOCD 调试配置 ├── .gdbinit # GDB 初始化脚本 -├── pyrightconfig.json # Python 类型检查配置 -├── ruff.toml # Python lint 配置 ├── README.md └── LICENSE ``` @@ -143,60 +137,61 @@ scons -c ```sh # Terminal 1 — 启动 OpenOCD -cd D:/Projects/AutoTitrator-Free +cd D:/Projects/AutoTitrator/Firmware openocd -f openocd.cfg # Terminal 2 — 连接 GDB arm-none-eabi-gdb build/AutoTitrator-Firmware.elf -x .gdbinit ``` -### 上位机运行 +### 寄存器生成工具 -```sh -uv pip install -r requirements.txt -uv run python TController/src/main.py -``` - -开发依赖(含 SCons、ruff、pyright)使用: +寄存器头文件生成脚本需要 `cmsis-svd`,开发依赖使用: ```sh uv pip install -r requirements-dev.txt +uv run scripts/generate_stm32f103.py ``` ## 上位机(TController) -Python 上位机通过串口与 MCU 通信,提供: +Rust/Tauri 上位机通过串口与 MCU 通信,提供: - 实时光谱曲线与电位曲线 - 在线滴定终点检测 - 双泵控制与进度显示 - 泵校准与 pH 电极校准 -- 数据记录与 Excel 导出 +- 状态持久化、运行历史和可靠性诊断 ### 主要模块 | 目录 | 功能 | |------|------| -| `TController/src/Communication/` | 串口后台线程、协议帧解析、事件队列 | -| `TController/src/DataProcessor/` | 终点检测(EWMA + 自适应阈值 + AMPD)、光谱重建、泵校准 | -| `TController/src/gui/` | ttkbootstrap 主窗口、绘图控件、校准/维护标签页 | -| `TController/scripts/` | 离线验证与实时回放脚本 | +| `TController/crates/controller-core/src/protocol/` | 串口线程、协议帧解析与重试 | +| `TController/crates/controller-core/src/processing/` | 终点检测、光谱重建、泵校准 | +| `TController/crates/controller-core/src/workflow.rs` | 滴定工作流与泵控状态机 | +| `TController/app/src-tauri/` | 后端状态快照、命令和持久化 | +| `TController/app/ui-next/` | Next.js 仪器工作台 | ### 技术栈 -| 组件 | 库 | 授权 | -|------|-----|------| -| UI 框架 | ttkbootstrap | MIT | -| 绘图 | matplotlib(blit 加速) | PSF/BSD | -| 数值计算 | numpy | BSD-3 | -| 串口通信 | pyserial | BSD-3 | -| 数据导出 | openpyxl | MIT | +| 组件 | 技术 | +|------|------| +| UI 框架 | Tauri 2 + Next.js | +| 状态管理 | Rust backend snapshot + Zustand 视图缓存 | +| 数值计算 | Rust ndarray / ndarray-npy | +| 串口通信 | Rust serialport | + +### 运行方式 -### 线程模型 +```sh +cd TController +cargo test --workspace +npm --prefix app/ui-next install +npm --prefix app/ui-next run build +``` -- 串口读取:`threading.Thread` + `queue.Queue` -- GUI 轮询:`root.after()` 递归调度,约 80 ms 刷新绘图 -- 通信事件:`ProtocolHandler.poll()` 排空队列并分发回调 +开发模式下由 Tauri 加载 `app/ui-next/out`;浏览器直接访问 Next 开发服务器时使用显式 mock adapter,真实 Tauri 环境始终以 Rust backend snapshot 为状态源。 ## 注意事项 From 0e051ddd59ee52230833b687285b55f05385b045 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 19:31:38 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E6=B8=85=E7=90=86(Python)=EF=BC=9A?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A7=E4=B8=8A=E4=BD=8D=E6=9C=BA=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 13 +++++++++++-- pyrightconfig.json | 29 ----------------------------- requirements-dev.txt | 8 ++------ requirements.txt | 8 -------- ruff.toml | 10 ---------- 5 files changed, 13 insertions(+), 55 deletions(-) delete mode 100644 pyrightconfig.json delete mode 100644 requirements.txt delete mode 100644 ruff.toml diff --git a/.gitignore b/.gitignore index cc3f2bb..7047cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Python +# Python tooling (firmware register generator) __pycache__/ *.py[cod] *$py.class @@ -36,7 +36,10 @@ CLAUDE.md continue.config.json .agents/ .zcode/ +.qoder/ +embedded-dev-skills/ docs/MoonEmbedded/ +skills-lock.json # Embedded build artifacts *.o @@ -50,9 +53,15 @@ build/ *.log tmp/ temp/ +tmp_diff/ -# 用户运行时偏好(含机器相关的 last_port 等) +# 上位机运行时状态(含机器相关的串口和历史记录) TController/data/settings.json +TController/data/pump2_calibration.json +TController/app/ui-next/node_modules/ +TController/app/ui-next/.next/ +TController/app/ui-next/out/ +TController/app/ui-next/.hallmark/ # SCons .sconsign.dblite diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index eb97701..0000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "include": ["TController/src", "TController/scripts"], - "pythonVersion": "3.12", - "venvPath": ".", - "venv": ".venv", - "typeCheckingMode": "standard", - "reportMissingTypeStubs": false, - "executionEnvironments": [ - { - "root": "TController/src/gui", - "extraPaths": ["TController/src"], - "reportOptionalMemberAccess": "none", - "reportAttributeAccessIssue": "none", - "reportSelfClsParameterName": "none", - "reportCallIssue": "none" - }, - { - "root": "TController/src", - "extraPaths": ["TController/src"] - }, - { - "root": "TController/scripts", - "extraPaths": ["TController/src"] - }, - { - "root": "TController" - } - ] -} diff --git a/requirements-dev.txt b/requirements-dev.txt index b3b006e..dbb23bb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,4 @@ -# 开发/构建/验证依赖 -# 含运行时依赖 + MCU 固件构建 + 离线验证脚本所需 - --r requirements.txt +# 固件构建与寄存器生成依赖 scons>=4.8,<5 # MCU 固件构建 (SConstruct) -ruff>=0.6,<1 # Python linter & formatter -pyright>=1.1,<2 # Python 静态类型检查 +cmsis-svd>=0.6,<1 # CMSIS-SVD 外设头文件生成 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 178baf4..0000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -# TController 运行时依赖 -# Python >=3.12 - -ttkbootstrap>=1.10,<2 # tkinter 现代主题 UI 库 (MIT) -matplotlib>=3.8,<4 # 实时绘图 (spectrum/potential/calibration, blit 加速) -numpy>=1.26,<3 # 数值计算与光谱重建 -openpyxl>=3.1,<4 # xlsx 数据导出 -pyserial>=3.5,<4 # 串口通信 (import serial) diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index df7812b..0000000 --- a/ruff.toml +++ /dev/null @@ -1,10 +0,0 @@ -# Ruff 配置 — Python linter & formatter -# 仅用于 TController/ 下的 Python 代码 - -[lint] -# 忽略不适用于本项目的规则 -ignore = [ - "N999", # 模块名大写开头(Communication/DataProcessor 是项目命名约定) - "S110", # try/except/pass(GUI 串口/IO 兜底场景) - "BLE001", # 裸 except Exception(同上,防御性捕获) -] From 5d8934deb3fdd207685900a8001bbbcca3b0088a Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 19:44:42 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(Tauri)=EF=BC=9A?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=9E=84=E5=BB=BA=20Next.js=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++----- TController/README.md | 21 ++++++++++++++++++--- TController/app/src-tauri/tauri.conf.json | 3 +++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d01844a..0f5d4c4 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,12 @@ Rust/Tauri 上位机通过串口与 MCU 通信,提供: ### 运行方式 ```sh -cd TController -cargo test --workspace -npm --prefix app/ui-next install -npm --prefix app/ui-next run build +cd TController/app/src-tauri +cargo tauri dev # 自动启动 Next.js 开发服务器 +cargo tauri build # 自动执行 Next.js 静态导出并打包 Tauri 应用 ``` -开发模式下由 Tauri 加载 `app/ui-next/out`;浏览器直接访问 Next 开发服务器时使用显式 mock adapter,真实 Tauri 环境始终以 Rust backend snapshot 为状态源。 +单独验证前端时,可在 `TController/app/ui-next` 下运行 `npm run build` 或 `npm run lint`。浏览器直接访问 Next 开发服务器时使用显式 mock adapter,真实 Tauri 环境始终以 Rust backend snapshot 为状态源。 ## 注意事项 diff --git a/TController/README.md b/TController/README.md index f5880fa..e74645d 100644 --- a/TController/README.md +++ b/TController/README.md @@ -42,10 +42,25 @@ AMPD 精修在短记录(大尺度不覆盖尾部峰)时返回 `None`;savgo ## 使用 ```bash -cargo test -p controller-core +cd app/src-tauri +cargo tauri dev # 自动启动 Next.js 开发服务器 +cargo tauri build # 自动执行 Next.js 静态导出并打包 Tauri 应用 +``` + +单独运行后端测试: + +```bash +cargo test --workspace cargo check --workspace -npm --prefix app/ui-next run build -npm --prefix app/ui-next run lint +``` + +单独验证前端: + +```bash +cd app/ui-next +npm install +npm run build +npm run lint ``` `calibre.npz` 探测顺序:环境变量 `AUTOTITRATOR_CALIBRE` → exe 同级 → diff --git a/TController/app/src-tauri/tauri.conf.json b/TController/app/src-tauri/tauri.conf.json index aa7f1d5..b5c3ec7 100644 --- a/TController/app/src-tauri/tauri.conf.json +++ b/TController/app/src-tauri/tauri.conf.json @@ -4,6 +4,9 @@ "version": "0.1.0", "identifier": "com.autotitrator.tcontroller", "build": { + "beforeDevCommand": "npm --prefix ui-next run dev", + "beforeBuildCommand": "npm --prefix ui-next run build", + "devUrl": "http://localhost:3000", "frontendDist": "../ui-next/out" }, "app": { From e7b3e3845d513d3bb3689e8c7ed870715b5047d1 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Tue, 25 Aug 2026 22:34:36 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E6=94=B9=E8=BF=9B(=E4=B8=8A=E4=BD=8D?= =?UTF-8?q?=E6=9C=BA)=EF=BC=9A=E6=98=BE=E7=A4=BA=E8=BF=9B=E6=A0=B7?= =?UTF-8?q?=E8=83=8C=E6=99=AF=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TController/app/src-tauri/tauri.conf.json | 10 +++- .../app/ui-next/components/app-shell.tsx | 2 +- .../components/pages/titration-page.tsx | 48 ++++++++++++++++--- TController/app/ui-next/lib/mock/simulator.ts | 26 ++++++++-- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/TController/app/src-tauri/tauri.conf.json b/TController/app/src-tauri/tauri.conf.json index b5c3ec7..27a96d6 100644 --- a/TController/app/src-tauri/tauri.conf.json +++ b/TController/app/src-tauri/tauri.conf.json @@ -4,8 +4,14 @@ "version": "0.1.0", "identifier": "com.autotitrator.tcontroller", "build": { - "beforeDevCommand": "npm --prefix ui-next run dev", - "beforeBuildCommand": "npm --prefix ui-next run build", + "beforeDevCommand": { + "script": "npm run dev", + "cwd": "../ui-next" + }, + "beforeBuildCommand": { + "script": "npm run build", + "cwd": "../ui-next" + }, "devUrl": "http://localhost:3000", "frontendDist": "../ui-next/out" }, diff --git a/TController/app/ui-next/components/app-shell.tsx b/TController/app/ui-next/components/app-shell.tsx index bb7336f..229a187 100644 --- a/TController/app/ui-next/components/app-shell.tsx +++ b/TController/app/ui-next/components/app-shell.tsx @@ -169,7 +169,7 @@ function ToolBar() { return (
- diff --git a/TController/app/ui-next/components/pages/titration-page.tsx b/TController/app/ui-next/components/pages/titration-page.tsx index 61c234a..1e862b2 100644 --- a/TController/app/ui-next/components/pages/titration-page.tsx +++ b/TController/app/ui-next/components/pages/titration-page.tsx @@ -28,8 +28,16 @@ function Stepper() { const t = useT(); const workflow = useStore((s) => s.workflow); const tubingOp = useStore((s) => s.tubingOp); + const sampleInput = useStore((s) => s.sampleInput); + const pump1Steps = useStore((s) => s.pump1Steps); + const pumpSlope = useStore((s) => s.pumpSlope); + const pumpIntercept = useStore((s) => s.pumpIntercept); const errIdx = workflow === "error" ? FLOW.indexOf("titrating") : -1; const activeIdx = workflow === "error" ? 1 : workflow === "idle" || tubingOp ? -1 : FLOW.indexOf(workflow); + const injectionVolume = pumpSlope > 0 ? Math.max(0, pump1Steps / pumpSlope + pumpIntercept) : null; + const injectionProgress = injectionVolume !== null && sampleInput > 0 + ? Math.min(100, Math.max(0, (injectionVolume / sampleInput) * 100)) + : null; return (
    @@ -37,23 +45,49 @@ function Stepper() { const done = activeIdx > i || workflow === "done"; const active = activeIdx === i && workflow !== "done"; const isErr = workflow === "error" && i === errIdx; + const injection = i === 0; return (
  1. - {i + 1} - {done && } - {isErr && } - {t(`state.${s}`)} - {i < FLOW.length - 1 && ( - + {injection && injectionProgress !== null && ( +
  2. ); diff --git a/TController/app/ui-next/lib/mock/simulator.ts b/TController/app/ui-next/lib/mock/simulator.ts index 8a643b5..00571c7 100644 --- a/TController/app/ui-next/lib/mock/simulator.ts +++ b/TController/app/ui-next/lib/mock/simulator.ts @@ -58,6 +58,7 @@ function log(level: "info" | "ok" | "warn" | "error", key: Parameters | null = null; let heartbeatTimer: ReturnType | null = null; let elapsedTimer: ReturnType | null = null; +let injectionTimer: ReturnType | null = null; let phaseTimer: ReturnType | null = null; let cfg: ScenarioCfg = SCENARIOS.normal; @@ -69,9 +70,10 @@ let runStartWall = 0; function clearTimers() { if (tickTimer) clearInterval(tickTimer); + if (injectionTimer) clearInterval(injectionTimer); if (phaseTimer) clearTimeout(phaseTimer); if (elapsedTimer) clearInterval(elapsedTimer); - tickTimer = phaseTimer = elapsedTimer = null; + tickTimer = injectionTimer = elapsedTimer = phaseTimer = null; } function speed() { @@ -316,13 +318,31 @@ export const backend = { degree1Ticks = 0; runStartWall = Date.now(); const sample = st.sampleInput; - useStore.setState({ workflow: "injecting", sampleVolume: sample, pump1Running: true }); + const injectionDuration = 2600 / speed(); + const injectionTargetSteps = pumpCal.slopeMlPerStep > 0 + ? Math.max(0, Math.round((sample - pumpCal.intercept) / pumpCal.slopeMlPerStep)) + : 0; + const injectionStarted = Date.now(); + useStore.setState({ workflow: "injecting", sampleVolume: sample, pump1Running: true, pump1Steps: 0 }); + injectionTimer = setInterval(() => { + const elapsed = Date.now() - injectionStarted; + const progress = Math.min(1, elapsed / injectionDuration); + useStore.setState({ pump1Steps: Math.round(injectionTargetSteps * progress) }); + if (progress >= 1 && injectionTimer) { + clearInterval(injectionTimer); + injectionTimer = null; + } + }, 50); log("info", "log.inject", { v: sample.toFixed(1) }); const { lang } = useStore.getState(); toast.info(translate(lang, "toast.runStarted", { v: sample.toFixed(1) })); phaseTimer = setTimeout(() => { - useStore.setState({ workflow: "titrating", pump1Running: false, pump2Running: true }); + if (injectionTimer) { + clearInterval(injectionTimer); + injectionTimer = null; + } + useStore.setState({ workflow: "titrating", pump1Running: false, pump1Steps: injectionTargetSteps, pump2Running: true }); log("info", "log.injectDone"); tickTimer = setInterval(titrationTick, BASE_TICK_MS / speed()); elapsedTimer = setInterval(() => { From 5f29d248d54343cea96eb45392dc86ae8cefc6b3 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 00:14:58 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E6=94=B9=E8=BF=9B(=E4=B8=8A=E4=BD=8D?= =?UTF-8?q?=E6=9C=BA)=EF=BC=9A=E6=B5=93=E5=BA=A6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E4=B8=8E=E8=A7=86=E8=A7=89=E6=9F=94=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶栏新增滴定剂浓度与计量比 a∶b 输入,终点后自动计算分析物浓度 c = c滴定剂 × V终点 × (a/b) ÷ V样品,浓度行附带终点置信度徽标, 计算式收进悬浮提示 - 移除工作台事件日志卡片:结果面板独占右列整高,一屏放完不滚动; T=1 初判在最终结果出来后折叠为摘要行 - 日志分流:仅泵手动操作入 store(维护页动作记录),其余事件走 console - 深色主题柔化为暖灰纸面:背景抬离纯黑并带微量暖度、前景柔白、 边框与图表网格减淡、状态色降饱和;嵌套输入簇改透明底消除内陷黑块 - 图表修复:dE/dV 右轴刻度按步长自适应小数位(不再出现重复标签), 双终点标记标签向外侧排布防重叠,标定散点图 X 轴末端刻度不再被裁 - 数据记录页空状态图标与文字组合居中 - 修复(mock):callMock 丢失 this 导致 disconnect 抛异常卡死运行 --- TController/app/ui-next/app/globals.css | 85 ++++++++-------- .../app/ui-next/components/app-shell.tsx | 51 +++++++++- .../components/charts/potential-chart.tsx | 28 ++++-- .../components/pages/calibration-page.tsx | 6 +- .../ui-next/components/pages/history-page.tsx | 8 +- .../components/pages/settings-page.tsx | 2 +- .../components/pages/titration-page.tsx | 96 +++++++++---------- TController/app/ui-next/lib/backend.ts | 2 +- TController/app/ui-next/lib/i18n.ts | 4 + TController/app/ui-next/lib/mock/simulator.ts | 6 +- TController/app/ui-next/lib/store.ts | 39 +++++++- 11 files changed, 215 insertions(+), 112 deletions(-) diff --git a/TController/app/ui-next/app/globals.css b/TController/app/ui-next/app/globals.css index 93f39ab..4888bf4 100644 --- a/TController/app/ui-next/app/globals.css +++ b/TController/app/ui-next/app/globals.css @@ -95,47 +95,49 @@ --chart-well: oklch(0.98 0 0); } -/* ===== 灰阶主题(neutral)—— 深色 ===== */ +/* ===== 灰阶主题(neutral)—— 深色 ===== + 柔化处理:背景抬离纯黑并带微量暖度,前景降至柔白, + 边框/网格减淡,状态色降饱和——整体从「硬黑」转为「暖灰纸面」。 */ .dark { - --background: oklch(0.105 0 0); - --foreground: oklch(0.97 0 0); - --card: oklch(0.152 0 0); - --card-foreground: oklch(0.97 0 0); - --popover: oklch(0.165 0 0); - --popover-foreground: oklch(0.97 0 0); - --primary: oklch(0.94 0 0); - --primary-foreground: oklch(0.16 0 0); - --secondary: oklch(0.22 0 0); - --secondary-foreground: oklch(0.97 0 0); - --muted: oklch(0.22 0 0); - --muted-foreground: oklch(0.70 0 0); - --accent: oklch(0.22 0 0); - --accent-foreground: oklch(0.97 0 0); - --destructive: oklch(0.68 0.19 22); - --border: oklch(1 0 0 / 14%); - --input: oklch(1 0 0 / 16%); - --ring: oklch(0.72 0 0); - --chart-1: oklch(0.95 0 0); - --chart-2: oklch(0.78 0 0); - --chart-3: oklch(0.62 0 0); - --chart-4: oklch(0.48 0 0); - --chart-5: oklch(0.36 0 0); - --sidebar: oklch(0.12 0 0); - --sidebar-foreground: oklch(0.97 0 0); - --sidebar-primary: oklch(0.94 0 0); - --sidebar-primary-foreground: oklch(0.16 0 0); - --sidebar-accent: oklch(0.20 0 0); - --sidebar-accent-foreground: oklch(0.97 0 0); - --sidebar-border: oklch(1 0 0 / 12%); - --sidebar-ring: oklch(0.72 0 0); - --status-ok: oklch(0.76 0.16 145); - --status-warn: oklch(0.80 0.15 75); - --status-danger: oklch(0.70 0.18 22); - --curve-potential: oklch(0.94 0 0); - --curve-derivative: oklch(0.58 0 0); - --curve-spectrum: oklch(0.92 0 0); - --chart-grid: oklch(1 0 0 / 22%); - --chart-well: oklch(0.125 0 0); + --background: oklch(0.165 0.005 80); + --foreground: oklch(0.90 0.004 80); + --card: oklch(0.205 0.005 80); + --card-foreground: oklch(0.90 0.004 80); + --popover: oklch(0.215 0.005 80); + --popover-foreground: oklch(0.90 0.004 80); + --primary: oklch(0.87 0.004 80); + --primary-foreground: oklch(0.21 0.005 80); + --secondary: oklch(0.255 0.005 80); + --secondary-foreground: oklch(0.90 0.004 80); + --muted: oklch(0.255 0.005 80); + --muted-foreground: oklch(0.65 0.005 80); + --accent: oklch(0.255 0.005 80); + --accent-foreground: oklch(0.90 0.004 80); + --destructive: oklch(0.68 0.15 25); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 12%); + --ring: oklch(0.62 0.005 80); + --chart-1: oklch(0.88 0.004 80); + --chart-2: oklch(0.74 0.005 80); + --chart-3: oklch(0.60 0.005 80); + --chart-4: oklch(0.47 0.005 80); + --chart-5: oklch(0.36 0.005 80); + --sidebar: oklch(0.15 0.005 80); + --sidebar-foreground: oklch(0.90 0.004 80); + --sidebar-primary: oklch(0.87 0.004 80); + --sidebar-primary-foreground: oklch(0.21 0.005 80); + --sidebar-accent: oklch(0.24 0.005 80); + --sidebar-accent-foreground: oklch(0.90 0.004 80); + --sidebar-border: oklch(1 0 0 / 9%); + --sidebar-ring: oklch(0.62 0.005 80); + --status-ok: oklch(0.74 0.13 150); + --status-warn: oklch(0.78 0.12 80); + --status-danger: oklch(0.68 0.15 25); + --curve-potential: oklch(0.88 0.004 80); + --curve-derivative: oklch(0.56 0.005 80); + --curve-spectrum: oklch(0.86 0.004 80); + --chart-grid: oklch(1 0 0 / 14%); + --chart-well: oklch(0.15 0.004 80); } @layer base { @@ -167,6 +169,9 @@ ::-webkit-scrollbar-thumb { @apply bg-border rounded-full; } + ::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } ::-webkit-scrollbar-track { background: transparent; } diff --git a/TController/app/ui-next/components/app-shell.tsx b/TController/app/ui-next/components/app-shell.tsx index 229a187..b21eea6 100644 --- a/TController/app/ui-next/components/app-shell.tsx +++ b/TController/app/ui-next/components/app-shell.tsx @@ -158,11 +158,14 @@ function ToolBar() { tubingOp, sampleInput, setSampleInput, + analysis, + setAnalysis, } = useStore(); const running = ["injecting", "titrating", "degree1", "titrating2"].includes(workflow); const canStart = connected && !running && !tubingOp; - const cluster = "flex h-7 items-stretch overflow-hidden rounded-sm border bg-background"; + /* 簇底透明:嵌在卡片色工具条上时不再形成深色内陷块,只靠发丝边框分组 */ + const cluster = "flex h-7 items-stretch overflow-hidden rounded-sm border"; const cell = "h-full rounded-none border-0 py-0 shadow-none font-mono text-[12px] leading-[26px] focus-visible:z-10 focus-visible:ring-2 [&_[data-slot=select-value]]:h-full [&_[data-slot=select-value]]:leading-[26px]"; @@ -235,6 +238,52 @@ function ToolBar() { mL +
    + + {t("toolbar.titrantConc")} + + setAnalysis({ titrantConc: Number(e.target.value) || 0 })} + className={cn( + cell, + "box-border h-full w-[68px] px-1.5 py-0 font-mono text-[12px] leading-[26px] md:text-[12px] md:leading-[26px]", + "[appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none", + "text-right focus-visible:ring-0" + )} + aria-label={t("toolbar.titrantConc")} + /> + mol/L + + + {t("toolbar.stoich")} + + {(["analyteCoeff", "titrantCoeff"] as const).map((field, i) => ( + + {i === 1 && } + setAnalysis({ [field]: Math.max(0, Math.round(Number(e.target.value) || 0)) })} + className={cn( + cell, + "box-border h-full w-[36px] px-1 py-0 font-mono text-[12px] leading-[26px] md:text-[12px] md:leading-[26px]", + "[appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none", + "text-center focus-visible:ring-0" + )} + aria-label={`${t("toolbar.stoich")} ${i === 0 ? "a" : "b"}`} + /> + + ))} +
    +
    - - -
    - {items.length === 0 &&

    } - {items.map((l, i) => ( -
    - - {new Date(l.t).toLocaleTimeString("zh-CN", { hour12: false })} - - {l.text} -
    - ))} -
    -
    - - ); -} - /* ---------------- 页面 ---------------- */ function PumpChip({ @@ -312,10 +309,9 @@ export function TitrationPage() {
    - {/* 右:结果 + 日志 */} + {/* 右:结果面板(日志已移至维护页,此处独占整列) */}
    -
diff --git a/TController/app/ui-next/lib/backend.ts b/TController/app/ui-next/lib/backend.ts index 01e90b7..c9813b3 100644 --- a/TController/app/ui-next/lib/backend.ts +++ b/TController/app/ui-next/lib/backend.ts @@ -124,7 +124,7 @@ async function initialize() { async function callMock(method: keyof (typeof import("@/lib/mock/simulator"))["backend"], ...args: unknown[]) { const mod = await mock(); const fn = mod.backend[method] as (...values: unknown[]) => unknown; - return fn(...args); + return fn.apply(mod.backend, args); } export const backend = { diff --git a/TController/app/ui-next/lib/i18n.ts b/TController/app/ui-next/lib/i18n.ts index d3c1c73..e3e5f67 100644 --- a/TController/app/ui-next/lib/i18n.ts +++ b/TController/app/ui-next/lib/i18n.ts @@ -29,6 +29,8 @@ const dict = { "toolbar.disconnected": { zh: "未连接", en: "Offline" }, "toolbar.connecting": { zh: "连接中", en: "Linking" }, "toolbar.sample": { zh: "样品体积", en: "Sample" }, + "toolbar.titrantConc": { zh: "滴定剂浓度", en: "Titrant conc." }, + "toolbar.stoich": { zh: "计量比 a∶b", en: "Stoich a∶b" }, "toolbar.scenario": { zh: "场景", en: "Scenario" }, "toolbar.speed": { zh: "速度", en: "Speed" }, "toolbar.start": { zh: "开始滴定", en: "Start" }, @@ -94,6 +96,8 @@ const dict = { "results.kfStd": { zh: "标准差", en: "Std" }, "results.kfNis": { zh: "NIS", en: "NIS" }, "results.refined": { zh: "AMPD 精修", en: "AMPD refine" }, + "results.concentration": { zh: "分析物浓度", en: "Analyte conc." }, + "results.concPending": { zh: "设置滴定剂浓度后自动计算", en: "Set titrant conc. to compute" }, "results.spectralState": { zh: "光谱状态", en: "Spectral state" }, "method.consensus": { zh: "双模态共识", en: "Consensus" }, diff --git a/TController/app/ui-next/lib/mock/simulator.ts b/TController/app/ui-next/lib/mock/simulator.ts index 00571c7..6bdda4a 100644 --- a/TController/app/ui-next/lib/mock/simulator.ts +++ b/TController/app/ui-next/lib/mock/simulator.ts @@ -428,14 +428,14 @@ export const backend = { useStore.setState(pump === 1 ? { pump1Running: true, tx: useStore.getState().tx + 1 } : { pump2Running: true, tx: useStore.getState().tx + 1 }); - useStore.getState().addLog("info", translate(lang, "log.pumpRun", { p: pump })); + useStore.getState().addLog("info", translate(lang, "log.pumpRun", { p: pump }), { pump }); }, freeStop(pump: 1 | 2) { const { lang } = useStore.getState(); useStore.setState(pump === 1 ? { pump1Running: false, tx: useStore.getState().tx + 1 } : { pump2Running: false, tx: useStore.getState().tx + 1 }); - useStore.getState().addLog("info", translate(lang, "log.pumpStop", { p: pump })); + useStore.getState().addLog("info", translate(lang, "log.pumpStop", { p: pump }), { pump }); }, jog(pump: 1 | 2, steps: number) { const { connected, lang } = useStore.getState(); @@ -444,7 +444,7 @@ export const backend = { useStore.setState(pump === 1 ? { pump1Steps: useStore.getState().pump1Steps + steps, tx: useStore.getState().tx + 1 } : { pump2Steps: useStore.getState().pump2Steps + steps, tx: useStore.getState().tx + 1 }); - useStore.getState().addLog("ok", translate(lang, "log.pumpJog", { p: pump, n: steps, v: vol.toFixed(3) })); + useStore.getState().addLog("ok", translate(lang, "log.pumpJog", { p: pump, n: steps, v: vol.toFixed(3) }), { pump }); }, /** 从后端重新读出当前载入的泵标定(mock:回放 calibre 镜像)。 */ diff --git a/TController/app/ui-next/lib/store.ts b/TController/app/ui-next/lib/store.ts index 31d14b4..a178cb8 100644 --- a/TController/app/ui-next/lib/store.ts +++ b/TController/app/ui-next/lib/store.ts @@ -34,6 +34,25 @@ export const DEFAULT_DETECTION: DetectionParams = { consensusTol: 0.15, }; +/** 浓度计算参数:a·分析物 + b·滴定剂 → 产物,c分析物 = c滴定剂 · V终点 · (a/b) ÷ V样品 */ +export interface AnalysisParams { + titrantConc: number; /* mol/L */ + analyteCoeff: number; /* a */ + titrantCoeff: number; /* b */ +} + +export const DEFAULT_ANALYSIS: AnalysisParams = { + titrantConc: 0.1, + analyteCoeff: 1, + titrantCoeff: 1, +}; + +/** 由终点体积(mL)与样品体积(mL)计算分析物浓度(mol/L);参数非法时返回 null */ +export function analyteConcentration(p: AnalysisParams, endpointMl: number, sampleMl: number): number | null { + if (p.titrantConc <= 0 || p.analyteCoeff <= 0 || p.titrantCoeff <= 0 || sampleMl <= 0 || endpointMl <= 0) return null; + return (p.titrantConc * endpointMl * (p.analyteCoeff / p.titrantCoeff)) / sampleMl; +} + export type { CalPoint }; export interface AppState { @@ -78,6 +97,7 @@ export interface AppState { history: HistoryRun[]; calPoints: CalPoint[]; detection: DetectionParams; + analysis: AnalysisParams; setLang: (l: Lang) => void; setPage: (p: PageId) => void; @@ -90,13 +110,14 @@ export interface AppState { setTubingPumps: (p1: boolean, p2: boolean) => void; setWatchdog: (on: boolean) => void; setDetection: (patch: Partial) => void; + setAnalysis: (patch: Partial) => void; clearLogs: () => void; - addLog: (level: LogEntry["level"], text: string) => void; + addLog: (level: LogEntry["level"], text: string, opts?: { pump?: 1 | 2 }) => void; recordRun: (run: Omit) => void; resetRunData: () => void; } -const initial: Omit = { +const initial: Omit = { lang: "zh", page: "titration", navCollapsed: false, @@ -138,6 +159,7 @@ const initial: Omit()((set, get) => ({ set({ detection }); void backend.setDetection(patch); }, + /* 浓度计算只在上位机进行,参数不下发固件 */ + setAnalysis: (patch) => { + set({ analysis: { ...get().analysis, ...patch } }); + }, clearLogs: () => set({ logs: [] }), - addLog: (level, text) => set({ logs: [...get().logs, { t: Date.now(), level, text }].slice(-400) }), + /* 仅泵手动操作入 store(维护页「动作记录」消费);其余运行事件只进 console */ + addLog: (level, text, opts) => { + if (opts?.pump) { + set({ logs: [...get().logs, { t: Date.now(), level, text }].slice(-400) }); + return; + } + console.debug(`[autotitrator:${level}] ${text}`); + }, recordRun: (run) => { const entry: HistoryRun = { ...run, id: Math.random().toString(36).slice(2, 9), startedAt: Date.now() }; set({ history: [entry, ...get().history].slice(0, 30) }); From cce03048144248b3275bc6c0bfcf8629ed558df1 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 01:02:12 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E6=9E=84=E5=BB=BA(CI):=20=E5=9B=BA?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E4=B8=8A=E4=BD=8D=E6=9C=BA=E5=9B=9B=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=9E=84=E5=BB=BA=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并 firmware-build.yml 到统一的 build.yml:固件与上位机各平台并行构建 (fail-fast: false),RELEASE-* 标签时由单个 release job 汇总全部产物、 生成 SHA256SUMS 并创建 Release。原工作流自己建 Release,会与汇总 job 抢同一个 tag - 上位机矩阵四条腿全部跑原生架构 runner(windows-latest / windows-11-arm / ubuntu-24.04 / ubuntu-24.04-arm):Tauri 的 AppImage 与 MSI 打包器无法跨 架构工作。Windows 出 msi/nsis/portable.zip,Linux 出 deb/rpm/tar.gz/AppImage - 修掉 Linux 打包必崩的图标问题:bundle.icon 只有一帧 16×16 的 .ico,而 tauri-bundler 的 Linux 分支会跳过非 PNG 图标,AppImage 打包器随后在图标集 为空时 panic。新增 icons/icon.svg 与 scripts/gen_app_icons.mjs,生成 32/128/256/512 PNG 与六尺寸 ICO,产物一并提交 - tar.gz 由 CI 用 dpkg-deb -x 解出 deb 文件树重新打包(Tauri 无此目标), 内容与 deb 一致 - Windows aarch64 只出 NSIS:WiX v3 的 arm64 支持未在 Windows on ARM 上验证, 且打包器失败会连带丢掉同条腿已编好的 NSIS 产物 - Linux 选 24.04 而非 22.04:22.04 镜像自 2026-09-17 起进入弃用期,代价是 glibc 下限升到 2.39 - 修正 license 元数据:workspace 的 MIT 改为 LicenseRef-PolyForm-Shield-1.0.0 (该字段会写进 rpm 的 License 标签),bundle.licenseFile 指向根 LICENSE; deb 补 libudev1 依赖(serialport 4 需要) --- .github/workflows/build.yml | 237 ++++++++++++++++++ .github/workflows/firmware-build.yml | 62 ----- README.md | 33 +++ TController/Cargo.toml | 4 +- TController/app/src-tauri/icons/128x128.png | Bin 0 -> 2030 bytes .../app/src-tauri/icons/128x128@2x.png | Bin 0 -> 4229 bytes TController/app/src-tauri/icons/32x32.png | Bin 0 -> 530 bytes TController/app/src-tauri/icons/icon.ico | Bin 1150 -> 38739 bytes TController/app/src-tauri/icons/icon.png | Bin 0 -> 9673 bytes TController/app/src-tauri/icons/icon.svg | 10 + TController/app/src-tauri/tauri.conf.json | 14 +- scripts/gen_app_icons.mjs | 138 ++++++++++ 12 files changed, 434 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/firmware-build.yml create mode 100644 TController/app/src-tauri/icons/128x128.png create mode 100644 TController/app/src-tauri/icons/128x128@2x.png create mode 100644 TController/app/src-tauri/icons/32x32.png create mode 100644 TController/app/src-tauri/icons/icon.png create mode 100644 TController/app/src-tauri/icons/icon.svg create mode 100644 scripts/gen_app_icons.mjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..910bdad --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,237 @@ +name: Build + +on: + push: + branches: [master, main] + tags: + - "RELEASE-*" + pull_request: + branches: [master, main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + # 打标签的那次构建要产出 Release,不能被后续推送取消 + cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} + +env: + CARGO_TERM_COLOR: always + NEXT_TELEMETRY_DISABLED: "1" + +jobs: + # ---------------------------------------------------------------- 固件 + firmware: + name: 固件 (STM32F103 / cortex-m3) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: 安装 ARM GCC 工具链 + uses: carlosperate/arm-none-eabi-gcc-action@v1 + with: + release: "13.3.Rel1" + + # 用 setup-python 的解释器而不是系统 Python:24.04 的系统 Python + # 被标记为 externally-managed,直接 pip install 会被拒绝。 + - name: 安装 Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 安装 SCons + run: python -m pip install --upgrade pip scons + + - name: 构建 elf + hex + run: scons + + - name: 构建 lst + map + run: scons lst + + - name: 上传固件产物 + uses: actions/upload-artifact@v4 + with: + name: dist-firmware + path: | + build/AutoTitrator-Firmware.elf + build/AutoTitrator-Firmware.hex + if-no-files-found: error + + # map/lst 只用于排查问题,不进 Release(Release 只收 dist-* 制品) + - name: 上传固件调试信息 + uses: actions/upload-artifact@v4 + with: + name: firmware-debug + path: | + build/AutoTitrator-Firmware.map + build/AutoTitrator-Firmware.lst + if-no-files-found: error + + # -------------------------------------------------------------- 上位机 + tcontroller: + name: 上位机 (${{ matrix.label }}) + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + - label: windows-x86_64 + runner: windows-latest + bundles: msi,nsis + # WiX v3 的 arm64 支持没在 Windows on ARM 上验证过,这条腿只出 NSIS。 + # Tauri 官方文档同样只保证 NSIS 支持 ARM64。 + - label: windows-aarch64 + runner: windows-11-arm + bundles: nsis + # 用 24.04 而不是 22.04:22.04 镜像从 2026-09-17 起进入弃用期。 + # 代价是 deb/rpm/AppImage 的 glibc 下限变成 2.39(Ubuntu 24.04 及更新)。 + - label: linux-x86_64 + runner: ubuntu-24.04 + bundles: deb,rpm,appimage + - label: linux-aarch64 + runner: ubuntu-24.04-arm + bundles: deb,rpm,appimage + env: + # profile.release 已经 strip 过,再让 linuxdeploy 剥一次只会平添失败点 + NO_STRIP: "true" + steps: + - uses: actions/checkout@v4 + + # libwebkit2gtk / gtk / appindicator / rsvg / xdo 是 Tauri 2 的常规依赖; + # libudev-dev + pkg-config 是 controller-core 依赖的 serialport 4 需要的。 + - name: 安装 Linux 构建依赖 + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + file \ + libayatana-appindicator3-dev \ + libgtk-3-dev \ + librsvg2-dev \ + libssl-dev \ + libudev-dev \ + libwebkit2gtk-4.1-dev \ + libxdo-dev \ + patchelf \ + pkg-config + + - name: 安装 Rust 工具链 + uses: dtolnay/rust-toolchain@stable + + - name: 缓存 Cargo 构建 + uses: Swatinem/rust-cache@v2 + with: + workspaces: TController + key: ${{ matrix.label }} + + - name: 安装 Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: TController/app/ui-next/package-lock.json + + # tauri.conf.json 的 beforeBuildCommand 会在 ui-next 里跑 `npm run build`, + # 所以前端依赖必须先装好。 + - name: 安装前端依赖 + working-directory: TController/app/ui-next + run: npm ci + + - name: 安装 Tauri CLI + run: | + npm install -g @tauri-apps/cli@2.11.4 + tauri --version + + - name: 构建并打包 + working-directory: TController/app + run: tauri build --bundles ${{ matrix.bundles }} -- --locked + + - name: 收集 Linux 产物 + if: runner.os == 'Linux' + run: | + set -euo pipefail + bundle=TController/target/release/bundle + mkdir -p dist + cp "$bundle"/deb/*.deb dist/ + cp "$bundle"/rpm/*.rpm dist/ + cp "$bundle"/appimage/*.AppImage dist/ + + # Tauri 没有 tar.gz 目标。直接把 deb 的文件树解出来重新打包, + # 内容与 deb 完全一致,安装方式是 `sudo tar -xzf ... -C /`。 + deb=$(ls "$bundle"/deb/*.deb) + stage=$(mktemp -d) + dpkg-deb -x "$deb" "$stage" + install -Dm644 LICENSE "$stage/usr/share/doc/tcontroller-app/LICENSE" + tar -czf "dist/$(basename "$deb" .deb).tar.gz" -C "$stage" . + + ls -l dist + + - name: 收集 Windows 产物 + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $bundle = 'TController/target/release/bundle' + New-Item -ItemType Directory -Force -Path dist | Out-Null + Copy-Item "$bundle/nsis/*-setup.exe" dist/ + if (Test-Path "$bundle/msi") { Copy-Item "$bundle/msi/*.msi" dist/ } + + # 便携版:沿用 NSIS 安装器的命名,只把 -setup.exe 换成 -portable.zip + $setup = Get-ChildItem "$bundle/nsis/*-setup.exe" | Select-Object -First 1 + $base = $setup.Name -replace '-setup\.exe$', '' + $stage = Join-Path $env:RUNNER_TEMP $base + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item 'TController/target/release/tcontroller-app.exe' $stage + Copy-Item 'LICENSE' $stage + Compress-Archive -Path $stage -DestinationPath "dist/$base-portable.zip" -CompressionLevel Optimal + + Get-ChildItem dist | Format-Table Name, Length + + - name: 上传上位机产物 + uses: actions/upload-artifact@v4 + with: + name: dist-tcontroller-${{ matrix.label }} + path: dist/* + if-no-files-found: error + + # -------------------------------------------------------------- Release + release: + name: 发布 Release + if: startsWith(github.ref, 'refs/tags/RELEASE-') + # 任何一条腿失败都不发布,避免出现只有半个平台的 Release + needs: [firmware, tcontroller] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: 下载全部发布产物 + uses: actions/download-artifact@v4 + with: + path: dist + pattern: dist-* + merge-multiple: true + + - name: 生成校验和 + working-directory: dist + run: | + set -euo pipefail + # 写到 dist 外面再挪回来:`> SHA256SUMS` 会在 find 枚举目录之前 + # 就把文件建出来,否则它会把自己也算进去 + find . -maxdepth 1 -type f -printf '%P\n' | sort \ + | xargs -r sha256sum > "$RUNNER_TEMP/SHA256SUMS" + mv "$RUNNER_TEMP/SHA256SUMS" . + cat SHA256SUMS + + - name: 创建 GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + name: AutoTitrator ${{ github.ref_name }} + fail_on_unmatched_files: true diff --git a/.github/workflows/firmware-build.yml b/.github/workflows/firmware-build.yml deleted file mode 100644 index a610e4f..0000000 --- a/.github/workflows/firmware-build.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Firmware Build and Release - -on: - push: - branches: [master, main] - tags: - - "RELEASE-*" - pull_request: - branches: [master, main] - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install ARM GCC toolchain - uses: carlosperate/arm-none-eabi-gcc-action@v1 - with: - release: "13.3.Rel1" - - - name: Install SCons - run: | - python -m pip install --upgrade pip - pip install scons - - - name: Build firmware (elf + hex) - run: scons - - - name: Build firmware (lst + map) - run: scons lst - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: firmware-${{ github.run_number }} - path: | - build/AutoTitrator-Firmware.elf - build/AutoTitrator-Firmware.hex - build/AutoTitrator-Firmware.map - build/AutoTitrator-Firmware.lst - - - name: Generate release checksums - if: startsWith(github.ref, 'refs/tags/RELEASE-') - working-directory: build - run: | - sha256sum AutoTitrator-Firmware.hex AutoTitrator-Firmware.elf > SHA256SUMS - - - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/RELEASE-') - uses: softprops/action-gh-release@v2 - with: - files: | - build/AutoTitrator-Firmware.hex - build/AutoTitrator-Firmware.elf - build/SHA256SUMS - generate_release_notes: true - name: "Firmware Release ${{ github.ref_name }}" diff --git a/README.md b/README.md index 0f5d4c4..2a9b645 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,39 @@ cargo tauri build # 自动执行 Next.js 静态导出并打包 Tauri 应用 单独验证前端时,可在 `TController/app/ui-next` 下运行 `npm run build` 或 `npm run lint`。浏览器直接访问 Next 开发服务器时使用显式 mock adapter,真实 Tauri 环境始终以 Rust backend snapshot 为状态源。 +### 应用图标 + +`TController/app/src-tauri/icons/` 下的 PNG 与 ICO 由 `scripts/gen_app_icons.mjs` 从同目录的 `icon.svg` 生成,产物已提交进仓库,CI 不会重新生成。换图标时改 `icon.svg` 后本地重跑: + +```sh +cd TController/app/ui-next && npm ci # 脚本复用前端的 sharp +cd ../../.. && node scripts/gen_app_icons.mjs +``` + +`bundle.icon` 必须至少包含一个正方形 PNG:tauri-bundler 的 Linux 分支会跳过非 PNG 图标,而 AppImage 打包器在找不到正方形 PNG 时会直接 panic。 + +## 持续集成与发布 + +`.github/workflows/build.yml` 在推送到 `master` / `main`、提交 PR、以及打 `RELEASE-*` 标签时运行,固件与上位机各平台并行构建(`fail-fast: false`,单条腿失败不影响其他腿)。 + +| 构建目标 | Runner | 产物 | +|----------|--------|------| +| 固件 cortex-m3 | `ubuntu-24.04` | `.elf` `.hex`(`.map` `.lst` 另存为 CI 制品,不进 Release) | +| 上位机 Windows x86_64 | `windows-latest` | `.msi` `-setup.exe` `-portable.zip` | +| 上位机 Windows aarch64 | `windows-11-arm` | `-setup.exe` `-portable.zip` | +| 上位机 Linux x86_64 | `ubuntu-24.04` | `.deb` `.rpm` `.tar.gz` `.AppImage` | +| 上位机 Linux aarch64 | `ubuntu-24.04-arm` | `.deb` `.rpm` `.tar.gz` `.AppImage` | + +四条上位机的腿都跑在原生架构的 runner 上,不做交叉编译——Tauri 的 AppImage 与 MSI 打包器都无法跨架构工作。 + +发布时推 `RELEASE-*` 标签:所有构建成功后,`release` job 汇总全部产物、生成 `SHA256SUMS` 并创建 GitHub Release。任一平台失败则不发布,避免出现只覆盖部分平台的 Release。 + +几个需要知道的约束: + +- **Windows aarch64 只出 NSIS**。WiX v3 的 arm64 支持没在 Windows on ARM 上验证过,Tauri 官方也只保证 NSIS 支持 ARM64。 +- **`.tar.gz` 由 CI 自己打**。Tauri 没有 tar.gz 目标,CI 把 `.deb` 的文件树解出来重新打包,内容与 deb 一致,安装方式是 `sudo tar -xzf TController_*.tar.gz -C /`(依赖需自行安装)。 +- **Linux 产物的 glibc 下限是 2.39**(Ubuntu 24.04 及更新)。选 24.04 而非 22.04 是因为 22.04 镜像从 2026-09-17 起进入弃用期;若要支持更老的发行版需换回 22.04 或改用容器构建。 + ## 注意事项 - **无 HAL / 无标准库**:所有外设寄存器通过自定义抽象层直接访问。 diff --git a/TController/Cargo.toml b/TController/Cargo.toml index 9bd3b1b..2f37e79 100644 --- a/TController/Cargo.toml +++ b/TController/Cargo.toml @@ -5,7 +5,9 @@ members = ["crates/controller-core", "app/src-tauri"] [workspace.package] version = "0.1.0" edition = "2021" -license = "MIT" +# 仓库许可是 PolyForm Shield 1.0.0(见根目录 LICENSE),不是 MIT。 +# 这个字段会写进 rpm 的 License 标签,所以必须与 LICENSE 一致。 +license = "LicenseRef-PolyForm-Shield-1.0.0" [profile.release] lto = true diff --git a/TController/app/src-tauri/icons/128x128.png b/TController/app/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..e77273a6f4b23241026a77606408f2f0da465ca4 GIT binary patch literal 2030 zcmVrph-kQRCwC$omos&R~W}n;#w^x&I}-Q5CsCLQ64Ix?!KrbMk!5P z5~C)*XiSW?KA>?)Q6RkdU^ApO_NfnQiINZ#n={)9;(i zHMAl!!E`Jd!3IP1CJ|Hs+SxkAK0jrI9_MSj13FX<%d8mTQ12=tKtAxUUNz2jJM z%p$Sl81Chma%_d7X*^}HlrmmN>W7K0sgHDyfcTbUo^aQvO`DcXnW>;mb-^6Z)kU#C znwFLp@Z9+1xN;47!c6!c6j$@9ffpq6TM%4rs)c4!dc5lUZ2GW25m@ciPQK-RZhfCm>O?TLEeT%G z&)isx{jcZ)9wCt0>+h*kr)I>`;6t5nPD#*|lan)H)Bw<%P9Si`DKy%mzZAhTwj@|H zqWRo^M7?kJV9|&phCIBBAQ@i@R19l=Qc@B<=N~|j%%3|Lb^?i>s6>FQfuQ_@`Fu?2 zS_Qz%|EwpVmj8DMjCByK9oqP-Lr|;(p!WS5zQ|G|1ja7)lJ{e{`2PmMu(lMK&#nKx zwgzC%oH=1x48a}-;8zPgOk&$6504ErL>$Y7uEpm}(&YY2N-nZQC~c281G8 zk*v7jrAwE}o}Qi$%y-1k@oOvs;H|LbVrR^lA@AP3`%&{9J$UdyW@Tkr1A(xm(Z>zF zPnqwizP{d?Mxhk|?Ay0*)aE-XD=Py4CTPWq6|%3dFKPh#`}<{aaWMeUS=rgyvbD7} zYV#fOd-*c}fTlWq`gF|ZJ34jh6adgsn>KCwZ1Wv$-MSS3=%n1-T>0e5lehuU(a|CE z^Ya0KE{dkh7fsg?0BE2cJ9fm~d`H{2ZwCOXxB8zxed-zjT&$`4Ky+B8 zeE9I8=K%0`@^=FOgS&k;bPKfxASES5wzRY)41ha#?u7FI01~7vTec+3d{f#2S_5$N z=FNly(A3nV_39yQ0a(9&eZtLmv~Jxx0N}MQT)3bN0O!x22LN7b?%cU@aBxr=05~u6 z=FI~DUWs?{sA9gO^73*3;FWIQzO5Vpw{G160A6U(qD9J??`Xk-1pvTpaZ#%(06JC! z+5yne(4Z^;moHxi0B(v8a)@Pn(o$v3cT`eR0s!37;lqcO2jIYg z0|3A+)z;Q34*>5*0st9lu18vu%m zinIZsu&@vS#9PakFV_YDoerAP1^_;-Ll*#6ty%>D;w=s;0I(QFt>%n4CN2?O!(+#e;g~qpsspZ%-8P=v9l96$#-0_-OR?NsI?kWX>k@EQ zu;N`r-krk(Gw~ec;eNge2SZwO%NQP&J9Oxfynp|G%*OM3dC*D65kA~BYKT^@Tp8{y z@Lc^!!^r#`zlNuDb(_@#0D5Lh z9UUFUneT{iY|YoG8UXK9TF_yt`6`|tYIGItDqX>>Q0Q@NapOTW2=yCr50%W}TgS3A#Acjlyf+_^bcmR%w zk0l~wl4^4UP+avmH8pjz7%33|B?y$US}jJC=!HKcSjH9oKZ(&Mnvt7GZ)ir~j8S7w zPR;}oO(Fo&(H=13FTag`rioY*y*ZEc0)f=leD*F^d`?P9Ny(xQ`U3&g&iqIG2Q1=| zBGo|?0;^A*A9Jz)vBZrCg5WrUtGrG`-QbI|G+0Al(vCnY+1OhxyhxD|!wSqDD4~8H z-VFwWUkI;LWCH03uP=uY-cRZBtlpF42jbM;#>fx0b8rgyL4kmt59HZ_?%o;d`yn2`ZKmYw2zBs0T0mwuQSso$m!2kdN M07*qoM6N<$f-<$XJOBUy literal 0 HcmV?d00001 diff --git a/TController/app/src-tauri/icons/128x128@2x.png b/TController/app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9e1f661c6b0e65ae65483cecf873d2c1aeb97f99 GIT binary patch literal 4229 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 literal 0 HcmV?d00001 diff --git a/TController/app/src-tauri/icons/32x32.png b/TController/app/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..793a393ad2c04ee67c5e4b678fff89018bd2696c GIT binary patch literal 530 zcmV+t0`2{YP)l#7RU!R9M5+mrIJmKoEv=22J8)C+H;<;!@lWj(hPGBEBXeC(xZ| z5IlkhNE|^|;t50;P1Kz=UBM26nn}`=beM)8LelB~>*{9-!jF>4WaM-@tr0DWY*7>; z3SnDBCXtFB!mA=l(mSbqipumCorqWCmHCZ>In~XDz>sk2`J;~6Y*rF*%o8xDbK?U6 zK3N+m6bg?10AM13#qu*5LzvBGFrUvsmSyG%DX`M(x(?Ut)sd!Y%u!$hsMqUoKA&9x ze8zWN0P^`ftX3=6oNB#ZJKtglz;HjBQ>m&NFF>(ag#CW+32-D7={rK0EYH*x%dKLoiM{7gjsri=2W#>EqZ`vvl$ow>qZVxtyTjAD2ft2 z0Pgp@-Oks1yWNI58$zvucDwBh&}y~f-3hzhF4(sHWNcX$bUGbAhf$?cfyrb7i^amx zcsz!3xyC!C(*=;N-XbHk(c0J%9@T39uZGcqd#Ycq?`;rD}oz zqa+iPuUsxC($uWMJ2gwCQg4s>AwnE^^!&-37R=Q!T_Q4A6BduLw(f0?+_2xvH@V3$ UfFhhc@&Et;07*qoM6N<$f`&uo_5c6? literal 0 HcmV?d00001 diff --git a/TController/app/src-tauri/icons/icon.ico b/TController/app/src-tauri/icons/icon.ico index c1d36fb0bc0b4813f500762694cf768d063d94b8..ba173fdbe6ffa6786dd9b1f1e1440432c9f1d2f1 100644 GIT binary patch literal 38739 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 literal 1150 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 Q + + + + + + + + + diff --git a/TController/app/src-tauri/tauri.conf.json b/TController/app/src-tauri/tauri.conf.json index 27a96d6..ba3caad 100644 --- a/TController/app/src-tauri/tauri.conf.json +++ b/TController/app/src-tauri/tauri.conf.json @@ -36,6 +36,18 @@ "bundle": { "active": true, "targets": "all", - "icon": ["icons/icon.ico"] + "licenseFile": "../../../LICENSE", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.png", + "icons/icon.ico" + ], + "linux": { + "deb": { + "depends": ["libudev1"] + } + } } } diff --git a/scripts/gen_app_icons.mjs b/scripts/gen_app_icons.mjs new file mode 100644 index 0000000..419d24e --- /dev/null +++ b/scripts/gen_app_icons.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// 从 icons/icon.svg 生成 Tauri 打包所需的图标集。 +// +// 为什么需要这个脚本:tauri-bundler 的 Linux 分支只接受 PNG 图标 +// (freedesktop/mod.rs 会跳过非 .png 文件),而 AppImage 打包器在找不到 +// 任何正方形 PNG 时会直接 panic。所以 bundle.icon 里必须有 PNG, +// 同时 Windows 的 exe 资源与 msi/nsis 安装器仍需要 .ico。 +// +// 依赖 sharp。仓库里没有单独的 node_modules,直接复用前端的: +// cd TController/app/ui-next && npm ci +// node scripts/gen_app_icons.mjs +// +// 产物已提交进仓库,CI 不会重新生成——换图标时在本地重跑本脚本并提交。 + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import fs from "node:fs/promises"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const iconsDir = path.join(repoRoot, "TController", "app", "src-tauri", "icons"); +const source = path.join(iconsDir, "icon.svg"); + +function loadSharp() { + const candidates = [ + "sharp", + path.join(repoRoot, "TController", "app", "ui-next", "node_modules", "sharp"), + ]; + for (const id of candidates) { + try { + return require(id); + } catch { + /* 继续尝试下一个 */ + } + } + throw new Error( + "找不到 sharp。请先在 TController/app/ui-next 执行 `npm ci`,或全局安装 sharp。", + ); +} + +const sharp = loadSharp(); + +/** 把 SVG 渲染成指定边长的 PNG buffer。 */ +const renderPng = (size) => + sharp(source, { density: 384 }).resize(size, size, { fit: "contain" }).png({ compressionLevel: 9 }).toBuffer(); + +/** 把 SVG 渲染成指定边长的原始 RGBA buffer。 */ +const renderRaw = (size) => + sharp(source, { density: 384 }).resize(size, size, { fit: "contain" }).raw().ensureAlpha().toBuffer(); + +/** + * 构造 ICO 里的一个 BMP/DIB 条目。 + * Windows 只在 32bpp 时用 alpha 通道,但 AND 掩码仍按规范写出, + * 以兼容按老规则解析 ICO 的 NSIS 与 WiX。 + */ +function dibEntry(rgba, size) { + const header = Buffer.alloc(40); + header.writeUInt32LE(40, 0); // biSize + header.writeInt32LE(size, 4); // biWidth + header.writeInt32LE(size * 2, 8); // biHeight:XOR 位图 + AND 掩码 + header.writeUInt16LE(1, 12); // biPlanes + header.writeUInt16LE(32, 14); // biBitCount + header.writeUInt32LE(0, 16); // biCompression = BI_RGB + + const xor = Buffer.alloc(size * size * 4); + const maskStride = ((size + 31) >> 5) * 4; // 1bpp,行按 4 字节对齐 + const mask = Buffer.alloc(maskStride * size); + + for (let y = 0; y < size; y += 1) { + const srcRow = y * size * 4; + const dstRow = (size - 1 - y) * size * 4; // DIB 自下而上存储 + const maskRow = (size - 1 - y) * maskStride; + for (let x = 0; x < size; x += 1) { + const s = srcRow + x * 4; + const d = dstRow + x * 4; + xor[d] = rgba[s + 2]; // B + xor[d + 1] = rgba[s + 1]; // G + xor[d + 2] = rgba[s]; // R + xor[d + 3] = rgba[s + 3]; // A + if (rgba[s + 3] < 128) mask[maskRow + (x >> 3)] |= 0x80 >> (x & 7); + } + } + + header.writeUInt32LE(xor.length + mask.length, 20); // biSizeImage + return Buffer.concat([header, xor, mask]); +} + +/** 打包多尺寸 ICO:小尺寸用 DIB,256 用 PNG(DIB 会让文件多出 1MB)。 */ +function buildIco(entries) { + const dir = Buffer.alloc(6); + dir.writeUInt16LE(1, 2); // type = icon + dir.writeUInt16LE(entries.length, 4); + + let offset = 6 + entries.length * 16; + const table = []; + for (const { size, data } of entries) { + const entry = Buffer.alloc(16); + entry.writeUInt8(size >= 256 ? 0 : size, 0); // 256 记为 0 + entry.writeUInt8(size >= 256 ? 0 : size, 1); + entry.writeUInt16LE(1, 4); // planes + entry.writeUInt16LE(32, 6); // bit count + entry.writeUInt32LE(data.length, 8); + entry.writeUInt32LE(offset, 12); + table.push(entry); + offset += data.length; + } + + return Buffer.concat([dir, ...table, ...entries.map((e) => e.data)]); +} + +// bundle.icon 里引用的 PNG。文件名里的尺寸必须和真实像素一致: +// tauri-bundler 按解码后的宽高决定 hicolor 目录,只用 @2x 后缀判断高密度。 +const pngTargets = [ + ["32x32.png", 32], + ["128x128.png", 128], + ["128x128@2x.png", 256], + ["icon.png", 512], +]; + +const icoSizes = [16, 24, 32, 48, 64]; + +await fs.mkdir(iconsDir, { recursive: true }); + +for (const [name, size] of pngTargets) { + await fs.writeFile(path.join(iconsDir, name), await renderPng(size)); + console.log(`icons/${name} ${size}x${size}`); +} + +const icoEntries = []; +for (const size of icoSizes) { + icoEntries.push({ size, data: dibEntry(await renderRaw(size), size) }); +} +icoEntries.push({ size: 256, data: await renderPng(256) }); + +const ico = buildIco(icoEntries); +await fs.writeFile(path.join(iconsDir, "icon.ico"), ico); +console.log(`icons/icon.ico ${icoSizes.join("/")}/256 ${(ico.length / 1024).toFixed(1)} KiB`); From aef9f916e5a60ae8e5af82143eb02a32822609a5 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 01:12:22 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E6=9E=84=E5=BB=BA(CI):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20macOS=20=E6=9E=84=E5=BB=BA=EF=BC=8C=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E5=8E=8B=E5=88=B0=2010.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 macos-aarch64 / macos-x86_64 两条腿,各出 .dmg 与 .app.zip。.app 是 目录,upload-artifact 会丢掉可执行位和符号链接,所以用 ditto 打 zip - bundle.macOS.minimumSystemVersion 显式设为 10.13(同时写入 LSMinimumSystemVersion 与 MACOSX_DEPLOYMENT_TARGET),这是工具链能压到的 最低值:Apple SDK 支持表里 Xcode 16.x 的 deployment target 是 10.13–15, Tauri 打包器默认下限也是 10.13 - runner 固定 macos-15 / macos-15-intel 而非 macos-latest:Xcode 27 起最低 deployment target 抬到 macOS 12.0,用 latest 会随镜像升级悄悄丢掉旧系统支持 - 不需要额外的 .icns:bundle.icon 里没有 .icns 时,打包器会用 icns crate 把 现有 PNG 打成 ICNS(含 is32/il32 这类需要 RLE 编码的旧格式) - 产物未签名未公证(仓库无 Apple 证书,Tauri 仅在设置 APPLE_CERTIFICATE 时 签名),README 记录了 Gatekeeper 绕过方式与启用签名所需的 secret --- .github/workflows/build.yml | 25 +++++++++++++++++++++++ README.md | 9 ++++++-- TController/app/src-tauri/tauri.conf.json | 3 +++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 910bdad..3641694 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,6 +96,15 @@ jobs: - label: linux-aarch64 runner: ubuntu-24.04-arm bundles: deb,rpm,appimage + # macOS 固定用 15 而不是 26/latest:按 Apple 的 SDK 支持表,Xcode 16.x + # 是最后一档仍支持 macOS 10.13 deployment target 的(Xcode 27 起抬到 + # 12.0),而 10.13 正是 tauri.conf.json 里 minimumSystemVersion 的取值。 + - label: macos-aarch64 + runner: macos-15 + bundles: app,dmg + - label: macos-x86_64 + runner: macos-15-intel + bundles: app,dmg env: # profile.release 已经 strip 过,再让 linuxdeploy 剥一次只会平添失败点 NO_STRIP: "true" @@ -172,6 +181,22 @@ jobs: ls -l dist + - name: 收集 macOS 产物 + if: runner.os == 'macOS' + run: | + set -euo pipefail + bundle=TController/target/release/bundle + mkdir -p dist + cp "$bundle"/dmg/*.dmg dist/ + + # .app 是目录,upload-artifact 会丢掉可执行位和符号链接,所以用 + # ditto 打成 zip(保留权限与资源分叉),命名与 dmg 对齐 + dmg=$(ls "$bundle"/dmg/*.dmg) + app=$(ls -d "$bundle"/macos/*.app) + ditto -c -k --sequesterRsrc --keepParent "$app" "dist/$(basename "$dmg" .dmg).app.zip" + + ls -l dist + - name: 收集 Windows 产物 if: runner.os == 'Windows' shell: pwsh diff --git a/README.md b/README.md index 2a9b645..630cf59 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ cd TController/app/ui-next && npm ci # 脚本复用前端的 sharp cd ../../.. && node scripts/gen_app_icons.mjs ``` -`bundle.icon` 必须至少包含一个正方形 PNG:tauri-bundler 的 Linux 分支会跳过非 PNG 图标,而 AppImage 打包器在找不到正方形 PNG 时会直接 panic。 +`bundle.icon` 必须至少包含一个正方形 PNG:tauri-bundler 的 Linux 分支会跳过非 PNG 图标,而 AppImage 打包器在找不到正方形 PNG 时会直接 panic。macOS 不需要额外准备 `.icns`——列表里没有 `.icns` 时,打包器会把这些 PNG 打成 ICNS。 ## 持续集成与发布 @@ -214,8 +214,10 @@ cd ../../.. && node scripts/gen_app_icons.mjs | 上位机 Windows aarch64 | `windows-11-arm` | `-setup.exe` `-portable.zip` | | 上位机 Linux x86_64 | `ubuntu-24.04` | `.deb` `.rpm` `.tar.gz` `.AppImage` | | 上位机 Linux aarch64 | `ubuntu-24.04-arm` | `.deb` `.rpm` `.tar.gz` `.AppImage` | +| 上位机 macOS aarch64 | `macos-15` | `.dmg` `.app.zip` | +| 上位机 macOS x86_64 | `macos-15-intel` | `.dmg` `.app.zip` | -四条上位机的腿都跑在原生架构的 runner 上,不做交叉编译——Tauri 的 AppImage 与 MSI 打包器都无法跨架构工作。 +六条上位机的腿都跑在原生架构的 runner 上,不做交叉编译——Tauri 的 AppImage、MSI、DMG 打包器都无法跨架构工作。 发布时推 `RELEASE-*` 标签:所有构建成功后,`release` job 汇总全部产物、生成 `SHA256SUMS` 并创建 GitHub Release。任一平台失败则不发布,避免出现只覆盖部分平台的 Release。 @@ -224,6 +226,9 @@ cd ../../.. && node scripts/gen_app_icons.mjs - **Windows aarch64 只出 NSIS**。WiX v3 的 arm64 支持没在 Windows on ARM 上验证过,Tauri 官方也只保证 NSIS 支持 ARM64。 - **`.tar.gz` 由 CI 自己打**。Tauri 没有 tar.gz 目标,CI 把 `.deb` 的文件树解出来重新打包,内容与 deb 一致,安装方式是 `sudo tar -xzf TController_*.tar.gz -C /`(依赖需自行安装)。 - **Linux 产物的 glibc 下限是 2.39**(Ubuntu 24.04 及更新)。选 24.04 而非 22.04 是因为 22.04 镜像从 2026-09-17 起进入弃用期;若要支持更老的发行版需换回 22.04 或改用容器构建。 +- **macOS 下限压到 10.13 (High Sierra)**,由 `bundle.macOS.minimumSystemVersion` 设定,同时写入 `LSMinimumSystemVersion` 与 `MACOSX_DEPLOYMENT_TARGET`。这是当前工具链能压到的最低值:Apple 的 SDK 支持表里 Xcode 16.x 支持的 deployment target 是 macOS 10.13–15(Xcode 27 起抬到 12.0),Tauri 打包器的默认下限也是 10.13。所以 macOS 两条腿都固定用 `macos-15`(Xcode 16.x),不用 `macos-latest`。 + 注意 Tauri 官方在 Prerequisites 页只声明支持 **macOS 10.15 (Catalina) 及以上**,10.13 / 10.14 属于工具链允许但上游未验证的区间。若实测在 10.13/10.14 起不来,把 `minimumSystemVersion` 改成 `"10.15"` 即可——Apple Silicon 机型本身最低就是 11.0,不受影响。 +- **macOS 产物未签名、未公证**。仓库没有配置 Apple 开发者证书,Tauri 只在设置了 `APPLE_CERTIFICATE` 时才签名。用户首次打开需右键「打开」,或执行 `xattr -dr com.apple.quarantine /Applications/TController.app`。要启用签名就给 workflow 加上 `APPLE_CERTIFICATE` / `APPLE_CERTIFICATE_PASSWORD` / `APPLE_SIGNING_IDENTITY` 等 secret。 ## 注意事项 diff --git a/TController/app/src-tauri/tauri.conf.json b/TController/app/src-tauri/tauri.conf.json index ba3caad..2e88daa 100644 --- a/TController/app/src-tauri/tauri.conf.json +++ b/TController/app/src-tauri/tauri.conf.json @@ -48,6 +48,9 @@ "deb": { "depends": ["libudev1"] } + }, + "macOS": { + "minimumSystemVersion": "10.13" } } } From 0a54ae104680a72d0afc881ddfaf7678e443e325 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 01:24:49 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E6=9E=84=E5=BB=BA(CI):=20=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E5=88=86=E6=94=AF=E6=8E=A8=E9=80=81=E5=9D=87=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - push 的 branches 过滤从 [master, main] 放开为 ["**"],pull_request 去掉 base 分支限制 - branches 必须显式写 "**" 而不能整段删掉:push 下一旦有 tags 过滤器, 缺省的 branches 就等于「不匹配任何分支」,分支推送会静默不触发 --- .github/workflows/build.yml | 5 +++-- README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3641694..b19d922 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,11 +2,12 @@ name: Build on: push: - branches: [master, main] + # 必须显式写 "**":push 下只要出现 tags 过滤器,缺省的 branches 就等于 + # 「不匹配任何分支」,分支推送会静默不触发 + branches: ["**"] tags: - "RELEASE-*" pull_request: - branches: [master, main] workflow_dispatch: permissions: diff --git a/README.md b/README.md index 630cf59..305919d 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ cd ../../.. && node scripts/gen_app_icons.mjs ## 持续集成与发布 -`.github/workflows/build.yml` 在推送到 `master` / `main`、提交 PR、以及打 `RELEASE-*` 标签时运行,固件与上位机各平台并行构建(`fail-fast: false`,单条腿失败不影响其他腿)。 +`.github/workflows/build.yml` 在推送到任意分支、提交 PR、以及打 `RELEASE-*` 标签时运行,固件与上位机各平台并行构建(`fail-fast: false`,单条腿失败不影响其他腿)。 | 构建目标 | Runner | 产物 | |----------|--------|------| From e0beb9b57156bcc54a72fae30a33c7f390738caa Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 04:33:52 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E6=89=93=E7=A3=A8(=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E6=B3=A8=E9=87=8A)=EF=BC=9A=E6=B8=85=E7=90=86=20AI=20?= =?UTF-8?q?=E5=91=B3=E4=B8=8E=E7=BF=BB=E8=AF=91=E8=85=94=E8=A1=A8=E8=BE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 项目简介补充功能说明 - 统一 innovation 译为新息、contract 译为约定、floor 译为下界 - 去除破折号、比喻与 LLM 黑话(对齐/契约/地板/确保/显著/核心等) - 修正语序与选词(当量点、ICNS、带跑→带动等) --- README.md | 16 ++++++++-------- TController/README.md | 14 +++++++------- TController/app/ui-next/lib/i18n.ts | 2 +- TController/app/ui-next/lib/mock/simulator.ts | 2 +- TController/app/ui-next/lib/tone.ts | 2 +- TController/app/ui-next/lib/types.ts | 2 +- TController/crates/controller-core/src/lib.rs | 2 +- .../controller-core/src/processing/ampd.rs | 2 +- .../controller-core/src/processing/divergence.rs | 14 +++++++------- .../controller-core/src/processing/endpoint.rs | 10 +++++----- .../crates/controller-core/src/processing/kf.rs | 2 +- .../controller-core/src/processing/tracker.rs | 6 +++--- .../controller-core/src/protocol/handler.rs | 2 +- .../controller-core/src/protocol/parser.rs | 6 +++--- .../crates/controller-core/src/protocol/retry.rs | 2 +- .../crates/controller-core/src/workflow.rs | 10 +++++----- .../tests/endpoint_reliability.rs | 8 ++++---- .../controller-core/tests/tmp_diff_python.rs | 2 +- include/device/AS7341.hpp | 2 +- include/device/PumpMotor.hpp | 2 +- include/hal/I2C.hpp | 2 +- include/hal/UART.hpp | 2 +- include/platform/IWDG.hpp | 2 +- include/platform/SysTick.hpp | 2 +- include/protocol/CommandDispatcher.hpp | 4 ++-- include/protocol/CommandParser.hpp | 8 ++++---- 26 files changed, 64 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 305919d..ef7f16b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AutoTitrator-Free -多模态自动滴定控制器 —— STM32F103 裸机固件 + Rust/Tauri 上位机。 +多模态自动滴定控制器。STM32F103 裸机固件驱动进样与滴定双蠕动泵,采集电位与光谱信号;Rust/Tauri 上位机对两路信号做在线终点判定,并支持泵标定、浓度计算与数据记录。 ## 项目概览 @@ -201,11 +201,11 @@ cd TController/app/ui-next && npm ci # 脚本复用前端的 sharp cd ../../.. && node scripts/gen_app_icons.mjs ``` -`bundle.icon` 必须至少包含一个正方形 PNG:tauri-bundler 的 Linux 分支会跳过非 PNG 图标,而 AppImage 打包器在找不到正方形 PNG 时会直接 panic。macOS 不需要额外准备 `.icns`——列表里没有 `.icns` 时,打包器会把这些 PNG 打成 ICNS。 +`bundle.icon` 必须至少包含一个正方形 PNG:tauri-bundler 的 Linux 分支会跳过非 PNG 图标,而 AppImage 打包器在找不到正方形 PNG 时会直接 panic。macOS 不需要额外准备 `.icns`;列表里没有 `.icns` 时,打包器会把这些 PNG 合成为 ICNS。 ## 持续集成与发布 -`.github/workflows/build.yml` 在推送到任意分支、提交 PR、以及打 `RELEASE-*` 标签时运行,固件与上位机各平台并行构建(`fail-fast: false`,单条腿失败不影响其他腿)。 +`.github/workflows/build.yml` 在推送到任意分支、提交 PR、以及打 `RELEASE-*` 标签时运行,固件与上位机各平台并行构建(`fail-fast: false`,单一构建目标失败不影响其他目标)。 | 构建目标 | Runner | 产物 | |----------|--------|------| @@ -217,17 +217,17 @@ cd ../../.. && node scripts/gen_app_icons.mjs | 上位机 macOS aarch64 | `macos-15` | `.dmg` `.app.zip` | | 上位机 macOS x86_64 | `macos-15-intel` | `.dmg` `.app.zip` | -六条上位机的腿都跑在原生架构的 runner 上,不做交叉编译——Tauri 的 AppImage、MSI、DMG 打包器都无法跨架构工作。 +六个上位机构建目标都跑在原生架构的 runner 上,不做交叉编译。Tauri 的 AppImage、MSI、DMG 打包器都无法跨架构工作。 发布时推 `RELEASE-*` 标签:所有构建成功后,`release` job 汇总全部产物、生成 `SHA256SUMS` 并创建 GitHub Release。任一平台失败则不发布,避免出现只覆盖部分平台的 Release。 -几个需要知道的约束: +已知约束: - **Windows aarch64 只出 NSIS**。WiX v3 的 arm64 支持没在 Windows on ARM 上验证过,Tauri 官方也只保证 NSIS 支持 ARM64。 - **`.tar.gz` 由 CI 自己打**。Tauri 没有 tar.gz 目标,CI 把 `.deb` 的文件树解出来重新打包,内容与 deb 一致,安装方式是 `sudo tar -xzf TController_*.tar.gz -C /`(依赖需自行安装)。 -- **Linux 产物的 glibc 下限是 2.39**(Ubuntu 24.04 及更新)。选 24.04 而非 22.04 是因为 22.04 镜像从 2026-09-17 起进入弃用期;若要支持更老的发行版需换回 22.04 或改用容器构建。 -- **macOS 下限压到 10.13 (High Sierra)**,由 `bundle.macOS.minimumSystemVersion` 设定,同时写入 `LSMinimumSystemVersion` 与 `MACOSX_DEPLOYMENT_TARGET`。这是当前工具链能压到的最低值:Apple 的 SDK 支持表里 Xcode 16.x 支持的 deployment target 是 macOS 10.13–15(Xcode 27 起抬到 12.0),Tauri 打包器的默认下限也是 10.13。所以 macOS 两条腿都固定用 `macos-15`(Xcode 16.x),不用 `macos-latest`。 - 注意 Tauri 官方在 Prerequisites 页只声明支持 **macOS 10.15 (Catalina) 及以上**,10.13 / 10.14 属于工具链允许但上游未验证的区间。若实测在 10.13/10.14 起不来,把 `minimumSystemVersion` 改成 `"10.15"` 即可——Apple Silicon 机型本身最低就是 11.0,不受影响。 +- **Linux 产物的 glibc 下限是 2.39**(Ubuntu 24.04 及更新)。选 24.04:22.04 镜像从 2026-09-17 起进入弃用期;若要支持更老的发行版需换回 22.04 或改用容器构建。 +- **macOS 下限压到 10.13 (High Sierra)**,由 `bundle.macOS.minimumSystemVersion` 设定,同时写入 `LSMinimumSystemVersion` 与 `MACOSX_DEPLOYMENT_TARGET`。这是当前工具链能压到的最低值:Apple 的 SDK 支持表里 Xcode 16.x 支持的 deployment target 是 macOS 10.13–15(Xcode 27 起抬到 12.0),Tauri 打包器的默认下限也是 10.13。所以 macOS 的两个构建目标都固定用 `macos-15`(Xcode 16.x),不用 `macos-latest`。 + 注意 Tauri 官方在 Prerequisites 页只声明支持 **macOS 10.15 (Catalina) 及以上**,10.13 / 10.14 属于工具链允许但上游未验证的区间。若实测在 10.13/10.14 起不来,把 `minimumSystemVersion` 改成 `"10.15"` 即可;Apple Silicon 机型本身最低就是 11.0,不受影响。 - **macOS 产物未签名、未公证**。仓库没有配置 Apple 开发者证书,Tauri 只在设置了 `APPLE_CERTIFICATE` 时才签名。用户首次打开需右键「打开」,或执行 `xattr -dr com.apple.quarantine /Applications/TController.app`。要启用签名就给 workflow 加上 `APPLE_CERTIFICATE` / `APPLE_CERTIFICATE_PASSWORD` / `APPLE_SIGNING_IDENTITY` 等 secret。 ## 注意事项 diff --git a/TController/README.md b/TController/README.md index e74645d..4d4a3dc 100644 --- a/TController/README.md +++ b/TController/README.md @@ -7,7 +7,7 @@ Rust `controller-core` 后端 + Tauri 2 应用壳 + Next.js 仪器工作台。 ``` TController/ ├── Cargo.toml # workspace -├── crates/controller-core/ # 后端核心(纯逻辑 + 串口 I/O 线程) +├── crates/controller-core/ # 后端逻辑(纯逻辑 + 串口 I/O 线程) │ ├── src/protocol/ # 串口协议、解析与重试 │ │ ├── crc.rs # CRC-8 (poly 0x31) │ │ ├── frames.rs # 上/下行帧编解码(含 ADC shift 语义) @@ -16,28 +16,28 @@ TController/ │ │ └── handler.rs # 串口工作线程 + Event 通道(poll 模型) │ ├── src/processing/ # 检测、重建与泵校准 │ │ ├── ewma.rs savgol.rs ampd.rs -│ │ ├── divergence.rs # JS / 交叉熵 / KL(含舍入地板 1e-14) +│ │ ├── divergence.rs # JS / 交叉熵 / KL(含舍入下界 1e-14) │ │ ├── tracker.rs # SpectralFeatureTracker │ │ ├── kf.rs # EndpointFusionKF │ │ ├── endpoint.rs # EndpointDetector │ │ ├── reconstructor.rs # calibre.npz 矩阵 → 380–1100nm 全光谱 │ │ └── calibration.rs # 泵线性标定(slope/intercept) │ ├── src/workflow.rs # 滴定工作流与泵控逻辑 -│ └── tests/ # 行为契约与回归测试 +│ └── tests/ # 行为约定与回归测试 └── app/ # Tauri 2 应用 ├── src-tauri/ # 后端命令与 backend://state 快照 └── ui-next/ # Next.js 仪器工作台 ``` -## 行为契约 +## 行为约定 -`tests/endpoint_reliability.rs` 保留算法移植时建立的行为契约 -(JS 对称有界、特征因果性、重复体积 hold、顶替滞回、舍入地板、KF 重置、 +`tests/endpoint_reliability.rs` 保留算法移植时建立的行为约定 +(JS 对称有界、特征因果性、重复体积 hold、顶替滞回、舍入下界、KF 重置、 AMPD 对照稠密 oracle 等);`protocol/` 内嵌测试覆盖协议边界; `tests/workflow.rs` 固化了曾实际发生的 **T=1 死锁回归** (conflict + 电位证据必须放行 T=1,spectral_only 不得控泵)。 -AMPD 精修在短记录(大尺度不覆盖尾部峰)时返回 `None`;savgol edge 填充使用与原始算法一致的边缘半窗口行为。 +AMPD 精修在短记录(大尺度不覆盖尾部峰)时返回 `None`;savgol 边缘填充与原始算法的边缘半窗口行为一致。 ## 使用 diff --git a/TController/app/ui-next/lib/i18n.ts b/TController/app/ui-next/lib/i18n.ts index e3e5f67..2ac6570 100644 --- a/TController/app/ui-next/lib/i18n.ts +++ b/TController/app/ui-next/lib/i18n.ts @@ -199,7 +199,7 @@ const dict = { "settings.detectionSub": { zh: "与 controller-core 默认值一致;正式版提供修改入口", en: "Mirrors controller-core defaults" }, "settings.about": { zh: "关于", en: "About" }, "settings.version": { zh: "上位机版本", en: "Host version" }, - "settings.core": { zh: "后端核心", en: "Backend core" }, + "settings.core": { zh: "后端版本", en: "Backend core" }, "settings.license": { zh: "许可证", en: "License" }, "toast.connected": { zh: "已连接到 {port}", en: "Connected to {port}" }, diff --git a/TController/app/ui-next/lib/mock/simulator.ts b/TController/app/ui-next/lib/mock/simulator.ts index 6bdda4a..13dc6fa 100644 --- a/TController/app/ui-next/lib/mock/simulator.ts +++ b/TController/app/ui-next/lib/mock/simulator.ts @@ -1,5 +1,5 @@ /** - * Mock 仪器后端 —— 设计稿阶段的数据源。 + * Mock 仪器后端,设计稿阶段的数据源。 * * 对外暴露与真实后端一致的动词语义(connect/start/stop/abort/jog…), * 内部用定时器 + 滴定物理模型产生事件流写入 store。 diff --git a/TController/app/ui-next/lib/tone.ts b/TController/app/ui-next/lib/tone.ts index 12d2dac..aa3330c 100644 --- a/TController/app/ui-next/lib/tone.ts +++ b/TController/app/ui-next/lib/tone.ts @@ -1,5 +1,5 @@ /** - * 语义色调样式映射。ok/warn/danger/muted 四级,与状态色 token 对齐。 + * 语义色调样式映射。ok/warn/danger/muted 四级,与状态色 token 对应。 * 用于 Badge / 状态标签的背景+前景+边框一次性赋色。 */ export const toneClass: Record = { diff --git a/TController/app/ui-next/lib/types.ts b/TController/app/ui-next/lib/types.ts index dbbfa8f..0c8db6b 100644 --- a/TController/app/ui-next/lib/types.ts +++ b/TController/app/ui-next/lib/types.ts @@ -1,5 +1,5 @@ /** - * 前后端事件协议类型 —— 与 controller-core (Rust) 语义对齐。 + * 前后端事件协议类型,与 controller-core (Rust) 语义一致。 * 当前由 lib/mock/simulator.ts 实现;接入真实后端时仅需替换数据源, * 字段名保持 snake_case 序列化语义。 */ diff --git a/TController/crates/controller-core/src/lib.rs b/TController/crates/controller-core/src/lib.rs index ec0fd96..3183536 100644 --- a/TController/crates/controller-core/src/lib.rs +++ b/TController/crates/controller-core/src/lib.rs @@ -1,4 +1,4 @@ -//! controller-core — TController 上位机后端核心的 Rust 移植。 +//! controller-core — TController 上位机后端逻辑的 Rust 移植。 //! //! 该 crate 保留了从旧 Python 上位机移植时建立的模块边界: //! diff --git a/TController/crates/controller-core/src/processing/ampd.rs b/TController/crates/controller-core/src/processing/ampd.rs index c15c1f9..789a99b 100644 --- a/TController/crates/controller-core/src/processing/ampd.rs +++ b/TController/crates/controller-core/src/processing/ampd.rs @@ -28,7 +28,7 @@ pub fn ampd_peak_idx(signal: &[f64]) -> Option { .map(|(idx, _)| idx)?; let mut score = vec![0i64; n]; - // Python: for k in range(sigma + 1, L + 1) —— sigma 是 0 基 gamma 索引, + // Python: for k in range(sigma + 1, L + 1);sigma 是 0 基 gamma 索引, // 故实际起始尺度 k = sigma + 1。 for k in (sigma + 1)..=l { for i in k..n - k { diff --git a/TController/crates/controller-core/src/processing/divergence.rs b/TController/crates/controller-core/src/processing/divergence.rs index bef8c39..f318088 100644 --- a/TController/crates/controller-core/src/processing/divergence.rs +++ b/TController/crates/controller-core/src/processing/divergence.rs @@ -2,14 +2,14 @@ /// 数值下限(Python `_EPS`)。 pub const EPS: f64 = 1e-12; -/// JS 实测舍入地板(Python `_JS_FLOOR`): -/// float64 上真实 8 通道帧的 JS 舍入底约 5e-17,平台期约 2e-12,终点事件约 2e-7。 -/// js_speed 除以体积步长平方(约 4e7 倍放大),低于此地板的 JS 绝不能归一化。 +/// JS 实测舍入下界(Python `_JS_FLOOR`): +/// float64 上真实 8 通道帧的 JS 舍入下界约 5e-17,平台期约 2e-12,终点事件约 2e-7。 +/// js_speed 除以体积步长平方(约 4e7 倍放大),低于此下界的 JS 绝不能归一化。 pub const JS_FLOOR: f64 = 1e-14; /// NumPy `np.sum` 的成对求和逐位复刻(loops_utils.h `pairwise_sum`): /// n<8 顺序累加;n≤128 用 8 累加器树;更大者对半递归。 -/// 与 NumPy 的求和舍入完全一致,是双实现数值对齐的前提。 +/// 与 NumPy 的求和舍入完全一致,是两实现数值一致的前提。 pub fn np_sum(a: &[f64]) -> f64 { const PW_BLOCKSIZE: usize = 128; let n = a.len(); @@ -102,10 +102,10 @@ pub fn cross_entropy(p: &[f64], q: &[f64]) -> f64 { -np_sum(&part) } -/// 去自身地板的交叉熵 = KL(p‖q):恒等分布为 0,可与退出阈值比较。 +/// 去自身下界的交叉熵 = KL(p‖q):恒等分布为 0,可与退出阈值比较。 /// -/// `cross_entropy(p,p)` 是 p 的熵(~ln n)而非 0,直接驱动状态机会永远出不去 -/// IN_CHANGE;减去地板后才是可用的 KL。 +/// `cross_entropy(p,p)` 是 p 的熵(~ln n),不是 0,直接驱动状态机会永远出不去 +/// IN_CHANGE;减去下界后才是可用的 KL。 pub fn cross_entropy_excess(p: &[f64], q: &[f64]) -> f64 { let p = normalize_spectrum(p).expect("cross_entropy_excess: invalid p"); let q = normalize_spectrum(q).expect("cross_entropy_excess: invalid q"); diff --git a/TController/crates/controller-core/src/processing/endpoint.rs b/TController/crates/controller-core/src/processing/endpoint.rs index 85e525d..fefc9a7 100644 --- a/TController/crates/controller-core/src/processing/endpoint.rs +++ b/TController/crates/controller-core/src/processing/endpoint.rs @@ -4,8 +4,8 @@ //! (有界 JS 信号、因果交叉曲率、终点/延迟两状态 KF 融合)。任何特征都不 //! 使用未来样本。 //! -//! 任一模态的终点都可能事后修正——光谱端被更强激变顶替、电位端被 AMPD -//! 精修——所以观测对变化时 KF 从头重跑:用陈旧状态门控修正值只会拒绝修正。 +//! 任一模态的终点都可能事后修正(光谱端被更强激变顶替、电位端被 AMPD +//! 精修);所以观测对变化时 KF 从头重跑:用陈旧状态门控修正值只会拒绝修正。 use serde::Serialize; @@ -366,7 +366,7 @@ impl EndpointDetector { // 数据输入 // ================================================================ - /// 喂入一个电位点:体积 mL、时间 s、电压 V。 + /// 输入一个电位点:体积 mL、时间 s、电压 V。 pub fn feed_potential(&mut self, vol: f64, t: f64, v: f64) { let vol = vol; let t = t; @@ -437,7 +437,7 @@ impl EndpointDetector { } } - /// 喂入一帧原始通道或重建全谱。 + /// 输入一帧原始通道或重建全谱。 pub fn feed_spectrum(&mut self, vol: f64, spectrum: &[f64]) { let diag = self.spectral.update(vol, spectrum); self.last_spec_diag = diag; @@ -684,7 +684,7 @@ impl EndpointDetector { confidence: Confidence::Low, method: Method::Conflict, warning: Some(format!( - "电位{:.3}mL vs 光谱{:.3}mL 未通过创新一致性门控", + "电位{:.3}mL vs 光谱{:.3}mL 未通过新息一致性门控", pot.volume, spec.volume )), potential: Some(pot), diff --git a/TController/crates/controller-core/src/processing/kf.rs b/TController/crates/controller-core/src/processing/kf.rs index ce4f41a..e542396 100644 --- a/TController/crates/controller-core/src/processing/kf.rs +++ b/TController/crates/controller-core/src/processing/kf.rs @@ -317,7 +317,7 @@ mod tests { #[test] fn reset_lets_a_revised_endpoint_pair_refuse() { // 复刻 Paper/ExpData 回归:被顶替的光谱终点必须能重新融合, - // 而不是被去重 token 挡住。 + // 去重 token 不能挡住修正后的终点。 let mut kf = EndpointFusionKf::with_params(0.01, 0.01, 0.08, 0.004, 0.02, DEFAULT_NIS_GATE); kf.observe(ObservationKind::Potential, 2.1475, Some("potential@2.1475")); let stale = kf.observe(ObservationKind::Spectral, 1.1805, Some("spectral@1.1805")); diff --git a/TController/crates/controller-core/src/processing/tracker.rs b/TController/crates/controller-core/src/processing/tracker.rs index 0629dd5..e8bd0d9 100644 --- a/TController/crates/controller-core/src/processing/tracker.rs +++ b/TController/crates/controller-core/src/processing/tracker.rs @@ -2,9 +2,9 @@ //! //! 两个必须知道的行为(源自 Python 文档): //! -//! * 体积归一化速度锚定到最后一个*前进*帧而非上一帧。生产中固件每 AS7341 +//! * 体积归一化速度锚定到最后一个*前进*帧。生产中固件每 AS7341 //! 帧上报一帧光谱而体积来自泵,多帧共享同一体积;把零步长喂进速度滤波会 -//! 注入 0 并淹没真实事件,所以体积静止时速度滤波器*保持*电平。 +//! 注入 0 并掩盖真实事件,所以体积静止时速度滤波器*保持*电平。 //! * `END_CONFIRMED` 可重入。激变记录进 `events`,报告的终点是最强事件, //! 只有后续事件强 `supersede_ratio` 倍才顶替。一次性闩锁曾在真实数据 //! (Paper/ExpData B 组)上把早于真终点 0.97 mL 的瞬态锁成终点, @@ -325,7 +325,7 @@ impl SpectralFeatureTracker { /// 最近因果窗口内的最强 (速度, 体积)。 /// /// 速度滤波滞后于底层激变,首个越过 `js_enter` 的帧可能已在短瞬态的 - /// 下降沿上;用保留窗口播种峰值,使候选落在真实最大值而非穿越点。 + /// 下降沿上;用保留窗口播种峰值,让候选定位到窗口内速度最强的帧。 fn lookback_peak(&self, volume: f64, speed: f64) -> (f64, f64) { let mut peak_speed = speed; let mut peak_volume = volume; diff --git a/TController/crates/controller-core/src/protocol/handler.rs b/TController/crates/controller-core/src/protocol/handler.rs index 9c1936f..298c76c 100644 --- a/TController/crates/controller-core/src/protocol/handler.rs +++ b/TController/crates/controller-core/src/protocol/handler.rs @@ -370,7 +370,7 @@ mod tests { #[test] fn heartbeat_is_skipped_while_command_pending() { // 复刻 Python test_send_heartbeat_does_not_overwrite_pending_command: - // 心跳路径的判定就是 is_pending()——pending 存在时心跳不写线。 + // 心跳路径的判定就是 is_pending();pending 存在时心跳不写线。 let mut m = RetryMachine::new(); m.send(vec![0xBB, 0x55, 0x02, 0x02, 0x00], 0x02); assert!(m.is_pending()); diff --git a/TController/crates/controller-core/src/protocol/parser.rs b/TController/crates/controller-core/src/protocol/parser.rs index f3fefc1..9d51a89 100644 --- a/TController/crates/controller-core/src/protocol/parser.rs +++ b/TController/crates/controller-core/src/protocol/parser.rs @@ -11,7 +11,7 @@ enum State { Checksum, } -/// 逐字节喂入,吐出 `(类型, 载荷)`;与 Python `_UplinkParser` 逐位一致。 +/// 逐字节输入,输出 `(类型, 载荷)`;与 Python `_UplinkParser` 逐位一致。 #[derive(Debug)] pub struct UplinkParser { state: State, @@ -43,7 +43,7 @@ impl UplinkParser { *self = Self::new(); } - /// 喂入一段字节流,把本批次解析出的帧追加到 `out`。 + /// 输入一段字节流,把本批次解析出的帧追加到 `out`。 pub fn feed(&mut self, bytes: &[u8], out: &mut Vec<(u8, Vec)>) { for &b in bytes { if let Some(frame) = self.feed_byte(b) { @@ -137,7 +137,7 @@ mod tests { #[test] fn drops_frame_with_bad_crc_and_recovers() { - // 心跳帧载荷长度须为 4,否则坏帧会吞掉后续字节 + // 心跳帧载荷长度须为 4,否则坏帧会占用后续字节 let payload = vec![0x05, 0x00, 0x00, 0x00]; let mut frame = vec![0xAA, 0x55, 0x40]; frame.extend_from_slice(&payload); diff --git a/TController/crates/controller-core/src/protocol/retry.rs b/TController/crates/controller-core/src/protocol/retry.rs index a00dd9c..ab93be7 100644 --- a/TController/crates/controller-core/src/protocol/retry.rs +++ b/TController/crates/controller-core/src/protocol/retry.rs @@ -18,7 +18,7 @@ pub const ABORT_ERROR: &str = "下位机通讯异常"; pub enum AckOutcome { /// ACK 匹配当前 pending 命令,已清除。 Cleared, - /// 收到不匹配的 ACK 且仍有 pending —— 状态可能不同步,应上报错误。 + /// 收到不匹配的 ACK 且仍有 pending;状态可能不同步,应上报错误。 Unexpected { received: u8, expected: u8 }, /// 无 pending 时收到的 ACK,忽略(可能是重复响应)。 Ignored, diff --git a/TController/crates/controller-core/src/workflow.rs b/TController/crates/controller-core/src/workflow.rs index 85dabf7..5d5f023 100644 --- a/TController/crates/controller-core/src/workflow.rs +++ b/TController/crates/controller-core/src/workflow.rs @@ -4,11 +4,11 @@ //! 工作流:空闲 → [开始] 进样泵 MaxCount → 滴定泵 FreeRun → 终点 T=1 //! → 继续 FreeRun 至 2×V_ep → T=2 停泵 + AMPD 精修 → 完成。 //! -//! T=1 的泵控判据是"报告的体积有电位证据支撑",而不是枚举 method 名字: -//! consensus 已由 KF 融合双模态;potential_only 与 conflict 报告的都是电位 -//! 终点(conflict 即"双模态都确认但未过 NIS 门控,退回电位")。只有 -//! spectral_only 不能控泵——它没有电极证据。若按 method 名白名单就会漏掉 -//! conflict:两模态持续不一致时 T=1 永不触发,滴定死锁而泵无限运行 +//! T=1 的泵控判据是"报告的体积有电位证据支撑",按 method 名白名单判定会 +//! 漏掉 conflict:consensus 已由 KF 融合双模态;potential_only 与 conflict +//! 报告的都是电位终点(conflict 即"双模态都确认但未过 NIS 门控,退回电位")。 +//! 只有 spectral_only 不能控泵;它没有电极证据。两模态持续不一致时 +//! T=1 永不触发,滴定死锁而泵无限运行 //! (Python 版的实际回归,此处固化为测试 `conflict_with_potential_evidence_triggers_t1`)。 use serde::Serialize; diff --git a/TController/crates/controller-core/tests/endpoint_reliability.rs b/TController/crates/controller-core/tests/endpoint_reliability.rs index 0cfbaaf..32aea2c 100644 --- a/TController/crates/controller-core/tests/endpoint_reliability.rs +++ b/TController/crates/controller-core/tests/endpoint_reliability.rs @@ -1,4 +1,4 @@ -//! Python `tests/test_endpoint_reliability.py` 的移植 — 行为对齐契约。 +//! Python `tests/test_endpoint_reliability.py` 的移植 — 行为约定。 use controller_core::processing::divergence::js_divergence; use controller_core::processing::endpoint::{Confidence, EndpointDetector, Method, PotentialState}; @@ -187,8 +187,8 @@ fn detector_reset_retains_spectrum_configuration() { .is_finite()); } -/// 生产路径复现:多帧光谱共享同一泵体积。速度滤波必须*保持*电平而不是 -/// 喂零——喂零会把活跃激变拖到退出阈值以下,伪造一次恢复。 +/// 生产路径复现:多帧光谱共享同一泵体积。速度滤波必须*保持*电平, +/// 输入零值会把活跃激变拖到退出阈值以下,伪造一次恢复。 #[test] fn repeated_volume_holds_speed_instead_of_injecting_zero() { let mut tracker = SpectralFeatureTracker::with_params( @@ -252,7 +252,7 @@ fn supersede_ratio_suppresses_a_near_tie() { assert_eq!(outcomes[1].event_count, 2); } -/// js_speed 除以 ~1e-8,舍入地板量级的散度必须保持 0(放大的不能是算术噪声)。 +/// js_speed 除以 ~1e-8,舍入下界量级的散度必须保持 0(放大的不能是算术噪声)。 #[test] fn round_off_scale_divergence_is_not_normalised() { let mut tracker = SpectralFeatureTracker::new(); diff --git a/TController/crates/controller-core/tests/tmp_diff_python.rs b/TController/crates/controller-core/tests/tmp_diff_python.rs index 4d01279..ddc0857 100644 --- a/TController/crates/controller-core/tests/tmp_diff_python.rs +++ b/TController/crates/controller-core/tests/tmp_diff_python.rs @@ -2,7 +2,7 @@ //! //! 前置:`tmp_diff/dump_python.py` 生成 `tmp_diff/dataA_python.json` //! (输入事件序列 + Python 逐帧特征 + 最终结果)。缺文件时跳过。 -//! 这是移植验证用的一次性测试,数值对齐后可删除。 +//! 这是移植验证用的一次性测试,两实现数值一致后即可删除。 use controller_core::processing::endpoint::EndpointDetector; use serde_json::Value; diff --git a/include/device/AS7341.hpp b/include/device/AS7341.hpp index 4ece960..84c0e05 100644 --- a/include/device/AS7341.hpp +++ b/include/device/AS7341.hpp @@ -67,7 +67,7 @@ class AS7341 { static void service() noexcept { if (g_i2cBusy) { if (Platform::SysTick_::elapsed(g_i2cStartedAt) < I2C_TIMEOUT_MS) return; - /** 无论 HAL busy 是否仍置位,都强制退出设备侧 busy,避免失步卡死 */ + /** 无论 HAL busy 是否仍置位,都强制退出设备侧 busy,避免失步停滞 */ if (!HAL::I2C::abortAndRecover()) { HAL::I2C::recoverBus(); } diff --git a/include/device/PumpMotor.hpp b/include/device/PumpMotor.hpp index adfbb37..8d80d8d 100644 --- a/include/device/PumpMotor.hpp +++ b/include/device/PumpMotor.hpp @@ -108,7 +108,7 @@ class PumpMotor { /** * @brief 查询是否有进度上报待发送 - * @return true=有待上报 + * @return true=有进度待上报 */ static bool isReportPending() noexcept { return g_reportPending; } diff --git a/include/hal/I2C.hpp b/include/hal/I2C.hpp index 0d2ca18..478d606 100644 --- a/include/hal/I2C.hpp +++ b/include/hal/I2C.hpp @@ -345,7 +345,7 @@ class I2C { static bool isError() noexcept { return g_error; } /** - * @brief 取消卡住的异步传输并恢复总线 + * @brief 中止停滞的异步传输并恢复总线 */ static bool abortAndRecover() noexcept { uint32_t primask = disableIrqSave(); diff --git a/include/hal/UART.hpp b/include/hal/UART.hpp index 34feb4d..ac86058 100644 --- a/include/hal/UART.hpp +++ b/include/hal/UART.hpp @@ -125,7 +125,7 @@ class UART { } /** - * @brief 填入 TX 数据(覆盖式,调用前需确保 isTxIdle) + * @brief 填入 TX 数据(覆盖式,调用前 isTxIdle 须为真) * @param data 待发送数据 * @param len 数据长度 */ diff --git a/include/platform/IWDG.hpp b/include/platform/IWDG.hpp index e80c283..2349a74 100644 --- a/include/platform/IWDG.hpp +++ b/include/platform/IWDG.hpp @@ -2,7 +2,7 @@ * @file IWDG.hpp * @brief 独立看门狗驱动(LSI ~40kHz,超时 ~5s) * - * IWDG 一旦使能无法关闭,只能在复位前持续喂狗。 + * IWDG 一旦使能无法关闭;只能周期性喂狗,直到复位。 * 超时计算:T = (RLR + 1) × prescaler / 40000 * prescaler=64(PR=4),RLR=3124 → T = 3125 × 64 / 40000 = 5.0s */ diff --git a/include/platform/SysTick.hpp b/include/platform/SysTick.hpp index a4fc633..980079a 100644 --- a/include/platform/SysTick.hpp +++ b/include/platform/SysTick.hpp @@ -26,7 +26,7 @@ class SysTick_ { /** AHB 72MHz → RELOAD = 72000 - 1 → 1ms 中断 */ CortexM3::SysTick::STRVR::WriteRELOAD(72000 - 1); CortexM3::SysTick::STCVR::WriteCURRENT(0); - /** 系统异常优先级 15(最低),避免抢占 I2C/TIM4 关键时序 */ + /** 系统异常优先级 15(最低),避免抢占 I2C/TIM4 时序 */ CortexM3::Control::SHPR3::WritePRI_15(0xF0); /** CLKSOURCE=1 (AHB), TICKINT=1 (中断), ENABLE=1 */ CortexM3::SysTick::STCSR::Write(0x00000007); diff --git a/include/protocol/CommandDispatcher.hpp b/include/protocol/CommandDispatcher.hpp index 12870c7..cf7ed92 100644 --- a/include/protocol/CommandDispatcher.hpp +++ b/include/protocol/CommandDispatcher.hpp @@ -46,10 +46,10 @@ class CommandDispatcher { } /** - * @brief 主循环服务:从 SerialPort 读字节 → 喂入 Parser → 处理事件 + * @brief 主循环服务:从 SerialPort 读字节 → 交给 Parser → 处理事件 */ static void service() noexcept { - /** 喂入 RX 字节 */ + /** 输入 RX 字节 */ uint8_t buf[16]; size_t n = Device::SerialPort::read(buf, sizeof(buf)); for (size_t i = 0; i < n; ++i) { diff --git a/include/protocol/CommandParser.hpp b/include/protocol/CommandParser.hpp index 1ef69dd..e87c4bb 100644 --- a/include/protocol/CommandParser.hpp +++ b/include/protocol/CommandParser.hpp @@ -27,22 +27,22 @@ template class CommandParser { public: /** - * @brief 喂入一个字节,推进状态机 + * @brief 输入一个字节,推进状态机 * @param b 输入字节 */ static void feed(uint8_t b) noexcept { switch (s_state) { case State::Idle: if (b == FrameCodec::DOWNLINK_PREAMBLE0) { - s_state = State::GotBB; + s_state = State::GotBB; } break; case State::GotBB: if (b == FrameCodec::DOWNLINK_PREAMBLE1) { s_state = State::GotCmd; - } else if (b == FrameCodec::DOWNLINK_PREAMBLE0) { - s_state = State::GotBB; + } else if (b == FrameCodec::DOWNLINK_PREAMBLE0) { + s_state = State::GotBB; } else { s_state = State::Idle; } From e0565ded2c40ec34a95e12f911680752766a9638 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 04:58:06 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=96=87=E6=A1=A3)=EF=BC=9A=E6=96=B0=E5=A2=9E=E9=9D=A2?= =?UTF-8?q?=E5=90=91=E4=BA=BA=E7=B1=BB=E7=9A=84=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增上位机使用手册、通信协议、固件二次开发指南、硬件接线、标定与数据格式五份文档 - 全部文档配备同内容英文版,文件名以 _EN 结尾 - 中英文文档互相链接,根 README 补充文档索引 - 新增根 README 英文版 - 公式以 LaTeX 嵌入,流程图与时序图以 mermaid 绘制 --- README.md | 12 ++ README_EN.md | 257 +++++++++++++++++++++++++++ TController/README.md | 85 ++++++--- TController/README_EN.md | 109 ++++++++++++ TController/app/ui-next/README.md | 56 +++--- TController/app/ui-next/README_EN.md | 40 +++++ docs/data-formats.md | 97 ++++++++++ docs/data-formats_EN.md | 97 ++++++++++ docs/firmware-dev-guide.md | 143 +++++++++++++++ docs/firmware-dev-guide_EN.md | 147 +++++++++++++++ docs/hardware-wiring.md | 59 ++++++ docs/hardware-wiring_EN.md | 59 ++++++ docs/host-user-guide.md | 131 ++++++++++++++ docs/host-user-guide_EN.md | 131 ++++++++++++++ docs/protocol.md | 104 +++++++++++ docs/protocol_EN.md | 104 +++++++++++ 16 files changed, 1578 insertions(+), 53 deletions(-) create mode 100644 README_EN.md create mode 100644 TController/README_EN.md create mode 100644 TController/app/ui-next/README_EN.md create mode 100644 docs/data-formats.md create mode 100644 docs/data-formats_EN.md create mode 100644 docs/firmware-dev-guide.md create mode 100644 docs/firmware-dev-guide_EN.md create mode 100644 docs/hardware-wiring.md create mode 100644 docs/hardware-wiring_EN.md create mode 100644 docs/host-user-guide.md create mode 100644 docs/host-user-guide_EN.md create mode 100644 docs/protocol.md create mode 100644 docs/protocol_EN.md diff --git a/README.md b/README.md index ef7f16b..288184a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # AutoTitrator-Free +[English](README_EN.md) | 中文 + 多模态自动滴定控制器。STM32F103 裸机固件驱动进样与滴定双蠕动泵,采集电位与光谱信号;Rust/Tauri 上位机对两路信号做在线终点判定,并支持泵标定、浓度计算与数据记录。 ## 项目概览 @@ -16,6 +18,16 @@ | 上位机 | Rust/Tauri 2 + Next.js | | 授权 | [PolyForm Shield 1.0.0](LICENSE) | +## 文档 + +| 文档 | 读者 | 内容 | +|------|------|------| +| [上位机使用手册](docs/host-user-guide.md) | 做实验的人 | 安装、连接、标定、滴定、导出、常见问题 | +| [通信协议](docs/protocol.md) | 对接协议的人 | 帧格式、命令表、时序、重试 | +| [固件二次开发指南](docs/firmware-dev-guide.md) | 固件开发者 | 代码布局、初始化、寄存器层、中断、协议扩展 | +| [硬件接线](docs/hardware-wiring.md) | 组装样机的人 | 引脚分配、接线说明、供电 | +| [标定与数据格式](docs/data-formats.md) | 处理数据的人 | calibre.npz 结构、sidecar、settings.json | + ## 目录结构 ``` diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..a16c773 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,257 @@ +# AutoTitrator-Free + +[中文](README.md) | English + +A multimodal automatic titration controller. The STM32F103 bare-metal firmware drives the sample and titrant peristaltic pumps and samples the potential and spectral signals. The Rust/Tauri host application performs online endpoint detection on both signals and provides pump calibration, concentration calculation, and data recording. + +## Project overview + +| Item | Value | +|------|-------| +| MCU | STM32F103C8T6 (ARM Cortex-M3) | +| Flash | 64 KB @ 0x08000000 | +| RAM | 20 KB @ 0x20000000 | +| Toolchain | GCC ARM (`arm-none-eabi-g++`) | +| Language | C++23, bare-metal, no HAL / no RTOS / no heap | +| Build system | SCons | +| Debugger | ST-Link V2 (SWD) | +| Host | Rust/Tauri 2 + Next.js | +| License | [PolyForm Shield 1.0.0](LICENSE) | + +## Documentation + +| Document | Audience | Contents | +|----------|----------|----------| +| [Host User Guide](docs/host-user-guide_EN.md) | people running experiments | installation, connection, calibration, titration, export, troubleshooting | +| [Communication Protocol](docs/protocol_EN.md) | people integrating the protocol | frame formats, command table, timing, retries | +| [Firmware Development Guide](docs/firmware-dev-guide_EN.md) | firmware developers | code layout, initialization, register layer, interrupts, protocol extension | +| [Hardware Wiring](docs/hardware-wiring_EN.md) | people assembling prototypes | pin assignments, wiring notes, power supply | +| [Calibration and Data Formats](docs/data-formats_EN.md) | people working with data | calibre.npz structure, sidecar, settings.json | + +## Directory layout + +``` +AutoTitrator-Free +├── SConstruct # SCons build script +├── Startup/ # startup code, vector table, linker script +│ ├── Vectors.cpp +│ ├── linker.ld +│ └── CXXStubs.cpp +├── src/ # firmware application source +│ ├── main.cpp # main loop entry +│ └── Interrupts.cpp # interrupt handlers +├── include/ # headers +│ ├── register/ # Cortex-M3 register/MMIO abstraction +│ ├── stm32f103/ # 61 peripheral headers generated from SVD +│ ├── platform/ # system clock, SysTick, NVIC helpers +│ ├── hal/ # peripheral HAL drivers +│ ├── device/ # device-level drivers +│ └── protocol/ # communication protocol stack +├── TController/ # Rust/Tauri host +│ ├── crates/controller-core/ # protocol, detection, reconstruction, workflow +│ ├── app/src-tauri/ # Tauri commands and backend state +│ ├── app/ui-next/ # Next.js instrument workbench +│ └── data/ # calibre.npz and runtime state +├── scripts/ # code generation scripts +│ └── generate_stm32f103.py # generate peripheral headers from CMSIS-SVD +├── requirements-dev.txt # firmware build and register generation deps +├── openocd.cfg # OpenOCD debug configuration +├── .gdbinit # GDB init script +├── README.md +└── LICENSE +``` + +## Firmware architecture + +### 1. Register abstraction layer + +Located in `include/register/`, a header-only, pure C++23, zero-overhead MMIO abstraction: + +- `CortexM3::Register`: whole-register read/write, field read/write, atomic `Set` / `Clear` / `Modify`. +- `CortexM3::Field`: bitfield type; `Mask()` is computed at compile time. +- `atomic.hpp`: 8/16/32-bit atomic read-modify-write via `LDREX` / `STREX`. + +### 2. Peripheral header generation + +The 61 peripheral headers under `include/stm32f103/` are generated automatically from CMSIS-SVD by `scripts/generate_stm32f103.py`: + +```sh +uv run scripts/generate_stm32f103.py +``` + +Each register becomes a pure static singleton class. `Read` / `Write` and per-bitfield accessors are generated from the SVD `` attribute, in the namespace `STM32F103::{Peripheral}`. + +### 3. HAL layer + +| File | Peripheral | Notes | +|------|------------|-------| +| `include/hal/GPIO.hpp` | GPIO | port mode / pull / speed configuration, set / read | +| `include/hal/UART.hpp` | USART1 | RX via DMA1_CH5 circular + IDLE interrupt, TX via TXE interrupt byte by byte | +| `include/hal/I2C.hpp` | I2C1 | 100 kHz, PB8/PB9, synchronous blocking + asynchronous interrupt modes | +| `include/hal/TIM.hpp` | TIM3 / TIM4 | TIM3 as ADC trigger time base, TIM4 dual-channel PWM driving the peristaltic pumps | +| `include/hal/ADC.hpp` | ADC1 | single channel PA0, TIM3_TRGO trigger, EOC interrupt | + +### 4. Device drivers and protocol stack + +| File | Function | +|------|----------| +| `include/device/PumpMotor.hpp` | two peristaltic pump drivers (TIM4 CH1/CH2), MaxCount / FreeRun modes | +| `include/device/ADCOversample.hpp` | 256-sample ADC accumulation oversampling, right-shift 4 to output 16-bit result | +| `include/device/AS7341.hpp` | AS7341 spectral sensor driver, two-phase SMUX scan state machine | +| `include/device/SerialPort.hpp` | ring-buffer RX + interrupt-driven byte-by-byte TX | +| `include/protocol/CommandDispatcher.hpp` | parses downlink commands, calls device APIs, packs uplink data | +| `include/protocol/FrameCodec.hpp` | CRC-8 (Maxim-Dallas, poly = 0x31) encode/decode | +| `include/protocol/CommandParser.hpp` | downlink frame state-machine parser | + +### 5. Interrupt assignment + +| Interrupt | Priority | Purpose | +|-----------|----------|---------| +| USART1 | 0 | IDLE reception + TXE transmission | +| DMA1_Channel5 | 0 | USART1 RX DMA half/full | +| TIM4 | 1 | pump pulse counting | +| ADC1_2 | 2 | ADC conversion complete | +| I2C1_EV / I2C1_ER | 2 | AS7341 asynchronous I2C | +| SysTick | 15 | 1 ms tick | + +All interrupt handlers are declared as `[[gnu::weak]]` weak symbols in `Startup/Vectors.cpp`, defaulting to `Default_Handler`. Define an `extern "C"` function with the same name in any `.cpp` to override. + +### 6. Main loop + +`src/main.cpp` initializes the clock, SysTick, LED, UART, pumps, ADC oversampling, and AS7341, then polls the protocol, spectrum, and ADC services in the main loop, starting the next spectral sweep automatically when one finishes. + +## Build and flashing + +### Firmware build + +The `arm-none-eabi` toolchain must be on the PATH: + +```sh +scons +# or specify a prefix +scons CROSS=arm-none-eabi- +``` + +Artifacts land in `build/`: + +- `AutoTitrator-Firmware.elf` — executable (with debug info) +- `AutoTitrator-Firmware.hex` — Intel HEX +- `AutoTitrator-Firmware.map` — memory map +- `AutoTitrator-Firmware.lst` — disassembly listing + +Clean: + +```sh +scons -c +``` + +### Flashing and debugging + +```sh +# Terminal 1 - start OpenOCD +cd D:/Projects/AutoTitrator/Firmware +openocd -f openocd.cfg + +# Terminal 2 - connect GDB +arm-none-eabi-gdb build/AutoTitrator-Firmware.elf -x .gdbinit +``` + +### Register generation tool + +The register header generator needs `cmsis-svd`; install the development dependencies with: + +```sh +uv pip install -r requirements-dev.txt +uv run scripts/generate_stm32f103.py +``` + +## Host application (TController) + +The Rust/Tauri host communicates with the MCU over a serial port and provides: + +- live spectral and potential curves +- online titration endpoint detection +- dual-pump control with progress display +- pump calibration and pH electrode calibration +- state persistence, run history, and reliability diagnostics + +### Main modules + +| Directory | Function | +|-----------|----------| +| `TController/crates/controller-core/src/protocol/` | serial thread, protocol frame parsing and retries | +| `TController/crates/controller-core/src/processing/` | endpoint detection, spectral reconstruction, pump calibration | +| `TController/crates/controller-core/src/workflow.rs` | titration workflow and pump-control state machine | +| `TController/app/src-tauri/` | backend state snapshot, commands, persistence | +| `TController/app/ui-next/` | Next.js instrument workbench | + +### Tech stack + +| Component | Technology | +|-----------|------------| +| UI framework | Tauri 2 + Next.js | +| State management | Rust backend snapshot + Zustand view cache | +| Numerics | Rust ndarray / ndarray-npy | +| Serial communication | Rust serialport | + +### Running + +```sh +cd TController/app/src-tauri +cargo tauri dev # starts the Next.js dev server automatically +cargo tauri build # runs the Next.js static export and packages the Tauri app +``` + +To verify the frontend on its own, run `npm run build` or `npm run lint` under `TController/app/ui-next`. A browser accessing the Next dev server directly uses the explicit mock adapter; the real Tauri environment always uses the Rust backend snapshot as the source of truth. + +### App icons + +The PNG and ICO files under `TController/app/src-tauri/icons/` are generated from `icon.svg` in the same directory by `scripts/gen_app_icons.mjs`. The generated files are committed and CI does not regenerate them. To change the icon, edit `icon.svg` and rerun locally: + +```sh +cd TController/app/ui-next && npm ci # the script reuses the frontend's sharp +cd ../../.. && node scripts/gen_app_icons.mjs +``` + +`bundle.icon` must contain at least one square PNG: the tauri-bundler Linux branch skips non-PNG icons, and the AppImage packager panics if it cannot find a square PNG. macOS needs no separate `.icns`; when the list has no `.icns`, the packager synthesizes the ICNS from the PNGs. + +## Continuous integration and releases + +`.github/workflows/build.yml` runs on pushes to any branch, on pull requests, and on `RELEASE-*` tags. It builds the firmware and the host in parallel across platforms (`fail-fast: false`; one failing build target does not affect the others). + +| Build target | Runner | Artifacts | +|--------------|--------|-----------| +| Firmware cortex-m3 | `ubuntu-24.04` | `.elf` `.hex` (`.map` `.lst` saved as CI artifacts, not in the Release) | +| Host Windows x86_64 | `windows-latest` | `.msi` `-setup.exe` `-portable.zip` | +| Host Windows aarch64 | `windows-11-arm` | `-setup.exe` `-portable.zip` | +| Host Linux x86_64 | `ubuntu-24.04` | `.deb` `.rpm` `.tar.gz` `.AppImage` | +| Host Linux aarch64 | `ubuntu-24.04-arm` | `.deb` `.rpm` `.tar.gz` `.AppImage` | +| Host macOS aarch64 | `macos-15` | `.dmg` `.app.zip` | +| Host macOS x86_64 | `macos-15-intel` | `.dmg` `.app.zip` | + +The six host build targets all run on native-architecture runners; there is no cross-compilation, because the Tauri AppImage, MSI, and DMG packagers cannot work cross-architecture. + +To release, push a `RELEASE-*` tag. After all builds succeed, the `release` job collects every artifact, generates `SHA256SUMS`, and creates a GitHub Release. If any platform fails, nothing is published, so a Release never covers only some platforms. + +Known constraints: + +- **Windows aarch64 only produces NSIS.** WiX v3's arm64 support is not validated on Windows on ARM, and Tauri officially guarantees NSIS for ARM64 only. +- **`.tar.gz` is packed by CI itself.** Tauri has no tar.gz target, so CI unpacks the `.deb` file tree and repacks it. The contents match the deb; install with `sudo tar -xzf TController_*.tar.gz -C /` (install dependencies yourself). +- **The Linux glibc floor is 2.39** (Ubuntu 24.04 and newer). 24.04 was chosen over 22.04 because the 22.04 image enters deprecation on 2026-09-17; supporting older distributions means switching back to 22.04 or using container builds. +- **The macOS floor is 10.13 (High Sierra)**, set by `bundle.macOS.minimumSystemVersion` and written to both `LSMinimumSystemVersion` and `MACOSX_DEPLOYMENT_TARGET`. This is the lowest the current toolchain supports: Apple's SDK table lists macOS 10.13-15 for Xcode 16.x (12.0 from Xcode 27 on), and the Tauri packager's default floor is also 10.13. So both macOS build targets stay on `macos-15` (Xcode 16.x), not `macos-latest`. + Note that Tauri officially documents support for **macOS 10.15 (Catalina) and newer** in its Prerequisites page; 10.13 / 10.14 is allowed by the toolchain but not verified upstream. If it fails to launch on 10.13/10.14, set `minimumSystemVersion` to `"10.15"`; Apple Silicon machines are already 11.0 minimum and are unaffected. +- **macOS artifacts are unsigned and unnotarized.** The repository has no Apple developer certificate; Tauri signs only when `APPLE_CERTIFICATE` is set. First launch requires right-clicking "Open", or running `xattr -dr com.apple.quarantine /Applications/TController.app`. To enable signing, add `APPLE_CERTIFICATE` / `APPLE_CERTIFICATE_PASSWORD` / `APPLE_SIGNING_IDENTITY` secrets to the workflow. + +## Notes + +- **No HAL / no standard library**: all peripheral registers are accessed through the custom abstraction layer. +- **Heap**: `new` / `delete` trigger an infinite loop by default; implement dynamic allocation in `Startup/CXXStubs.cpp` if needed. +- **Static construction**: `.init_array` runs before `main()` via `Reset_Handler`, so global C++ object constructors work. + +## License + +This project is licensed under [PolyForm Shield 1.0.0](LICENSE). + +- Permitted for personal learning, research, and internal use +- Prohibited from being offered as a competing product, including derivatives +- Redistribution must include the full license text or its URL diff --git a/TController/README.md b/TController/README.md index 4d4a3dc..69dddfe 100644 --- a/TController/README.md +++ b/TController/README.md @@ -1,6 +1,12 @@ -# TController — AutoTitrator Rust/Tauri 上位机 +# TController 上位机 -Rust `controller-core` 后端 + Tauri 2 应用壳 + Next.js 仪器工作台。后端持有串口、工作流、校准、检测、设置和历史状态,前端通过状态快照和命令接口与其交互。 +[English](README_EN.md) | 中文 + +AutoTitrator 的桌面端,由三部分组成: + +- `controller-core`:Rust 后端逻辑,负责串口通信、终点检测、光谱重建、泵标定和工作流状态机,不含界面。 +- `src-tauri`:Tauri 2 应用壳,把 controller-core 包装成桌面应用,向前端提供命令接口和状态快照。 +- `ui-next`:Next.js 前端,仪器工作台界面。 ## 目录 @@ -31,48 +37,73 @@ TController/ ## 行为约定 -`tests/endpoint_reliability.rs` 保留算法移植时建立的行为约定 -(JS 对称有界、特征因果性、重复体积 hold、顶替滞回、舍入下界、KF 重置、 -AMPD 对照稠密 oracle 等);`protocol/` 内嵌测试覆盖协议边界; -`tests/workflow.rs` 固化了曾实际发生的 **T=1 死锁回归** -(conflict + 电位证据必须放行 T=1,spectral_only 不得控泵)。 +`tests/endpoint_reliability.rs` 保留了算法从 Python 移植时定下的行为约定:JS 散度对称有界、特征只用历史样本(因果)、重复体积帧保持速度电平、后发事件要强 1.5 倍才能顶替旧候选、舍入下界以下的散度不归一化、端点对变化时 KF 重新融合、AMPD 与稠密参照实现逐点一致。改动这些行为前先跑测试,改完测试会明确告诉你有哪里不一致。 -AMPD 精修在短记录(大尺度不覆盖尾部峰)时返回 `None`;savgol 边缘填充与原始算法的边缘半窗口行为一致。 +`tests/workflow.rs` 固化了一次实际发生过的 T=1 死锁回归:当双模态都确认但差距过大(conflict)时,只要电位证据在就必须放行 T=1;只有光谱、没有电位证据时不能控泵。场景见该文件顶部注释。 -## 使用 +AMPD 精修在记录太短(尾部峰没有大尺度覆盖)时返回 `None`,这是预期行为;savgol 边缘填充与参照算法的边缘半窗口行为一致。 -```bash -cd app/src-tauri -cargo tauri dev # 自动启动 Next.js 开发服务器 -cargo tauri build # 自动执行 Next.js 静态导出并打包 Tauri 应用 -``` +## 开发环境 -单独运行后端测试: +需要 Rust 工具链和 Node.js。根目录的 `requirements-dev.txt` 提供 Python 依赖(`uv` 安装),`generate_stm32f103.py` 生成固件外设头文件,上位机本身不需要它。 ```bash -cargo test --workspace -cargo check --workspace +# 安装前端依赖(首次) +cd app/ui-next +npm install + +# 启动开发(自动拉起 Next.js 开发服务器) +cd ../src-tauri +cargo tauri dev + +# 打包发布 +cargo tauri build # 自动执行 Next.js 静态导出 ``` -单独验证前端: +## 测试 ```bash +cargo test --workspace # 后端逻辑测试 +cargo check --workspace # 编译检查 + cd app/ui-next -npm install npm run build npm run lint ``` -`calibre.npz` 探测顺序:环境变量 `AUTOTITRATOR_CALIBRE` → exe 同级 → -开发模式 `TController/data/calibre.npz`(由 workspace manifest 路径锚定)。 +`savgol`、`ampd`、`endpoint` 等测试把 Python 参照实现的行为固化成断言,改数值算法时优先看这些测试是否还能过。`tests/tmp_diff_python.rs` 是一次性差分测试,需要先跑 `tmp_diff/dump_python.py` 生成对照数据,缺文件时自动跳过。 + +## 双数据源:mock 与真实后端 + +前端有两种运行方式: + +- **Tauri 环境**:始终以 Rust backend snapshot 为状态源。前端通过 `invoke` 调用 Tauri 命令,后端每 50ms 推一次 `backend://state` 快照。 +- **浏览器直接访问 Next 开发服务器**:没有 Tauri 桥,自动使用 `lib/mock/simulator.ts` 的内置模拟器,用定时器和滴定模型产生事件流。接 Tauri 后端时只需替换数据源,store 与界面组件不用改。 + +切换判断在 `lib/backend.ts` 的 `isTauriRuntime()`。mock 模拟器里带有几种演示场景(正常、弱信号、模态冲突、泵故障),只在浏览器调试时出现,打包进 Tauri 的正式界面不会显示场景选择。 + +## 后端状态模型 + +controller-core 不持有界面状态,通过 `Event` 通道向上抛事件,Tauri 壳 `BackendRuntime` 消费事件,维护一份 `BackendSnapshot`,以 `backend://state` 事件广播。快照字段全部 camelCase,与前端 `lib/backend.ts` 的类型定义一一对应。 + +关键设计: + +- 串口工作线程独占端口,主循环只管收发,命令的 ACK/NAK 重试、心跳都在 `ProtocolHandler` 线程里完成。 +- 工作流引擎 `WorkflowEngine` 是纯逻辑,不碰 I/O。`on_adc` / `on_spectrum` 喂数据,`poll` 每 500ms 做一次决策,产出泵指令交给传输层执行。 +- 状态持久化:`settings.json`(界面偏好、检测参数、历史)、`pump2_calibration.json`(应用标定后的 sidecar)。两个文件都由 `.gitignore` 忽略。 +- 诊断信息(可靠性、KF 快照、reason codes)随快照推送,前端「可靠性」分组直接展示,字段与 `EndpointResult` / `Reliability` 结构对应。 ## 数据文件 -- `data/calibre.npz`:光谱重建和泵校准数据,必须保留。 -- `data/settings.json`:后端运行时设置和历史记录,由 `.gitignore` 忽略。 -- `data/pump2_calibration.json`:应用校准后的 sidecar,由 `.gitignore` 忽略。 +- `data/calibre.npz`:光谱重建矩阵和泵标定参数,必须保留。 +- `data/settings.json`:后端运行时设置和历史记录,自动生成。 +- `data/pump2_calibration.json`:应用标定后的 sidecar,自动生成。 + +`calibre.npz` 的探测顺序:环境变量 `AUTOTITRATOR_CALIBRE` → 可执行文件同级目录 → 开发模式 `TController/data/calibre.npz`(编译期锚定)。结构见「标定与数据格式」文档。 -## 后续迭代 +## 相关文档 -- [ ] 数据记录与 xlsx 五 sheet 导出(rust_xlsxwriter) -- [ ] 电极元数据(calibre.npz 中的对象数组键,需迁移到 JSON sidecar 或专用格式) +- 根 README:构建、烧录、CI、发布。 +- 通信协议:帧格式、命令、时序。 +- 上位机使用手册:面向操作者的界面说明。 +- 标定与数据格式:calibre.npz 与 sidecar 的结构。 \ No newline at end of file diff --git a/TController/README_EN.md b/TController/README_EN.md new file mode 100644 index 0000000..2ce5cba --- /dev/null +++ b/TController/README_EN.md @@ -0,0 +1,109 @@ +# TController Host Application + +[中文](README.md) | English + +The desktop application for AutoTitrator, made of three parts: + +- `controller-core`: the Rust backend logic. It handles serial communication, endpoint detection, spectral reconstruction, pump calibration, and the workflow state machine. It has no UI. +- `src-tauri`: the Tauri 2 application shell. It wraps controller-core into a desktop app and exposes commands and a state snapshot to the frontend. +- `ui-next`: the Next.js frontend, the instrument workbench UI. + +## Layout + +``` +TController/ +├── Cargo.toml # workspace +├── crates/controller-core/ # backend logic (pure logic + serial I/O thread) +│ ├── src/protocol/ # serial protocol, parsing and retries +│ │ ├── crc.rs # CRC-8 (poly 0x31) +│ │ ├── frames.rs # uplink/downlink frame codec (incl. ADC shift semantics) +│ │ ├── parser.rs # AA55 byte-wise state machine +│ │ ├── retry.rs # ACK/NAK exponential backoff (single pending slot) +│ │ └── handler.rs # serial worker thread + Event channel (poll model) +│ ├── src/processing/ # detection, reconstruction, pump calibration +│ │ ├── ewma.rs savgol.rs ampd.rs +│ │ ├── divergence.rs # JS / cross-entropy / KL (incl. rounding lower bound 1e-14) +│ │ ├── tracker.rs # SpectralFeatureTracker +│ │ ├── kf.rs # EndpointFusionKF +│ │ ├── endpoint.rs # EndpointDetector +│ │ ├── reconstructor.rs # calibre.npz matrix → 380–1100nm full spectrum +│ │ └── calibration.rs # pump linear calibration (slope/intercept) +│ ├── src/workflow.rs # titration workflow and pump control +│ └── tests/ # behavior contracts and regression tests +└── app/ # Tauri 2 application + ├── src-tauri/ # backend commands and backend://state snapshots + └── ui-next/ # Next.js instrument workbench +``` + +## Behavior contracts + +`tests/endpoint_reliability.rs` keeps the behavior contracts established when the algorithms were ported from Python: JS divergence symmetric and bounded, features use only historical samples (causal), repeated-volume frames hold the speed level, a later event must be 1.5x stronger to supersede an earlier candidate, divergences below the rounding lower bound are not normalized, the KF re-fuses when the endpoint pair changes, and AMPD matches the dense reference implementation point for point. Before changing these behaviors, run the tests; they will tell you exactly where things diverge. + +`tests/workflow.rs` fixes a real T=1 deadlock regression: when both modalities confirm but disagree too much (conflict), T=1 must fire as long as potential evidence exists; with only spectral evidence, the pump must not be controlled. See the comments at the top of that file. + +AMPD refinement returns `None` when the record is too short (the tail peak has no large-scale coverage); that is expected. The savgol edge padding behaves like the reference algorithm's edge half-window. + +## Development environment + +You need the Rust toolchain and Node.js. The root `requirements-dev.txt` provides the Python dependencies (installed with `uv`) used by `generate_stm32f103.py`, which generates the firmware peripheral headers; the host application itself does not need them. + +```bash +# Install frontend dependencies (first time) +cd app/ui-next +npm install + +# Run in development (starts the Next.js dev server) +cd ../src-tauri +cargo tauri dev + +# Build a release +cargo tauri build # runs the Next.js static export automatically +``` + +## Tests + +```bash +cargo test --workspace # backend logic tests +cargo check --workspace # compile check + +cd app/ui-next +npm run build +npm run lint +``` + +Tests for `savgol`, `ampd`, `endpoint`, and others freeze the behavior of the Python reference implementation as assertions. When you change a numerical algorithm, look at these tests first. `tests/tmp_diff_python.rs` is a one-off differential test; run `tmp_diff/dump_python.py` first to generate the comparison data, and it skips automatically when the file is missing. + +## Two data sources: mock and real backend + +The frontend runs in one of two modes: + +- **Tauri environment**: always uses the Rust backend snapshot as the source of truth. The frontend calls Tauri commands via `invoke`, and the backend pushes a `backend://state` snapshot every 50ms. +- **Browser directly on the Next dev server**: with no Tauri bridge, it automatically uses the built-in simulator in `lib/mock/simulator.ts`, which produces event streams with timers and a titration model. When wiring up the real Tauri backend, only the data source is replaced; the store and UI components stay unchanged. + +The switch lives in `isTauriRuntime()` in `lib/backend.ts`. The mock simulator ships a few demo scenarios (normal, noisy, modal conflict, pump failure) that appear only in browser debugging; the packaged Tauri interface does not show the scenario picker. + +## Backend state model + +controller-core does not hold UI state. It pushes events up through an `Event` channel; the Tauri shell `BackendRuntime` consumes them, maintains a `BackendSnapshot`, and broadcasts it as the `backend://state` event. Snapshot fields are all camelCase and match the type definitions in `lib/backend.ts` one to one. + +Key design points: + +- The serial worker thread owns the port; the main loop only sends and receives. Command ACK/NAK retries and heartbeats all happen inside the `ProtocolHandler` thread. +- The `WorkflowEngine` is pure logic and does not touch I/O. `on_adc` / `on_spectrum` feed data in; `poll` makes a decision every 500ms and hands pump commands to the transport layer. +- State persistence: `settings.json` (interface preferences, detection parameters, history) and `pump2_calibration.json` (sidecar after applying calibration). Both are ignored by `.gitignore`. +- Diagnostics (reliability, KF snapshot, reason codes) ride along with the snapshot; the "Reliability" group in the frontend displays them directly, with fields matching the `EndpointResult` / `Reliability` structures. + +## Data files + +- `data/calibre.npz`: spectral reconstruction matrix and pump calibration parameters. Must be kept. +- `data/settings.json`: backend runtime settings and history, generated automatically. +- `data/pump2_calibration.json`: sidecar after applying calibration, generated automatically. + +`calibre.npz` is discovered in this order: the `AUTOTITRATOR_CALIBRE` environment variable, the executable's directory, then, in development, `TController/data/calibre.npz` (anchored at compile time). Its structure is described in the "Calibration and Data Formats" document. + +## Related documents + +- Root README: build, flashing, CI, releases. +- Communication Protocol: frame formats, commands, timing. +- Host User Guide: interface instructions for operators. +- Calibration and Data Formats: structure of calibre.npz and the sidecar. \ No newline at end of file diff --git a/TController/app/ui-next/README.md b/TController/app/ui-next/README.md index e215bc4..3d38d5d 100644 --- a/TController/app/ui-next/README.md +++ b/TController/app/ui-next/README.md @@ -1,36 +1,40 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# ui-next 仪器工作台 -## Getting Started +[English](README_EN.md) | 中文 -First, run the development server: +AutoTitrator 上位机的前端部分,基于 Next.js。它展示滴定工作台、标定、维护、数据记录和设置五个页面,数据来源有两种: -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +- Tauri 环境:订阅后端 `backend://state` 快照,通过 `invoke` 调用 Tauri 命令。 +- 浏览器直接访问:自动使用 `lib/mock/simulator.ts` 的模拟器,内置几种演示场景,方便不开硬件调试界面。 -## Learn More +切换逻辑在 `lib/backend.ts`。正式打包进 Tauri 时始终走真实后端,mock 只在浏览器开发模式出现。 -To learn more about Next.js, take a look at the following resources: +## 开发 -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```bash +npm install +npm run dev # 开发服务器(浏览器模式,自动启用 mock) +npm run build # 静态导出,供 Tauri 打包 +npm run lint +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 目录 -## Deploy on Vercel +``` +lib/ +├── backend.ts # Tauri/mock 双数据源桥接 +├── store.ts # Zustand 全局状态 +├── i18n.ts # 中英文案 +├── types.ts # 前后端事件协议类型 +├── chart-utils.ts # Canvas 图表公共工具 +├── tone.ts # 语义色调映射 +└── mock/ + ├── simulator.ts # 内置模拟器 + └── calibre.ts # 泵标定镜像 +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## 约定 -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- 界面文案全部走 `i18n.ts`,不要在组件里写死中文或英文。 +- 状态字段名与后端快照的 camelCase 字段一一对应,改协议先改 `types.ts`。 +- 页面布局见 `design.md`,改版式前先读它。 \ No newline at end of file diff --git a/TController/app/ui-next/README_EN.md b/TController/app/ui-next/README_EN.md new file mode 100644 index 0000000..a27b975 --- /dev/null +++ b/TController/app/ui-next/README_EN.md @@ -0,0 +1,40 @@ +# ui-next Instrument Workbench + +[中文](README.md) | English + +The frontend of the AutoTitrator host application, built with Next.js. It shows five pages: titration workbench, calibration, maintenance, records, and settings. Data comes from one of two sources: + +- Tauri environment: subscribes to the backend `backend://state` snapshot and calls Tauri commands via `invoke`. +- Browser directly: automatically uses the simulator in `lib/mock/simulator.ts`, which ships a few demo scenarios for developing the UI without hardware. + +The switch lives in `lib/backend.ts`. The packaged Tauri app always uses the real backend; mock appears only in browser development mode. + +## Development + +```bash +npm install +npm run dev # dev server (browser mode, mock enabled automatically) +npm run build # static export, used by the Tauri build +npm run lint +``` + +## Layout + +``` +lib/ +├── backend.ts # Tauri/mock dual data-source bridge +├── store.ts # Zustand global state +├── i18n.ts # Chinese/English strings +├── types.ts # frontend-backend event protocol types +├── chart-utils.ts # shared Canvas chart utilities +├── tone.ts # semantic tone mapping +└── mock/ + ├── simulator.ts # built-in simulator + └── calibre.ts # pump calibration mirror +``` + +## Conventions + +- All UI strings go through `i18n.ts`; do not hard-code Chinese or English in components. +- State field names map one to one to the camelCase fields of the backend snapshot. Change `types.ts` first when the protocol changes. +- Page layout is documented in `design.md`; read it before changing the layout. \ No newline at end of file diff --git a/docs/data-formats.md b/docs/data-formats.md new file mode 100644 index 0000000..4ba0fbb --- /dev/null +++ b/docs/data-formats.md @@ -0,0 +1,97 @@ +# 标定与数据格式 + +[English](data-formats_EN.md) | 中文 + +本文档说明 `calibre.npz` 和标定 sidecar 的内部结构,供需要读取、生成或迁移这些数据的人参考。实现代码见 `crates/controller-core/src/processing/`。 + +## 一、calibre.npz + +`calibre.npz` 是 NumPy 格式的压缩包,里面同时放光谱重建数据和泵标定数据。上位机启动时按固定顺序找它:环境变量 `AUTOTITRATOR_CALIBRE` → 可执行文件同级目录 → 开发模式 `TController/data/calibre.npz`。找不到时上位机降级运行:光谱不重建、泵用默认参数。 + +### 光谱重建键 + +| 键 | 类型 | 说明 | +|----|------|------| +| `spectral_matrix` | (721, 10) float | Golden Device 重建矩阵 | +| `spectral_wavelengths` | (721,) int32 | 波长轴,380–1100nm,1nm 步长 | +| `spectral_offsets` | (10,) float | 每通道暗电流偏移 | +| `spectral_factors` | (10,) float | 每通道增益系数 | + +重建公式(`Reconstructor::reconstruct`): + +$$ +\text{corrected}[ch] = \text{factors}[ch] \times \max\left(\text{raw}[ch] - \text{offsets}[ch],\, 0\right) +$$ + +$$ +\text{spectrum}[\lambda] = \max\left(\sum_{ch}\, \text{matrix}[\lambda, ch] \times \text{corrected}[ch],\, 0\right) +$$ + +即先按通道减去偏移、乘增益并截断到非负,再乘矩阵得到 721 点光谱。输入必须是 10 个通道,负数或非有限值会报错。 + +### 泵标定键 + +| 键 | 说明 | +|----|------| +| `pump1_slope` / `pump1_intercept` | 泵 1 线性模型 slope(mL/步)与 intercept(mL) | +| `pump2_slope` / `pump2_intercept` | 泵 2 同上 | +| `pump1_pulses` / `pump1_volumes` | 泵 1 标定点(步数 ↔ 实测体积) | +| `pump2_pulses` / `pump2_volumes` | 泵 2 标定点 | +| `pump1_r2` / `pump2_r2` | 拟合 R²,可为标量或单元素数组 | + +加载规则: + +- slope 必须为正,否则回退默认值。 +- intercept 允许为负,但绝对值不能超过 10 mL。 +- 缺失或读取失败的键静默回退默认值,不报错。 + +默认值(`DEFAULT_PUMP_SLOPE = 6.03752e-6`,intercept 0)对应泵步进频率 1000Hz,流速约 6.04e-3 mL/s。 + +体积与步数互换(`PumpCalibration`): + +$$ +\text{volume} = \text{slope} \times \text{steps} + \text{intercept} +$$ + +$$ +\text{steps} = \left\lfloor \frac{\text{volume} - \text{intercept}}{\text{slope}} \right\rfloor \quad (\text{下限 0}) +$$ + +## 二、标定 sidecar + +上位机在界面里应用泵标定后,把结果写进与 `settings.json` 同目录的 `pump2_calibration.json`,结构如下: + +```json +{ + "points": [ + { "steps": 0, "vol": 0.0 }, + { "steps": 5000, "vol": 0.031 } + ], + "slopeMlPerStep": 6.0997e-6, + "interceptMl": 0.0, + "r2": 0.9997 +} +``` + +- `points`:界面里的标定点,`steps` 是累计步数,`vol` 是实测体积(mL)。 +- `slopeMlPerStep`:拟合斜率,单位 mL/步。 +- `interceptMl`:截距,单位 mL。 +- `r2`:拟合优度,可选。 + +启动时如果这个文件存在且 `slopeMlPerStep > 0`,上位机会用它覆盖 `calibre.npz` 里的泵 2 参数,并把点集和 R² 加载进界面。 + +## 三、settings.json + +`settings.json` 保存界面偏好和运行历史,自动生成: + +- `lang`、`theme`、`navCollapsed`:界面偏好。 +- `detection`:检测参数(T=1 导数阈值、单步剂量、过量滴定余量、共识容差)。 +- `history`:最近 30 次运行记录。 +- `port`、`baud`:上次连接的串口与波特率。 +- `sampleInput`、`tubingP1`、`tubingP2`:进样体积和管路泵选择。 + +这个文件可以删除,删了回到默认设置。它被 `.gitignore` 忽略,不会进版本库。 + +## 四、生成与迁移 + +需要重新生成 `calibre.npz` 时,用 Python(`numpy` / `scipy`)按上面的键名写入即可。迁移到新机器时,把 `calibre.npz` 放到可执行文件同级目录,或设置 `AUTOTITRATOR_CALIBRE` 指向它。标定数据跟着走:`calibre.npz` 里的泵标定是出厂默认,界面里应用的标定单独放在 `pump2_calibration.json`。 \ No newline at end of file diff --git a/docs/data-formats_EN.md b/docs/data-formats_EN.md new file mode 100644 index 0000000..a2ef062 --- /dev/null +++ b/docs/data-formats_EN.md @@ -0,0 +1,97 @@ +# Calibration and Data Formats + +[中文](data-formats.md) | English + +This document describes the internal structure of `calibre.npz` and the calibration sidecar, for people who need to read, generate, or migrate these data files. The implementation lives in `crates/controller-core/src/processing/`. + +## 1. calibre.npz + +`calibre.npz` is a NumPy-format archive that holds both the spectral reconstruction data and the pump calibration data. At startup the host looks for it in a fixed order: the `AUTOTITRATOR_CALIBRE` environment variable, then the executable's directory, then, in development, `TController/data/calibre.npz` (anchored at compile time). If it is not found, the host degrades: the spectrum is not reconstructed and the pumps use default parameters. + +### Spectral reconstruction keys + +| Key | Type | Notes | +|-----|------|-------| +| `spectral_matrix` | (721, 10) float | Golden Device reconstruction matrix | +| `spectral_wavelengths` | (721,) int32 | Wavelength axis, 380–1100nm, 1nm steps | +| `spectral_offsets` | (10,) float | Per-channel dark offset | +| `spectral_factors` | (10,) float | Per-channel gain factor | + +Reconstruction (`Reconstructor::reconstruct`): + +$$ +\text{corrected}[ch] = \text{factors}[ch] \times \max\left(\text{raw}[ch] - \text{offsets}[ch],\, 0\right) +$$ + +$$ +\text{spectrum}[\lambda] = \max\left(\sum_{ch}\, \text{matrix}[\lambda, ch] \times \text{corrected}[ch],\, 0\right) +$$ + +That is, per channel, subtract the offset, multiply by the factor, clamp to non-negative, then multiply by the matrix to get the 721-point spectrum. The input must have exactly 10 channels; negative or non-finite values raise an error. + +### Pump calibration keys + +| Key | Notes | +|-----|-------| +| `pump1_slope` / `pump1_intercept` | Pump 1 linear model slope (mL/step) and intercept (mL) | +| `pump2_slope` / `pump2_intercept` | Pump 2, same | +| `pump1_pulses` / `pump1_volumes` | Pump 1 calibration points (steps ↔ measured volume) | +| `pump2_pulses` / `pump2_volumes` | Pump 2 calibration points | +| `pump1_r2` / `pump2_r2` | Fit R², either a scalar or a single-element array | + +Loading rules: + +- The slope must be positive, otherwise the default is used. +- The intercept may be negative, but its absolute value must not exceed 10 mL. +- Missing or unreadable keys silently fall back to defaults; no error is reported. + +Defaults (`DEFAULT_PUMP_SLOPE = 6.03752e-6`, intercept 0) correspond to a pump step rate of 1000Hz, a flow rate of about 6.04e-3 mL/s. + +Volume and steps convert as follows (`PumpCalibration`): + +$$ +\text{volume} = \text{slope} \times \text{steps} + \text{intercept} +$$ + +$$ +\text{steps} = \left\lfloor \frac{\text{volume} - \text{intercept}}{\text{slope}} \right\rfloor \quad (\text{lower bound 0}) +$$ + +## 2. Calibration sidecar + +After the user applies a pump calibration in the interface, the host writes the result to `pump2_calibration.json` next to `settings.json`: + +```json +{ + "points": [ + { "steps": 0, "vol": 0.0 }, + { "steps": 5000, "vol": 0.031 } + ], + "slopeMlPerStep": 6.0997e-6, + "interceptMl": 0.0, + "r2": 0.9997 +} +``` + +- `points`: the calibration points from the interface; `steps` is the accumulated step count, `vol` the measured volume (mL). +- `slopeMlPerStep`: fitted slope, in mL/step. +- `interceptMl`: intercept, in mL. +- `r2`: goodness of fit, optional. + +At startup, if this file exists and `slopeMlPerStep > 0`, the host uses it to override the pump 2 parameters from `calibre.npz` and loads the points and R² into the interface. + +## 3. settings.json + +`settings.json` stores interface preferences and run history, created automatically: + +- `lang`, `theme`, `navCollapsed`: interface preferences. +- `detection`: detection parameters (T=1 derivative threshold, dose per step, over-titration margin, consensus tolerance). +- `history`: the most recent 30 runs. +- `port`, `baud`: the serial port and baud rate from the last connection. +- `sampleInput`, `tubingP1`, `tubingP2`: sample volume and tubing pump selection. + +This file can be deleted to reset to defaults. It is ignored by `.gitignore` and does not enter the repository. + +## 4. Generation and migration + +To regenerate `calibre.npz`, write the keys above with Python (`numpy` / `scipy`). To migrate to a new machine, put `calibre.npz` next to the executable, or point `AUTOTITRATOR_CALIBRE` at it. Calibration data follows along: the pump calibration inside `calibre.npz` is the factory default, while a calibration applied in the interface lives separately in `pump2_calibration.json`. \ No newline at end of file diff --git a/docs/firmware-dev-guide.md b/docs/firmware-dev-guide.md new file mode 100644 index 0000000..ad053a5 --- /dev/null +++ b/docs/firmware-dev-guide.md @@ -0,0 +1,143 @@ +# 固件二次开发指南 + +[English](firmware-dev-guide_EN.md) | 中文 + +本文档写给要改动固件代码的开发者。固件运行在 STM32F103C8T6 上,裸机 C++23,没有 HAL、没有 RTOS、没有标准库。系统结构、构建与烧录见根 README,引脚与中断见「硬件接线」文档,协议见「通信协议」文档。 + +## 一、代码布局 + +``` +src/ +├── main.cpp # 入口,初始化 + 主循环 +└── Interrupts.cpp # ISR 桩函数 +include/ +├── register/ # Cortex-M3 寄存器/MMIO 抽象层(header-only) +├── stm32f103/ # 从 SVD 生成的 61 个外设头文件 +├── platform/ # 系统时钟、SysTick、NVIC、IWDG +├── hal/ # 外设驱动:GPIO、UART、TIM、ADC、I2C +├── device/ # 设备驱动:SerialPort、PumpMotor、ADCOversample、AS7341 +└── protocol/ # 帧编解码、命令解析、命令分发 +Startup/ +├── Vectors.cpp # 中断向量表(weak 默认处理) +├── CXXStubs.cpp # new/delete 陷阱、静态构造支持 +└── linker.ld # 链接脚本 +``` + +分层关系: + +```mermaid +flowchart LR + P[protocol 协议层] --> D[device 设备驱动] + D --> H[hal 外设驱动] + H --> PL[platform 平台] + PL --> R[register 抽象层] + H -. 寄存器访问 .-> R +``` + +## 二、启动与初始化顺序 + +复位后 `Reset_Handler` 调用 `main()`,按下面的顺序初始化: + +```mermaid +flowchart TD + A[Reset_Handler] --> B[系统时钟 72MHz] + B --> C[SysTick 1ms 时基] + C --> D[NVIC 优先级分组] + D --> E[LED PC13] + E --> F[串口 + 双泵 + ADC + AS7341] + F --> G[启动首轮光谱测量] + G --> H[启动看门狗] + H --> I[进入主循环] + I --> J[命令分发 / 光谱状态机 / ADC 过采样] + J --> K{光谱完成?} + K -- 否 --> I + K -- 是 --> L[自动续采] --> I +``` + +1. 系统时钟:HSE 8MHz × PLL9 → SYSCLK 72MHz,总线分频,ADC 12MHz。 +2. SysTick 1ms 时基(优先级 15)。 +3. NVIC 优先级分组:PRIGROUP=0,4 位抢占,共 16 级。 +4. LED(PC13)。 +5. 串口、双泵、ADC 过采样、AS7341。 +6. 启动首轮光谱测量。 +7. 启动独立看门狗(~5s)。 + +之后进入主循环,每轮依次:命令分发、光谱状态机、ADC 过采样,然后喂狗。光谱测量完成后自动开始下一轮。 + +新增外设时,把它加在第 3 步之后、看门狗启动之前初始化,并把对应的 `service()` 放进主循环。 + +## 三、寄存器抽象层 + +`include/register/` 提供零开销的 MMIO 抽象,全部在编译期完成: + +- `Register`:整寄存器读写,字段读写(RMW),以及 `Set` / `Clear` / `Modify`。 +- `Field`:位域描述,`Mask()` 用 `consteval` 在编译期算出来。 +- `atomic.hpp`:`LDREX` / `STREX` 的 8/16/32 位封装。 +- `concepts.hpp`:约束寄存器值必须是无符号整数、位域不能越界。 + +用 `stm32f103/` 下生成的头文件访问具体外设,例如: + +```cpp +using namespace STM32F103; +RCC::APB2ENR::WriteIOPAEN(1); // 使能 GPIOA 时钟 +GPIOA::BSRR::Write(1u << 5); // PA5 置高 +ADC1::CR2::WriteEXTSEL(4); // ADC 触发源 = TIM3_TRGO +``` + +`stm32f103/` 头文件按 SVD 的 `` 属性生成方法:只读寄存器只有 `Read`,只写只有 `Write`,读写两者都有。不要手动改这些文件,改 SVD 后用 `uv run scripts/generate_stm32f103.py` 重新生成。 + +## 四、中断 + +中断向量在 `Startup/Vectors.cpp` 里以 weak 符号默认指向 `Default_Handler`。要接一个中断,在任意 `.cpp` 里定义同名的 `extern "C"` 函数即可,工程里统一放在 `src/Interrupts.cpp`。 + +优先级约定(0 最高,15 最低): + +| 优先级 | 中断 | 用途 | +|--------|------|------| +| 0 | USART1、DMA1_CH5 | 命令接收,不能丢帧 | +| 1 | TIM4 | 泵脉冲计数 | +| 2 | ADC1_2、I2C1_EV/ER | 采样与光谱 | +| 15 | SysTick | 时基 | + +规则: + +- 中断服务函数要短,只做标志置位和状态推进,耗时的搬移放到主循环。 +- 跨 ISR 共享的变量声明为 `volatile`。 +- 寄存器字段的 RMW 已经用短 PRIMASK 临界区保护;在 ISR 里改共享状态,需要自己处理关中断,见 `register.hpp` 的 `DisableIrqSave` / `RestoreIrq`。 + +## 五、协议扩展 + +上下行帧格式见「通信协议」文档。要加一条新命令: + +1. `include/protocol/FrameCodec.hpp` 的 `downlinkParamLen` 里登记参数长度。 +2. `include/protocol/CommandDispatcher.hpp` 的 `DispatcherHandler::onCommand` 里加 `case`。 +3. 如果命令要触发上行上报,按 `packUplink` 的格式在 `service()` 里加一个发送分支。 +4. 上位机侧在 `crates/controller-core/src/protocol/frames.rs` 同步命令与载荷长度,并加测试。 + +上下行命令码都要在上位机注册,否则帧会被当未知类型丢弃。 + +## 六、约束与注意事项 + +- **无堆**:`new` / `delete` 会触发死循环陷阱。需要动态内存先想清楚静态替代,或用固定大小缓冲。 +- **静态构造**:`.init_array` 在 `main()` 之前由 `Reset_Handler` 调用,支持全局 C++ 对象,但构造顺序按链接顺序,依赖关系要自己保证。 +- **Flash 64KB,RAM 20KB**:当前固件约 9.4KB Flash、1.3KB RAM。新增代码前看一眼 `.map` 文件,别让 .bss 悄悄涨过预算。 +- **看门狗**:初始化后无法关闭,主循环必须周期性 `IWDG_::reload()`。长阻塞操作(如 I2C 恢复的 23ms)里也要喂狗。 +- **I2C 时序**:读寄存器按 RM0008/AN2824 的时序实现,单字节、两字节、多字节各有讲究,改动前先读 `include/hal/I2C.hpp` 里的注释。 +- **泵共享 TIM4**:两个泵共用 TIM4 时基,UPDATE 中断也是共享的。`PumpMotor::stop()` 只在两路都停后才关主计时器和中断。改这段时别破坏这个约定。 + +## 七、构建与验证 + +```sh +scons # 构建 +scons -c # 清理 +scons CROSS=arm-none-eabi- # 指定工具链前缀 +``` + +产物在 `build/` 下:`.elf`、`.hex`、`.map`、`.lst`。烧录用 ST-Link + OpenOCD: + +```sh +openocd -f openocd.cfg +arm-none-eabi-gdb build/AutoTitrator-Firmware.elf -x .gdbinit +``` + +改完跑一次 `scons -c && scons`,确认 0 警告 0 错误,再核对 Flash/RAM 占用有没有异常变化。 \ No newline at end of file diff --git a/docs/firmware-dev-guide_EN.md b/docs/firmware-dev-guide_EN.md new file mode 100644 index 0000000..0ebd481 --- /dev/null +++ b/docs/firmware-dev-guide_EN.md @@ -0,0 +1,147 @@ +# Firmware Development Guide + +[中文](firmware-dev-guide.md) | English + +This document is for developers who modify the firmware. The firmware runs on an STM32F103C8T6, bare-metal C++23, with no HAL, no RTOS, and no standard library. The overall structure, build, and flashing steps are in the root README; pin and interrupt assignments are in the "Hardware Wiring" document; the protocol is in the "Communication Protocol" document. + +## 1. Code layout + +``` +src/ +├── main.cpp # Entry point: initialization + main loop +└── Interrupts.cpp # ISR stub functions +include/ +├── register/ # Cortex-M3 register/MMIO abstraction (header-only) +├── stm32f103/ # 61 peripheral headers generated from SVD +├── platform/ # System clock, SysTick, NVIC, IWDG +├── hal/ # Peripheral drivers: GPIO, UART, TIM, ADC, I2C +├── device/ # Device drivers: SerialPort, PumpMotor, ADCOversample, AS7341 +└── protocol/ # Frame codec, command parser, command dispatcher +Startup/ +├── Vectors.cpp # Interrupt vector table (weak default handlers) +├── CXXStubs.cpp # new/delete traps, static construction support +└── linker.ld # Linker script +``` + +Layer relationships: + +```mermaid +flowchart LR + P[protocol layer] --> D[device drivers] + D --> H[hal drivers] + H --> PL[platform] + PL --> R[register abstraction] + H -. register access .-> R +``` + +## 2. Startup and initialization order + +After reset, `Reset_Handler` calls `main()`, which initializes in this order: + +```mermaid +flowchart TD + A[Reset_Handler] --> B[System clock 72MHz] + B --> C[SysTick 1ms tick] + C --> D[NVIC priority grouping] + D --> E[LED PC13] + E --> F[UART + pumps + ADC + AS7341] + F --> G[Start first spectral sweep] + G --> H[Start watchdog] + H --> I[Enter main loop] + I --> J[Command dispatch / spectral state machine / ADC oversampling] + J --> K{Sweep done?} + K -- No --> I + K -- Yes --> L[Start next sweep] --> I +``` + +1. System clock: HSE 8MHz × PLL9 → SYSCLK 72MHz, bus dividers, ADC 12MHz. +2. SysTick 1ms tick (priority 15). +3. NVIC priority grouping: PRIGROUP=0, 4 preemption bits, 16 levels. +4. LED (PC13). +5. UART, both pumps, ADC oversampling, AS7341. +6. Start the first spectral sweep. +7. Start the independent watchdog (~5s). + +Then the main loop runs: command dispatch, spectral state machine, ADC oversampling, then feed the watchdog. When a spectral sweep finishes, the next one starts automatically. + +To add a peripheral, initialize it after step 3 and before the watchdog starts, and add its `service()` call to the main loop. + +## 3. Register abstraction layer + +`include/register/` provides a zero-overhead MMIO abstraction, all resolved at compile time: + +- `Register`: whole-register read/write, field read/write (RMW), plus `Set`, `Clear`, and `Modify`. +- `Field`: bitfield descriptor; `Mask()` is computed at compile time with `consteval`. +- `atomic.hpp`: 8/16/32-bit wrappers around `LDREX` / `STREX`. +- `concepts.hpp`: constrains register values to unsigned integers and rejects out-of-range bitfields. + +Access concrete peripherals through the generated headers in `stm32f103/`, for example: + +```cpp +using namespace STM32F103; +RCC::APB2ENR::WriteIOPAEN(1); // enable GPIOA clock +GPIOA::BSRR::Write(1u << 5); // set PA5 high +ADC1::CR2::WriteEXTSEL(4); // ADC trigger source = TIM3_TRGO +``` + +The `stm32f103/` headers generate methods according to the SVD `` attribute: read-only registers only get `Read`, write-only only `Write`, read-write get both. Do not edit these files by hand; regenerate them from the SVD with: + +```sh +uv run scripts/generate_stm32f103.py +``` + +## 4. Interrupts + +The vector table in `Startup/Vectors.cpp` declares every handler as a weak symbol pointing at `Default_Handler`. To hook an interrupt, define an `extern "C"` function with the same name in any `.cpp`; the project keeps them in `src/Interrupts.cpp`. + +Priority convention (0 highest, 15 lowest): + +| Priority | Interrupt | Purpose | +|----------|-----------|---------| +| 0 | USART1, DMA1_CH5 | Command reception; must not drop frames | +| 1 | TIM4 | Pump pulse counting | +| 2 | ADC1_2, I2C1_EV/ER | Sampling and spectrum | +| 15 | SysTick | Tick | + +Rules: + +- Keep ISRs short. Set flags and advance state, move heavy work to the main loop. +- Declare variables shared across ISRs as `volatile`. +- Field RMW is already protected by a short PRIMASK critical section. When an ISR modifies shared state, you must handle interrupt disabling yourself; see `DisableIrqSave` / `RestoreIrq` in `register.hpp`. + +## 5. Extending the protocol + +The frame formats are in the "Communication Protocol" document. To add a new command: + +1. Register the parameter length in `include/protocol/FrameCodec.hpp` (`downlinkParamLen`). +2. Add a `case` in `DispatcherHandler::onCommand` in `include/protocol/CommandDispatcher.hpp`. +3. If the command must trigger an uplink report, add a send branch to `service()` following the `packUplink` format. +4. Mirror the command and its payload length in `crates/controller-core/src/protocol/frames.rs` on the host side, and add a test. + +Both directions use registered command codes; anything unregistered is treated as an unknown type and discarded. + +## 6. Constraints and notes + +- **No heap**: `new` / `delete` trigger a trap loop. Before reaching for dynamic memory, find a static alternative or use fixed-size buffers. +- **Static construction**: `.init_array` runs before `main()` via `Reset_Handler`, so global C++ objects work, but construction order follows link order; guarantee dependencies yourself. +- **Flash 64KB, RAM 20KB**: the current firmware uses about 9.4KB of Flash and 1.3KB of RAM. Check the `.map` file after adding code so `.bss` does not quietly exceed the budget. +- **Watchdog**: once initialized it cannot be disabled; the main loop must call `IWDG_::reload()` every iteration. Long blocking operations (such as the 23ms I2C recovery) must also feed the watchdog. +- **I2C timing**: register reads follow the RM0008/AN2824 sequences; single-byte, two-byte, and multi-byte reads each have their own requirements. Read the comments in `include/hal/I2C.hpp` before touching it. +- **Pumps share TIM4**: both pumps share the TIM4 time base and its UPDATE interrupt. `PumpMotor::stop()` disables the timer and interrupt only after both channels have stopped. Do not break this when changing that code. + +## 7. Build and verify + +```sh +scons # build +scons -c # clean +scons CROSS=arm-none-eabi- # specify a toolchain prefix +``` + +Artifacts land in `build/`: `.elf`, `.hex`, `.map`, `.lst`. Flash with ST-Link and OpenOCD: + +```sh +openocd -f openocd.cfg +arm-none-eabi-gdb build/AutoTitrator-Firmware.elf -x .gdbinit +``` + +After changes, run `scons -c && scons` and confirm zero warnings and zero errors, then check that Flash/RAM usage has not changed unexpectedly. \ No newline at end of file diff --git a/docs/hardware-wiring.md b/docs/hardware-wiring.md new file mode 100644 index 0000000..bcd789b --- /dev/null +++ b/docs/hardware-wiring.md @@ -0,0 +1,59 @@ +# 硬件接线 + +[English](hardware-wiring_EN.md) | 中文 + +本文档说明滴定仪主机的引脚分配,供组装样机、排查接线问题的人参考。固件代码按这份分配写死,改电路必须先同步改固件。 + +## 一、引脚分配 + +| 外设 | 引脚 | 方向 | 说明 | +|------|------|------|------| +| USART1 | PA9 (TX) / PA10 (RX) | 输出 / 输入 | 上位机串口,115200-8N1 | +| I2C1 | PB8 (SCL) / PB9 (SDA) | 开漏 | AS7341 光谱传感器,100kHz,需 AFIO 重映射 | +| ADC1 | PA0 | 输入 | 电位测量,TIM3 触发 | +| TIM3 | — | — | ADC 触发时基,1kHz | +| TIM4 | PB6 (CH1) / PB7 (CH2) | 输出 | 双泵 PWM,1kHz,50% 占空比 | +| DMA1_CH5 | — | — | USART1 接收 DMA | +| GPIO | PC13 | 输出 | LED 状态指示 | + +## 二、接线说明 + +### 串口(USART1) + +PA9 接 USB 转串口板的 RX,PA10 接 TX,共地。这个串口是上位机通信口,波特率固定 115200。注意:这两个引脚也要复用功能输出/输入,别和 I2C 或 PWM 引脚混。 + +### 光谱传感器(I2C1 → AS7341) + +PB8(SCL)、PB9(SDA)接 AS7341 的对应引脚,两个引脚都要上拉到 3.3V(开漏模式)。I2C1 需要 AFIO 重映射,固件里 `AFIO::MAPR::WriteI2C1_REMAP(1)` 已经做好。AS7341 的地址是 0x39(7 位)。 + +### 电位测量(ADC1 → PA0) + +电极端接 PA0,参考和地线接好。电位信号是模拟量,采样用 TIM3 在 1kHz 触发。接线时尽量让信号线和泵的 PWM 线分开走,减少干扰。 + +### 蠕动泵(TIM4 → PB6/PB7) + +PB6(CH1)驱动泵 1(进样泵),PB7(CH2)驱动泵 2(滴定泵)。PWM 是 1kHz、50% 占空比,泵的启停由固件控制,不需要外部使能引脚。改泵驱动板时注意共地。 + +### LED(PC13) + +板载 LED,程序启动后点亮,用于指示系统运行状态。 + +## 三、12V/3.3V 供电 + +- MCU 和传感器用 3.3V。 +- 蠕动泵电机需要独立电源(通常 12V),通过驱动板接电。 +- 泵和传感器的地必须和 MCU 共地,否则 PWM 信号会乱。 + +## 四、改接线的注意事项 + +上面的引脚分配散落在多个头文件里: + +| 功能 | 位置 | +|------|------| +| 串口引脚 | `include/hal/UART.hpp` 的 `GPIO::configure` | +| I2C 引脚 | `include/hal/I2C.hpp` 的 `GPIO::configure` | +| ADC 引脚 | `include/hal/ADC.hpp` | +| 泵 PWM | `include/hal/TIM.hpp` 的 `initTIM4` | +| LED | `src/main.cpp` | + +换接线时逐个改,改完跑 `scons` 验证。I2C 重映射、ADC 触发源、TIM4 通道这些配置是互相牵连的,不要只改一处。 \ No newline at end of file diff --git a/docs/hardware-wiring_EN.md b/docs/hardware-wiring_EN.md new file mode 100644 index 0000000..167498d --- /dev/null +++ b/docs/hardware-wiring_EN.md @@ -0,0 +1,59 @@ +# Hardware Wiring + +[中文](hardware-wiring.md) | English + +This document describes the pin assignments of the titrator main unit, for people assembling a prototype or debugging wiring. The firmware hard-codes these assignments; if you change the circuit, update the firmware first. + +## 1. Pin assignments + +| Peripheral | Pin | Direction | Notes | +|------------|-----|-----------|-------| +| USART1 | PA9 (TX) / PA10 (RX) | output / input | Host serial port, 115200-8N1 | +| I2C1 | PB8 (SCL) / PB9 (SDA) | open-drain | AS7341 spectral sensor, 100kHz, requires AFIO remap | +| ADC1 | PA0 | input | Potential measurement, triggered by TIM3 | +| TIM3 | — | — | ADC trigger time base, 1kHz | +| TIM4 | PB6 (CH1) / PB7 (CH2) | output | Dual-pump PWM, 1kHz, 50% duty | +| DMA1_CH5 | — | — | USART1 receive DMA | +| GPIO | PC13 | output | LED status indicator | + +## 2. Wiring notes + +### Serial port (USART1) + +PA9 goes to the RX of a USB-serial adapter, PA10 to its TX, and the grounds are shared. This port talks to the host application at a fixed 115200 baud. Note that these pins use the alternate-function mode; do not mix them with the I2C or PWM pins. + +### Spectral sensor (I2C1 → AS7341) + +Connect PB8 (SCL) and PB9 (SDA) to the matching AS7341 pins, and pull both up to 3.3V (open-drain mode). I2C1 needs the AFIO remap, which the firmware already does in `AFIO::MAPR::WriteI2C1_REMAP(1)`. The AS7341 address is 0x39 (7-bit). + +### Potential measurement (ADC1 → PA0) + +Connect the electrode to PA0 and wire the reference and ground properly. The potential is analog and sampled by TIM3 at 1kHz. Keep the signal traces away from the pump PWM lines to reduce interference. + +### Peristaltic pumps (TIM4 → PB6/PB7) + +PB6 (CH1) drives pump 1 (sample pump); PB7 (CH2) drives pump 2 (titrant pump). The PWM runs at 1kHz with a 50% duty cycle. The firmware controls start and stop; no external enable pin is needed. Make sure the pump driver board shares a ground. + +### LED (PC13) + +The on-board LED lights after startup and indicates the system is running. + +## 3. Power supply + +- The MCU and sensors use 3.3V. +- The peristaltic pump motors need a separate supply (usually 12V) through a driver board. +- The pump and sensor grounds must share the MCU ground, or the PWM signals will be corrupted. + +## 4. Notes on rewiring + +The pin assignments are scattered across several headers: + +| Function | Location | +|----------|----------| +| Serial pins | `GPIO::configure` in `include/hal/UART.hpp` | +| I2C pins | `GPIO::configure` in `include/hal/I2C.hpp` | +| ADC pin | `include/hal/ADC.hpp` | +| Pump PWM | `initTIM4` in `include/hal/TIM.hpp` | +| LED | `src/main.cpp` | + +Change them one at a time and run `scons` to verify. The I2C remap, ADC trigger source, and TIM4 channels depend on each other; do not change only one of them. \ No newline at end of file diff --git a/docs/host-user-guide.md b/docs/host-user-guide.md new file mode 100644 index 0000000..1b36566 --- /dev/null +++ b/docs/host-user-guide.md @@ -0,0 +1,131 @@ +# 上位机使用手册 + +[English](host-user-guide_EN.md) | 中文 + +TController 是 AutoTitrator 的桌面端软件,跑在 Windows、macOS 或 Linux 电脑上,通过串口连接滴定仪主机,完成标定、滴定和结果查看。 + +本手册写给使用这台仪器做实验的人。开发相关的说明见「上位机开发者文档」,与主机通信的细节见「通信协议」文档。 + +## 一、安装与启动 + +### Windows / macOS + +从发布页下载对应平台的安装包: + +- Windows:`.msi` 或 `-setup.exe` +- macOS:`.dmg` + +macOS 的应用没有签名。第一次打开时,在应用上点右键选「打开」;如果系统仍拦截,在终端执行: + +```sh +xattr -dr com.apple.quarantine /Applications/TController.app +``` + +### Linux + +下载 `-portable.zip` 解压后直接运行里面的可执行文件,或安装 `.deb` / `.rpm`(`sudo dpkg -i` / `sudo rpm -i`)。`.tar.gz` 是把 deb 的文件树原样打包的,解压到根目录即可: + +```sh +sudo tar -xzf TController_*.tar.gz -C / +``` + +Linux 产物需要在 glibc 2.39 以上的系统运行(Ubuntu 24.04 及更新)。 + +### 数据文件 + +上位机运行时会读取 `calibre.npz`,里面有光谱重建矩阵和泵标定参数,必须和程序放在一起。找不到时上位机会降级:光谱通道不做重建,泵用默认参数。`settings.json` 保存界面偏好和运行历史,由程序自动生成,可以删除,删了会回到默认设置。 + +## 二、连接仪器 + +1. 用 USB 线把滴定仪主机连到电脑,主机会枚举出一个串口。 +2. 打开上位机,在工具栏选端口,波特率保持 115200。 +3. 点「连接」。连上后状态栏显示已连接,事件日志出现一条握手成功记录。 + +如果列表里没有端口,检查线缆和驱动,然后点一次刷新。一次只能连一台仪器。 + +## 三、标定 + +标定分两部分:泵标定和光谱标定。 + +### 泵标定 + +泵标定是把"泵步数"和"实际液体体积"对应起来。蠕动泵用久了会磨损,换管后也需要重新标。 + +1. 切到「标定」页,泵 2 工作区。 +2. 选滴定泵(泵 2),在「排出」输入一个步数,点「排出」,把液体排到称量容器。 +3. 记录称量得到的体积,点「记录点」。 +4. 重复几次,覆盖从空管到接近满程的范围,至少 10 个点。 +5. 左侧出现拟合预览和斜率、截距、R²。R² 接近 1 说明线性好,标定可用。 +6. 满意后点「应用标定」,程序把参数写回 `pump2_calibration.json`。 + +清空点会放弃本次拟合,不会影响已应用的标定。 + +### 光谱标定 + +光谱标定使用出厂时测得的 Golden Device 矩阵,放在 `calibre.npz` 的 `spectral_matrix` 键里。上位机只负责加载和展示,不在这里做重新标定。「标定」页顶部的色块和曲线是这张矩阵的样子,方便核对加载的是不是预期数据。 + +## 四、做一次滴定 + +1. 在「滴定工作台」设置样品体积(进样量,mL)。 +2. 打开「滴定剂浓度」和「计量比 a∶b」,终点确认后会自动算出分析物浓度。 +3. 点「开始滴定」。 + +工作流程是自动的: + +```mermaid +flowchart TD + A[设置样品体积与浓度] --> B[开始滴定] + B --> C[进样泵 泵1 打进样品] + C --> D{进样完成?} + D -- 否 --> C + D -- 是 --> E[滴定泵 泵2 匀速加液] + E --> F[电位+光谱 双路判定] + F --> G{T=1 初判?} + G -- 否 --> E + G -- 是 --> H[继续过量滴定至 2 倍体积] + H --> I[自动停泵, AMPD 精修] + I --> J[显示最终终点] +``` + +1. 进样泵(泵 1)把样品打进反应杯,直到设定的体积。 +2. 进样完成,滴定泵(泵 2)开始以固定速度加滴定剂。 +3. 上位机实时看电位曲线和光谱,双路信号在终点附近会先后给出判断。 +4. T=1 初判出现后提示一个候选终点,同时继续过量滴定直到 2 倍体积。 +5. 到达后自动停泵,用 AMPD 对电位曲线做一次离线精修,给出最终终点。 + +滴定过程中随时可以「手动停止」(会立即用已有数据做精修),或「中止」(回到待机,保留曲线不写入结果)。「急停」会立刻让所有泵停下并复位工作流。 + +结果面板显示终点体积、判定方法、置信度、可靠性,以及由滴定剂浓度和计量比换算出的分析物浓度。 + +## 五、查看结果与导出 + +「数据记录」页列出历次运行:时间、时长、样品量、终点、方法、置信度、状态。支持导出 CSV,字段包含时间、体积、电位、泵位置等,用表格软件打开即可。 + +同一次滴定的记录在结束后自动写入,最多保留最近 30 条。 + +## 六、日常维护 + +### 管路操作 + +「滴定工作台」和「维护」页都提供管路操作: + +- 预充:把滴定剂灌满管路。入口放进液体里,看到管里没有气泡后停止。 +- 排空:把管路里的液体清掉。出口放进废液杯,排空后停止。 + +管路操作使用自由运行模式,需要人工观察并点停止,程序不会自动停。 + +### 看门狗 + +固件带一个看门狗:上位机每 1 秒发一次心跳,5 秒收不到就判定断线,自动停泵。默认开启。「维护」页可以关掉,但建议保持开启,避免拔线后泵失控。 + +## 七、常见问题 + +**连不上串口。** 看端口列表里有没有设备,重新插拔 USB;macOS/Linux 检查是否有权限访问串口设备节点。 + +**标定拟合的 R² 很低。** 检查是否换了管没重新标、管路里有没有气泡、称量读数是否准确。 + +**光谱区域显示"等待数据"。** 检查 AS7341 接线(见硬件接线文档)和 `calibre.npz` 是否存在。 + +**结果里没有分析物浓度。** 需要在开始滴定前设置滴定剂浓度和计量比。 + +**程序打不开。** macOS 未签名应用需要右键打开并清除隔离属性;Linux 检查 glibc 版本。 \ No newline at end of file diff --git a/docs/host-user-guide_EN.md b/docs/host-user-guide_EN.md new file mode 100644 index 0000000..c7813a8 --- /dev/null +++ b/docs/host-user-guide_EN.md @@ -0,0 +1,131 @@ +# Host Application User Guide + +[中文](host-user-guide.md) | English + +TController is the desktop application for AutoTitrator. It runs on Windows, macOS, or Linux, connects to the titrator main unit over a serial port, and handles calibration, titration, and result review. + +This guide is for people who run experiments on the instrument. Developers should read the "Host Application Developer Guide", and anyone integrating with the communication protocol should read the "Communication Protocol" document. + +## 1. Installation and startup + +### Windows / macOS + +Download the installer for your platform from the releases page: + +- Windows: `.msi` or `-setup.exe` +- macOS: `.dmg` + +The macOS app is not signed. On first launch, right-click the app and choose "Open". If the system still blocks it, run this in a terminal: + +```sh +xattr -dr com.apple.quarantine /Applications/TController.app +``` + +### Linux + +Download `-portable.zip`, unpack it, and run the executable inside. Alternatively install the `.deb` or `.rpm` package (`sudo dpkg -i` / `sudo rpm -i`). The `.tar.gz` archive contains the same file tree as the deb package; unpack it at the root: + +```sh +sudo tar -xzf TController_*.tar.gz -C / +``` + +The Linux build requires glibc 2.39 or newer (Ubuntu 24.04 and later). + +### Data files + +The application reads `calibre.npz` at startup. It holds the spectral reconstruction matrix and the pump calibration parameters and must be placed next to the executable. If it is missing, the application degrades: the spectral channel is not reconstructed and the pumps fall back to default parameters. `settings.json` stores interface preferences and run history; it is created automatically and can be deleted to reset to defaults. + +## 2. Connecting to the instrument + +1. Connect the titrator main unit to the computer with a USB cable. The unit enumerates a serial port. +2. Open the application, select the port in the toolbar, and keep the baud rate at 115200. +3. Click "Connect". When connected, the status bar shows Connected and a handshake success entry appears in the event log. + +If no port appears in the list, check the cable and driver, then refresh. Only one instrument can be connected at a time. + +## 3. Calibration + +Calibration has two parts: pump calibration and spectral calibration. + +### Pump calibration + +Pump calibration maps pump steps to actual liquid volume. Peristaltic pumps wear with use, so recalibrate after replacing the tubing. + +1. Switch to the Calibration page, pump 2 workbench. +2. Select the titrant pump (pump 2), enter a step count in "Dispense", and click "Dispense" to pump liquid into a weighing container. +3. Record the weighed volume and click "Add point". +4. Repeat several times, covering from empty tubing to near full stroke. At least 10 points. +5. The fit preview, slope, intercept, and R² appear on the left. An R² close to 1 means the fit is good and the calibration is usable. +6. When satisfied, click "Apply". The application writes the parameters to `pump2_calibration.json`. + +Clearing the points discards the current fit and does not affect the applied calibration. + +### Spectral calibration + +Spectral calibration uses the Golden Device matrix measured at the factory, stored in the `spectral_matrix` key of `calibre.npz`. The application only loads and displays this matrix; it does not recalibrate here. The color swatches and curves at the top of the Calibration page show what was loaded, so you can verify it matches expectations. + +## 4. Running a titration + +1. On the Titration workbench, set the sample volume (injection volume, mL). +2. Set the titrant concentration and the stoichiometric ratio a∶b. The analyte concentration is computed automatically once the endpoint is confirmed. +3. Click "Start". + +The workflow is automatic: + +```mermaid +flowchart TD + A[Set sample volume and concentration] --> B[Start] + B --> C[Sample pump 1 injects sample] + C --> D{Injection done?} + D -- No --> C + D -- Yes --> E[Titrant pump 2 adds at fixed rate] + E --> F[Potential + spectral detection] + F --> G{T=1 reached?} + G -- No --> E + G -- Yes --> H[Over-titrate to 2x volume] + H --> I[Stop; AMPD refinement] + I --> J[Show final endpoint] +``` + +1. The sample pump (pump 1) injects sample into the reaction vessel until the set volume. +2. When injection finishes, the titrant pump (pump 2) adds titrant at a fixed rate. +3. The application watches the potential curve and the spectrum live; the two signals each give a verdict near the endpoint. +4. After the T=1 first pass, it reports a candidate endpoint and continues over-titrating to 2x the volume. +5. At that point it stops automatically and runs AMPD on the potential curve offline to produce the final endpoint. + +At any time during titration you can click "Manual stop" (it refines with the data collected so far) or "Abort" (returns to idle, keeps the curves, does not write a result). "E-STOP" stops all pumps immediately and resets the workflow. + +The results panel shows the endpoint volume, method, confidence, reliability, and the analyte concentration derived from the titrant concentration and the ratio. + +## 5. Reviewing results and exporting + +The Records page lists each run: time, duration, sample volume, endpoint, method, confidence, and status. It supports CSV export with fields such as time, volume, potential, and pump position, which you can open in a spreadsheet. + +Each run is written automatically when it finishes. The list keeps the most recent 30 runs. + +## 6. Routine maintenance + +### Tubing operations + +Both the Titration workbench and the Maintenance page offer tubing operations: + +- Prime: fill the tubing with titrant. Put the inlet into the liquid and stop once no bubbles remain. +- Empty: clear liquid out of the tubing. Put the outlet into a waste cup and stop when empty. + +Tubing operations run in free-run mode; watch them and click stop manually. The application does not stop them automatically. + +### Watchdog + +The firmware has a watchdog: the application sends a heartbeat every second, and if none arrives within 5 seconds the firmware considers the link lost and stops the pumps. It is enabled by default. The Maintenance page can disable it, but it is safer to keep it on so the pumps cannot run away after the cable is unplugged. + +## 7. Troubleshooting + +**Cannot connect to the serial port.** Check that a device appears in the port list and re-seat the cable. On macOS/Linux, check that you have permission to access the serial device node. + +**The calibration fit has a low R².** Check whether the tubing was changed without recalibrating, whether there are bubbles in the tubing, and whether the weighing readings are accurate. + +**The spectral area shows "Waiting for data".** Check the AS7341 wiring (see the Hardware Wiring document) and whether `calibre.npz` exists. + +**No analyte concentration in the result.** Set the titrant concentration and the ratio before starting the titration. + +**The application will not open.** On macOS, the unsigned app needs right-click Open and the quarantine attribute removed. On Linux, check the glibc version. \ No newline at end of file diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..0ba993d --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,104 @@ +# 通信协议 + +[English](protocol_EN.md) | 中文 + +固件(STM32F103)与上位机(TController)通过串口通信。本文档描述两者之间的帧格式、命令和事件,供需要对接协议或排查通信问题的开发者阅读。实现代码见固件 `include/protocol/` 与后端 `crates/controller-core/src/protocol/`。 + +## 一、总体 + +- 串口参数:115200-8N1。 +- 帧分两类:上行帧(固件发给上位机)和下行帧(上位机发给固件)。 +- 所有多字节整数都是小端序。 +- 每帧带一个 CRC-8 校验,多项式 `0x31`(Maxim-Dallas 变体),初值 0。 + +上行帧以 `AA 55` 开头,下行帧以 `BB 55` 开头。帧正文(类型/命令 + 数据)参与 CRC 计算,帧头不参与。 + +``` +上行:AA 55 | 类型(1B) | 数据(NB) | CRC8(1B) +下行:BB 55 | 命令(1B) | 参数(NB) | CRC8(1B) +``` + +最大帧长 26 字节(光谱帧),小于固件发送缓冲 32 字节与接收缓冲 64 字节,不会截断。 + +## 二、下行命令(上位机 → 固件) + +| 命令 | 名称 | 参数 | 固件行为 | +|------|------|------|----------| +| 0x01 | MaxCount | pump_id(1) + count(4) | 泵定量运行,到达 count 步后自动停止 | +| 0x02 | FreeRun | pump_id(1) | 泵自由运行,直到收到停止命令 | +| 0x03 | FreeStop | pump_id(1),0xFF 表示全部 | 正常停止,停止后无位置上报 | +| 0x04 | AbortAll | pump_id(1),0xFF 表示全部 | 紧急停止,功能与 0x03 相同,语义用于异常场景 | +| 0x05 | Heartbeat | 0x01 使能,0x00 关闭 | 使能后固件每 1 秒回一条心跳帧;同时开启看门狗 | +| 0x06 | Reset | 无 | 回 ACK,等 10 毫秒后复位 MCU | + +泵号 1 是进样泵(TIM4 CH1),泵号 2 是滴定泵(TIM4 CH2)。 + +命令执行成功回 `0x00 ACK`,参数非法或未知命令回 `0x01 NAK`。0x03 与 0x04 实现完全相同,保留两个命令码是为了在日志里区分正常停止与异常中止。 + +## 三、上行帧(固件 → 上位机) + +| 类型 | 名称 | 数据 | 触发条件 | +|------|------|------|----------| +| 0x00 | ACK | echo_cmd(1) | 收到合法命令 | +| 0x01 | NAK | echo_cmd(1) | 校验失败或未知命令 | +| 0x10 | PumpPos | pump_id(1) + position(4) | 每累计 1000 脉冲上报一次 | +| 0x11 | PumpDone | pump_id(1) + position(4) | MaxCount 定量完成 | +| 0x20 | ADC | sum(4) + samples(2) + shift(1) + pump2_pos(4) | 一次过采样完成(256 次,由主循环拉取) | +| 0x30 | Spectral | 10 × uint16 LE + reserved(2),共 22 字节 | AS7341 一轮扫描完成 | +| 0x40 | Heartbeat | uptime_ms(4) | 心跳使能后每 1 秒 | + +### 0x20 ADC 帧说明 + +固件对 ADC1 做 256 次累加过采样。原始值是 12 位右对齐,累加后得到 `sum`,实际有效读数 = `sum >> shift` 再取低 16 位。`samples` 是参与累加的采样次数(正常为 256),`shift` 是右移位数(正常为 4)。 + +`pump2_pos` 是过采样结束时刻泵 2 的脉冲计数,上位机把它换算成滴定剂体积,用来把电位数据和体积画在同一条时间轴上。电位换算公式(软件放大后的偏移值): + +$$ +V(\text{V}) = \frac{(\text{sum} \gg \text{shift}) \times 3.3}{65535} - 1.1 +$$ + +### 0x30 Spectral 帧说明 + +AS7341 有 8 个可见光通道(F1 到 F8)加一个 Clear 和一个 NIR,共 10 个 16 位值,小端序排列。两个 phase 各扫一次,Clear 和 NIR 在固件里取了两次平均。帧尾 2 字节保留,固定为 0。 + +通道波长对应:F1≈415nm,F2≈445nm,F3≈480nm,F4≈515nm,F5≈555nm,F6≈590nm,F7≈630nm,F8≈680nm,Clear 全波段,NIR≈910nm。 + +## 四、时序与重试 + +上位机发送命令后等待 ACK 或 NAK: + +- 首包超时 100 毫秒。 +- 收到 NAK 或首包超时,按指数退避重传,最多重试 5 次。 +- 重试耗尽后上位机发送 `AbortAll`(0x04 0xFF)并报「下位机通讯异常」。 + +任一时刻只有一条命令在等待确认。心跳不占用这个槽位:有命令在等确认时,心跳帧直接跳过,避免覆盖命令的确认状态。 + +串口读取本身不设命令级超时,空闲时每 5 毫秒轮询一次。 + +```mermaid +sequenceDiagram + participant H as 上位机 + participant F as 固件 + H->>F: 下行命令 + F-->>H: NAK 或 100ms 无响应 + Note over H: 退避 50ms + H->>F: 重传 + F-->>H: NAK + Note over H: 退避 100ms + H->>F: 重传 + Note over H: …再重传两次… + H->>F: 第 5 次重传 + F-->>H: NAK + Note over H: 耗尽 → 发 AbortAll + 报错 +``` + +## 五、看门狗 + +固件看门狗由下行 `0x05` 使能。上位机连接期间每 1 秒发一次心跳;固件 5 秒没有收到心跳就把两个泵都停下,防止上位机掉线后泵失控。心跳帧同时带一个 `uptime_ms`,上位机用它显示固件运行时间。 + +## 六、边界情况 + +- **CRC 错误的上行帧**:固件收到 CRC 不对的下行帧回 NAK;上位机收到坏的上行帧直接丢弃,不产生事件。 +- **未知类型**:两端都丢弃并继续等下一帧。 +- **重同步**:上位机解析器允许在 `AA` 和 `55` 之间夹杂干扰字节后重新同步;连续两个 `BB` 时,固件把第二个 `BB` 当作新帧头处理。 +- **ACK/NAK 失配**:上位机收到与当前等待命令号不符的 ACK 时,记一条错误事件便于排查,不会清掉当前 pending 命令。 \ No newline at end of file diff --git a/docs/protocol_EN.md b/docs/protocol_EN.md new file mode 100644 index 0000000..aed9180 --- /dev/null +++ b/docs/protocol_EN.md @@ -0,0 +1,104 @@ +# Communication Protocol + +[中文](protocol.md) | English + +The firmware (STM32F103) and the host application (TController) communicate over a serial port. This document describes the frame formats, commands, and events between the two, for developers who need to integrate with the protocol or debug communication issues. The implementation lives in the firmware `include/protocol/` directory and the backend `crates/controller-core/src/protocol/` crate. + +## 1. Overview + +- Serial parameters: 115200-8N1. +- Frames come in two directions: uplink (firmware to host) and downlink (host to firmware). +- All multi-byte integers are little-endian. +- Every frame carries a CRC-8 checksum, polynomial `0x31` (Maxim-Dallas variant), initial value 0. + +Uplink frames start with `AA 55`; downlink frames start with `BB 55`. The CRC covers the body (type/command + data) but not the header. + +``` +Uplink: AA 55 | type(1B) | data(NB) | CRC8(1B) +Downlink: BB 55 | cmd(1B) | params(NB) | CRC8(1B) +``` + +The largest frame is 26 bytes (the spectral frame), smaller than the firmware's 32-byte transmit buffer and 64-byte receive buffer, so frames never truncate. + +## 2. Downlink commands (host → firmware) + +| Command | Name | Parameters | Firmware behavior | +|---------|------|------------|-------------------| +| 0x01 | MaxCount | pump_id(1) + count(4) | Run the pump for a fixed number of steps, then stop automatically | +| 0x02 | FreeRun | pump_id(1) | Run the pump until a stop command is received | +| 0x03 | FreeStop | pump_id(1), 0xFF means all | Normal stop; no position report after stopping | +| 0x04 | AbortAll | pump_id(1), 0xFF means all | Emergency stop; functionally identical to 0x03, kept for semantic distinction | +| 0x05 | Heartbeat | 0x01 enables, 0x00 disables | When enabled, the firmware replies with a heartbeat frame every second and arms the watchdog | +| 0x06 | Reset | none | Reply ACK, wait 10 ms, then reset the MCU | + +Pump 1 is the sample pump (TIM4 CH1); pump 2 is the titrant pump (TIM4 CH2). + +A successful command is answered with `0x00 ACK`; invalid parameters or an unknown command are answered with `0x01 NAK`. Commands 0x03 and 0x04 are implemented identically; both codes are kept so logs can distinguish a normal stop from an abnormal abort. + +## 3. Uplink frames (firmware → host) + +| Type | Name | Data | Trigger | +|------|------|------|---------| +| 0x00 | ACK | echo_cmd(1) | Valid command received | +| 0x01 | NAK | echo_cmd(1) | Checksum failure or unknown command | +| 0x10 | PumpPos | pump_id(1) + position(4) | Every 1000 accumulated pulses | +| 0x11 | PumpDone | pump_id(1) + position(4) | MaxCount run completed | +| 0x20 | ADC | sum(4) + samples(2) + shift(1) + pump2_pos(4) | One oversampling run finished (256 samples, pulled by the main loop) | +| 0x30 | Spectral | 10 × uint16 LE + reserved(2), 22 bytes total | One AS7341 sweep finished | +| 0x40 | Heartbeat | uptime_ms(4) | Every second while heartbeat is enabled | + +### The 0x20 ADC frame + +The firmware accumulates 256 samples of the ADC1. The raw value is 12-bit right-justified; the accumulation produces `sum`. The effective reading is `sum >> shift` truncated to the low 16 bits. `samples` is the number of samples accumulated (normally 256) and `shift` is the right-shift amount (normally 4). + +`pump2_pos` is the pump 2 pulse count when the oversampling run finished. The host converts it to titrant volume so that potential and volume share a time axis. The potential conversion (including the software offset): + +$$ +V(\text{V}) = \frac{(\text{sum} \gg \text{shift}) \times 3.3}{65535} - 1.1 +$$ + +### The 0x30 Spectral frame + +The AS7341 has eight visible-band channels (F1 to F8) plus Clear and NIR: ten 16-bit values, little-endian. Each scan has two phases; the firmware averages Clear and NIR over the two phases. The last 2 bytes are reserved and fixed to 0. + +Channel wavelengths: F1≈415nm, F2≈445nm, F3≈480nm, F4≈515nm, F5≈555nm, F6≈590nm, F7≈630nm, F8≈680nm, Clear full-band, NIR≈910nm. + +## 4. Timing and retries + +After sending a command, the host waits for an ACK or NAK: + +- First-packet timeout: 100 ms. +- On NAK or first-packet timeout, the host retransmits with exponential backoff, up to 5 attempts. +- When retries are exhausted, the host sends `AbortAll` (0x04 0xFF) and reports "lower-computer communication error". + +Only one command waits for confirmation at a time. Heartbeats do not occupy this slot: while a command is pending, heartbeat frames are skipped so they cannot overwrite the command's confirmation state. + +Serial reads have no command-level timeout; the port is polled every 5 ms while idle. + +```mermaid +sequenceDiagram + participant H as Host + participant F as Firmware + H->>F: Downlink command + F-->>H: NAK or no reply for 100ms + Note over H: Backoff 50ms + H->>F: Retransmit + F-->>H: NAK + Note over H: Backoff 100ms + H->>F: Retransmit + Note over H: ...retransmit twice more... + H->>F: 5th retransmit + F-->>H: NAK + Note over H: Exhausted → send AbortAll + report error +``` + +## 5. Watchdog + +The firmware watchdog is armed by the downlink command `0x05`. While connected, the host sends a heartbeat every second. If the firmware receives no heartbeat within 5 seconds, it stops both pumps to prevent them from running away after the host disconnects. The heartbeat frame also carries `uptime_ms`, which the host uses to show the firmware uptime. + +## 6. Edge cases + +- **Uplink frame with a bad CRC**: the firmware replies NAK to a downlink frame with a bad CRC; the host drops a corrupted uplink frame without generating an event. +- **Unknown type**: both ends discard it and keep waiting for the next frame. +- **Resynchronization**: the host parser allows stray bytes between `AA` and `55` and still resynchronizes; on two consecutive `BB` bytes, the firmware treats the second `BB` as a new frame header. +- **Mismatched ACK/NAK**: when the host receives an ACK whose command number does not match the pending command, it logs an error event for diagnosis without clearing the pending command. \ No newline at end of file From 82a3b936068097f9f6cd0ff08d8a6a9a16e2e3f5 Mon Sep 17 00:00:00 2001 From: ZhiYi-R Date: Wed, 26 Aug 2026 05:29:47 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E6=96=87=E6=A1=A3(=E7=AE=97=E6=B3=95?= =?UTF-8?q?=E6=8A=A5=E5=91=8A)=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=A4=9A?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E8=9E=8D=E5=90=88=E7=AE=97=E6=B3=95=E6=8A=80?= =?UTF-8?q?=E6=9C=AF=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增算法技术报告(中英双语),描述电位/光谱双通道、KF 融合、AMPD 微调与可靠性诊断 - 公式以 LaTeX 嵌入,架构图以 mermaid 绘制 - 中英文文档互相链接,根 README 文档索引补充算法报告 - 打磨文档与代码注释的中文表述(微调、假阳性、推送给、防止回归等) - 消除对仓库外本地数据 tmp_diff 的悬空引用 --- README.md | 1 + README_EN.md | 1 + TController/README.md | 6 +- TController/README_EN.md | 2 +- .../src/processing/endpoint.rs | 11 +- .../controller-core/src/protocol/handler.rs | 2 +- .../crates/controller-core/src/workflow.rs | 8 +- .../controller-core/tests/tmp_diff_python.rs | 10 +- docs/algorithm-report.md | 201 +++++++++++++++++ docs/algorithm-report_EN.md | 203 ++++++++++++++++++ docs/host-user-guide.md | 10 +- 11 files changed, 430 insertions(+), 25 deletions(-) create mode 100644 docs/algorithm-report.md create mode 100644 docs/algorithm-report_EN.md diff --git a/README.md b/README.md index 288184a..f123438 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ | [固件二次开发指南](docs/firmware-dev-guide.md) | 固件开发者 | 代码布局、初始化、寄存器层、中断、协议扩展 | | [硬件接线](docs/hardware-wiring.md) | 组装样机的人 | 引脚分配、接线说明、供电 | | [标定与数据格式](docs/data-formats.md) | 处理数据的人 | calibre.npz 结构、sidecar、settings.json | +| [算法技术报告](docs/algorithm-report.md) | 关注算法的开发者 | 多模态融合、终点判定、参数与验证 | ## 目录结构 diff --git a/README_EN.md b/README_EN.md index a16c773..a519688 100644 --- a/README_EN.md +++ b/README_EN.md @@ -27,6 +27,7 @@ A multimodal automatic titration controller. The STM32F103 bare-metal firmware d | [Firmware Development Guide](docs/firmware-dev-guide_EN.md) | firmware developers | code layout, initialization, register layer, interrupts, protocol extension | | [Hardware Wiring](docs/hardware-wiring_EN.md) | people assembling prototypes | pin assignments, wiring notes, power supply | | [Calibration and Data Formats](docs/data-formats_EN.md) | people working with data | calibre.npz structure, sidecar, settings.json | +| [Algorithm Technical Report](docs/algorithm-report_EN.md) | developers interested in the algorithm | multimodal fusion, endpoint detection, parameters, verification | ## Directory layout diff --git a/TController/README.md b/TController/README.md index 69dddfe..19747e4 100644 --- a/TController/README.md +++ b/TController/README.md @@ -39,9 +39,9 @@ TController/ `tests/endpoint_reliability.rs` 保留了算法从 Python 移植时定下的行为约定:JS 散度对称有界、特征只用历史样本(因果)、重复体积帧保持速度电平、后发事件要强 1.5 倍才能顶替旧候选、舍入下界以下的散度不归一化、端点对变化时 KF 重新融合、AMPD 与稠密参照实现逐点一致。改动这些行为前先跑测试,改完测试会明确告诉你有哪里不一致。 -`tests/workflow.rs` 固化了一次实际发生过的 T=1 死锁回归:当双模态都确认但差距过大(conflict)时,只要电位证据在就必须放行 T=1;只有光谱、没有电位证据时不能控泵。场景见该文件顶部注释。 +`tests/workflow.rs` 防止一次实际发生过的 T=1 死锁回归:当双模态都确认但差距过大(conflict)时,我们取电位数据作为 T=1 的判据;只有光谱、没有电位证据时不控泵。场景见该文件顶部注释。 -AMPD 精修在记录太短(尾部峰没有大尺度覆盖)时返回 `None`,这是预期行为;savgol 边缘填充与参照算法的边缘半窗口行为一致。 +AMPD 微调在记录太短(尾部峰没有大尺度覆盖)时返回 `None`,这是预期行为;savgol 边缘填充与参照算法的边缘半窗口行为一致。 ## 开发环境 @@ -71,7 +71,7 @@ npm run build npm run lint ``` -`savgol`、`ampd`、`endpoint` 等测试把 Python 参照实现的行为固化成断言,改数值算法时优先看这些测试是否还能过。`tests/tmp_diff_python.rs` 是一次性差分测试,需要先跑 `tmp_diff/dump_python.py` 生成对照数据,缺文件时自动跳过。 +`savgol`、`ampd`、`endpoint` 等测试把 Python 参照实现的行为写成断言,改数值算法时优先看这些测试是否还能过。`tests/tmp_diff_python.rs` 是一次性差分测试,需要仓库外的本地生成数据(`tmp_diff/dataA_python.json`)才能跑,缺文件时自动跳过。 ## 双数据源:mock 与真实后端 diff --git a/TController/README_EN.md b/TController/README_EN.md index 2ce5cba..b18c59c 100644 --- a/TController/README_EN.md +++ b/TController/README_EN.md @@ -71,7 +71,7 @@ npm run build npm run lint ``` -Tests for `savgol`, `ampd`, `endpoint`, and others freeze the behavior of the Python reference implementation as assertions. When you change a numerical algorithm, look at these tests first. `tests/tmp_diff_python.rs` is a one-off differential test; run `tmp_diff/dump_python.py` first to generate the comparison data, and it skips automatically when the file is missing. +Tests for `savgol`, `ampd`, `endpoint`, and others freeze the behavior of the Python reference implementation as assertions. When you change a numerical algorithm, look at these tests first. `tests/tmp_diff_python.rs` is a one-off differential test that needs a local, repo-external data file (`tmp_diff/dataA_python.json`) to run; it skips automatically when the file is missing. ## Two data sources: mock and real backend diff --git a/TController/crates/controller-core/src/processing/endpoint.rs b/TController/crates/controller-core/src/processing/endpoint.rs index fefc9a7..bd8478d 100644 --- a/TController/crates/controller-core/src/processing/endpoint.rs +++ b/TController/crates/controller-core/src/processing/endpoint.rs @@ -5,7 +5,7 @@ //! 使用未来样本。 //! //! 任一模态的终点都可能事后修正(光谱端被更强激变顶替、电位端被 AMPD -//! 精修);所以观测对变化时 KF 从头重跑:用陈旧状态门控修正值只会拒绝修正。 +//! 微调);所以观测对变化时 KF 从头重跑:用陈旧状态门控修正值只会拒绝修正。 use serde::Serialize; @@ -41,9 +41,8 @@ pub const SPEC_MIN_EVENT_VOL: f64 = 0.08; /// 后发激变须强过的倍数才能顶替终点。 pub const SPEC_SUPERSEDE_RATIO: f64 = 1.5; -/// AMPD 精修拒绝的尾部位置上限:最大尺度只覆盖窗口中部,尾部峰支持的尺度 -/// 很少;0.75 曾静默拒绝手动停止稍晚于等价点的合法终点,故守在无支撑尾部 -/// 之内。 +/// AMPD 微调拒绝的尾部位置上限:最大尺度只覆盖窗口中部,尾部峰支持的尺度 +/// 很少;0.75 曾在稍晚于化学计量点产生假阳性,故将门限放宽到 0.9。 pub const AMPD_MAX_POSITION: f64 = 0.9; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -617,7 +616,7 @@ impl EndpointDetector { if self.kf_consumed == Some(pair) { return; } - // 观测对变化(光谱顶替 / AMPD 精修)→ 重跑滤波器, + // 观测对变化(光谱顶替 / AMPD 微调)→ 重跑滤波器, // 避免用陈旧状态门控修正值。 kf.reset(); if let Some(pv) = pot_vol { @@ -717,7 +716,7 @@ impl EndpointDetector { } } - /// 历史样本足够后用 AMPD 离线精修电位终点。 + /// 历史样本足够后用 AMPD 离线微调电位终点。 pub fn refine_with_ampd(&mut self) -> Option { if self.pot_raw_buf.len() < 20 { return None; diff --git a/TController/crates/controller-core/src/protocol/handler.rs b/TController/crates/controller-core/src/protocol/handler.rs index 298c76c..061f18a 100644 --- a/TController/crates/controller-core/src/protocol/handler.rs +++ b/TController/crates/controller-core/src/protocol/handler.rs @@ -1,7 +1,7 @@ //! 串口通信运行时:后台线程 + 事件通道(Python `_SerialReader` + `ProtocolHandler` 的移植)。 //! //! 模型:调用方(未来的 Tauri 命令层)通过 `send`/`send_heartbeat`/`connect` 投递意图, -//! 工作线程独占串口,执行读帧、ACK/NAK 重试、心跳,把 [`Event`] 推回通道, +//! 工作线程独占串口,执行读帧、ACK/NAK 重试、心跳,把 [`Event`] 推送到通道, //! 由调用方周期 `poll()` 取走。 use std::io::{Read as _, Write as _}; diff --git a/TController/crates/controller-core/src/workflow.rs b/TController/crates/controller-core/src/workflow.rs index 5d5f023..0fdb6d5 100644 --- a/TController/crates/controller-core/src/workflow.rs +++ b/TController/crates/controller-core/src/workflow.rs @@ -2,14 +2,14 @@ //! `_run_detection` 泵控判据的后端化移植。 //! //! 工作流:空闲 → [开始] 进样泵 MaxCount → 滴定泵 FreeRun → 终点 T=1 -//! → 继续 FreeRun 至 2×V_ep → T=2 停泵 + AMPD 精修 → 完成。 +//! → 继续 FreeRun 至 2×V_ep → T=2 停泵 + AMPD 微调 → 完成。 //! //! T=1 的泵控判据是"报告的体积有电位证据支撑",按 method 名白名单判定会 //! 漏掉 conflict:consensus 已由 KF 融合双模态;potential_only 与 conflict //! 报告的都是电位终点(conflict 即"双模态都确认但未过 NIS 门控,退回电位")。 //! 只有 spectral_only 不能控泵;它没有电极证据。两模态持续不一致时 //! T=1 永不触发,滴定死锁而泵无限运行 -//! (Python 版的实际回归,此处固化为测试 `conflict_with_potential_evidence_triggers_t1`)。 +//! (Python 版的实际回归,此处用测试 `conflict_with_potential_evidence_triggers_t1` 防止复发)。 use serde::Serialize; @@ -58,7 +58,7 @@ pub struct WorkflowOutcome { pub commands: Vec, /// T=1 首次报告的终点。 pub first_endpoint: Option, - /// T=2/手动停止时 AMPD 精修后的终点。 + /// T=2/手动停止时 AMPD 微调后的终点。 pub refined_endpoint: Option, /// T=1 时 method 为 conflict(界面据此给出不同提示)。 pub conflict_at_t1: bool, @@ -210,7 +210,7 @@ impl WorkflowEngine { outcome } - /// 手动停止:停泵;已有 T=1 时用 AMPD 精修并收尾。 + /// 手动停止:停泵;已有 T=1 时用 AMPD 微调并收尾。 pub fn manual_stop(&mut self) -> WorkflowOutcome { let mut outcome = WorkflowOutcome { state: TitrationState::Done, diff --git a/TController/crates/controller-core/tests/tmp_diff_python.rs b/TController/crates/controller-core/tests/tmp_diff_python.rs index ddc0857..2370ad6 100644 --- a/TController/crates/controller-core/tests/tmp_diff_python.rs +++ b/TController/crates/controller-core/tests/tmp_diff_python.rs @@ -1,8 +1,8 @@ -//! 临时差分测试:在真实滴定数据 A 上与 Python 实现逐帧数值比对。 +//! 一次性差分测试:在真实滴定数据上与 Python 实现逐帧数值比对。 //! -//! 前置:`tmp_diff/dump_python.py` 生成 `tmp_diff/dataA_python.json` -//! (输入事件序列 + Python 逐帧特征 + 最终结果)。缺文件时跳过。 -//! 这是移植验证用的一次性测试,两实现数值一致后即可删除。 +//! 前置:需要仓库外的本地生成数据 `tmp_diff/dataA_python.json` +//! (输入事件序列 + Python 逐帧特征 + 最终结果,由本地脚本生成,未提交进仓库)。 +//! 缺文件时跳过。这是移植验证用的一次性测试,两实现数值一致后即可删除。 use controller_core::processing::endpoint::EndpointDetector; use serde_json::Value; @@ -46,7 +46,7 @@ impl Report { #[test] fn matches_python_on_titration_data_a() { let Ok(text) = std::fs::read_to_string(JSON_PATH) else { - eprintln!("跳过:未找到 {JSON_PATH}(先运行 tmp_diff/dump_python.py)"); + eprintln!("跳过:未找到 {JSON_PATH}(该数据由仓库外的本地脚本生成)"); return; }; let data: Value = serde_json::from_str(&text).expect("parse json"); diff --git a/docs/algorithm-report.md b/docs/algorithm-report.md new file mode 100644 index 0000000..c7163af --- /dev/null +++ b/docs/algorithm-report.md @@ -0,0 +1,201 @@ +# 算法技术报告:多模态融合与滴定终点判定 + +[English](algorithm-report_EN.md) | 中文 + +本文档描述 AutoTitrator 在线滴定终点判定的实际算法实现,包括电位通道、光谱通道、两状态卡尔曼融合、最终微调与可靠性诊断。实现代码在 `TController/crates/controller-core/src/processing/`,工作流接线在 `src/workflow.rs`。协议与数据格式见配套文档。 + +## 1. 问题与总体设计 + +滴定过程:进样泵抽取样品到反应容器,滴定泵以固定速度加滴定剂。终点是化学计量点附近电位或光谱的剧烈变化点,在线判定必须在加液过程中实时给出,且只用已采集到的样本(因果)。 + +系统用两条相互独立的通道各自产生终点候选,再用卡尔曼滤波器融合: + +```mermaid +flowchart TD + A[电位通道
EWMA 导数状态机] --> C[电位候选] + B[光谱通道
特征追踪器状态机] --> D[光谱候选] + C --> E[两状态 KF 融合
端点体积 + 光谱延迟] + D --> E + E --> F[EndpointResult
体积 / 方法 / 置信度 / 可靠性] +``` + +两条通道都只消费历史样本。任一模态的终点都可能事后修正(光谱候选被更强激变顶替、电位候选被 AMPD 微调),所以观测对变化时 KF 从头重跑,避免用陈旧状态门控修正值。 + +## 2. 电位通道 + +电位通道的目标是在 dV/dV 曲线中定位最陡的下降点。实现分成三步:平滑、阈值估计、状态机。 + +### 2.1 平滑 + +电压与导数各过一个一阶因果 EWMA: + +$$ +v_{\text{sm},t} = \alpha_v \, v_t + (1-\alpha_v)\, v_{\text{sm},t-1}, \qquad \alpha_v = 0.15 +$$ + +$$ +d_t = \frac{v_{\text{sm},t} - v_{\text{sm},t-1}}{t_t - t_{t-1}}, \qquad +d_{\text{sm},t} = \alpha_d \, d_t + (1-\alpha_d)\, d_{\text{sm},t-1}, \qquad \alpha_d = 0.05 +$$ + +### 2.2 阈值估计(观察期) + +累计体积未超过 `POT_OBSERVE_VOL = 0.1` mL 前,只收集导数样本。观察期结束时用无偏样本标准差设两个阈值(在负方向,因为终点处导数明显向下): + +$$ +\text{enter\_th} = \bar d - \max(\text{POT\_MIN\_ENTER},\, 2.5\,\sigma_d), \qquad \text{POT\_MIN\_ENTER} = 0.005 +$$ + +$$ +\text{exit\_th} = \bar d - \max(\text{POT\_MIN\_EXIT},\, 2.5\,\sigma_d), \qquad \text{POT\_MIN\_EXIT} = 0.001 +$$ + +### 2.3 状态机 + +- `Idle` → `Tracking`:平滑导数跌破进入阈值,记录候选体积。 +- `Tracking`:导数创新低时更新候选体积为最低点。 +- `Tracking` → `EndConfirmed`:导数回升超过退出阈值,且自进入点起的体积增量超过 `POT_CONFIRM_VOL = 0.15` mL。 + +确认后的候选即电位终点。阈值与进入退出参数取 $2.5\sigma$,是实测中能在噪声基底上工作、又不至于把正常起伏误判为终点的折中。 + +## 3. 光谱通道 + +光谱通道把「形状变化」转化为一个标量速度,再驱动一个可重入状态机。主要度量是 Jensen–Shannon 散度。 + +### 3.1 度量选择 + +早期实现用交叉熵做事件驱动: + +$$ +\text{CE}(p, q) = - \sum_i p_i \log q_i +$$ + +它有一个结构性缺陷:$\text{CE}(p, p)$ 等于 p 的熵(约 $\ln n$),不是 0。除以体积步长平方后,速度信号从第一帧起就稳定在一个高值,永远高于退出阈值,状态机无法离开变化状态。改用 JS 散度后,$\text{JS}(p, p) = 0$,且对称、有界(值域 $[0,\, \ln 2]$): + +$$ +\text{JS}(p, q) = \frac{1}{2} \sum_i p_i \ln\frac{p_i}{(p_i+q_i)/2} + \frac{1}{2} \sum_i q_i \ln\frac{q_i}{(p_i+q_i)/2} +$$ + +$\text{cross\_entropy\_excess}$(= $\text{KL}(p \| q)$,减去自身下界后为 0)作为 `use_jsd = false` 的兼容路径保留,不驱动默认状态机。 + +### 3.2 体积归一化速度 + +速度定义为当前平滑谱与**最后一个前进帧锚点**的 JS 散度除以体积步长平方: + +$$ +s_t = \frac{\text{JS}(\tilde p_t,\, \tilde p_{\text{anchor}})}{\Delta V_t^2} +$$ + +锚定前进帧是刻意的。生产中一个泵体积对应多帧光谱,若锚定上一帧,重复体积帧会注入零步长、把真实事件稀释掉;锚定前进帧让速度滤波器在体积静止时保持电平,不输入零值。 + +**舍入下界**:float64 上真实 8 通道帧的 JS 舍入底约 5e-17,平台期约 2e-12,除以 $\Delta V^2$(约 2.4e-8)会被放大约 4e7 倍,变成算术噪声。因此: + +$$ +s_t = 0 \quad \text{当} \quad \text{JS}(\tilde p_t,\, \tilde p_{\text{anchor}}) \le \text{JS\_FLOOR} = 10^{-14} +$$ + +### 3.3 基线 + +在体积不超过 `SPEC_BASELINE_MAX_VOL = 0.30` mL 且帧数未达 `SPEC_BASELINE_FRAMES = 12` 前,逐帧累加归一化谱,取平均作为基线。基线 JS 用 `SPEC_BASELINE_ENTER = 3e-7` 作为进入事件的必要条件,避免基线未建立时误触发。 + +### 3.4 状态机(可重入) + +- `Idle` / `EndConfirmed` → `InChange`:平滑速度 $\ge$ `SPEC_JS_ENTER = 0.05` 且基线 JS $\ge$ `3e-7`。用保留窗口(lookback 8 帧)播种峰值,让候选定位到窗口内速度最强的帧,而不是首个越过阈值的穿越点。 +- `InChange`:速度创下新高时更新峰值;速度回落 $\le$ `SPEC_JS_EXIT = 0.008` 时累计恢复帧数。 +- `InChange` → `EndConfirmed`:恢复帧数 $\ge$ `SPEC_CONFIRM_FRAMES = 10`,且自进入点体积增量 $\ge$ `SPEC_MIN_EVENT_VOL = 0.08` mL。 + +`EndConfirmed` 可重入:激变记入事件列表,报告的终点是迄今最强事件;只有后发事件峰值速度超过旧峰值 `SPEC_SUPERSEDE_RATIO = 1.5` 倍才顶替。这个滞回修复了一次实际回归:一次性闩锁曾把早于真终点 0.97 mL 的瞬态锁成终点,而 KF 门控只能拒绝、无法修正。 + +## 4. 两状态卡尔曼融合 + +融合层把电位终点与光谱终点组合成一个估计。两状态线性 KF:状态 = [终点体积, 光谱延迟]。 + +$$ +x = \begin{bmatrix} V_{\text{ep}} \\ \delta \end{bmatrix}, \qquad +H_{\text{pot}} = [1, 0], \qquad H_{\text{spec}} = [1, 1] +$$ + +电位观测直接是终点体积;光谱观测 = 终点 + 延迟,因此延迟被估计出来。 + +### 4.1 观测模型与方差 + +| 参数 | 值 | +|---|---| +| 电位观测噪声 std | 0.012 | +| 光谱观测噪声 std | 0.025 | +| 延迟 std | 0.08 | +| 过程噪声 std | 0.004 | +| 延迟先验 | 0.02 | +| NIS 门 | 6.635 | + +首次观测决定初始化:首个电位观测把状态设为 $[z,\, 0]$,首个光谱观测设为 $[z - \delta_0,\, \delta_0]$。之后的观测走标准预测-更新,用 token 去重,同一观测幂等。 + +### 4.2 NIS 门控 + +每次观测的新息是标量,把它归一化后与自由度为 1 的卡方分布比较: + +$$ +\text{NIS} = \frac{(z - H x^-)^2}{H P^- H^\top + R}, \qquad \text{接受当 NIS} \le 6.635 +$$ + +6.635 是自由度为 1 的卡方分布 99 分位。旧实现用了自由度为 2 的 99 分位 9.21,自由度错配导致判定边界过宽,已修正。被拒绝的观测不更新状态,记入快照供诊断。 + +### 4.3 观测对变化时重新融合 + +光谱候选被顶替、电位候选被 AMPD 微调,都会改变观测对。`endpoint.rs` 检测到观测对变化时先 `kf.reset()` 再重新观测,避免用陈旧状态门控修正值拒绝修正。 + +## 5. 汇总结案 + +`detect()` 按双通道候选与 KF 融合能力返回结果: + +| 电位 | 光谱 | KF 可融合 | method | confidence | +|---|---|---|---|---| +| 有 | 有 | 是 | consensus | high | +| 有 | 有 | 否(KF 关闭且 $\lvert\Delta V\rvert < 0.3$) | consensus | high | +| 有 | 有 | 否(未过 NIS 门控) | conflict | low | +| 有 | 无 | — | potential_only | medium | +| 无 | 有 | — | spectral_only | medium | + +`conflict` 的语义是「双模态都确认但未过 NIS 门控,退回电位终点」,它仍有电位证据,因此工作流以电位为判据控制泵。`spectral_only` 没有电极证据,工作流不以它为判据控制泵。 + +## 6. 最终微调(AMPD) + +滴定到达 $2 \times$ T=1 体积或手动停止时,对电位导数做离线微调。AMPD(自动多尺度峰值检测)在取负的导数序列上找最显著峰,实现上逐尺度即时归约而不是物化稠密矩阵: + +- 需要至少 20 个导数样本。 +- 峰位必须落在 `AMPD_MAX_POSITION = 0.9` 以内:最大尺度只覆盖窗口中部,尾部峰的尺度支持很少,0.75 曾在稍晚于化学计量点产生假阳性,故将门限放宽到 0.9。 +- 微调结果覆盖候选终点,并触发 KF 重新融合。 + +## 7. 可靠性诊断 + +`Reliability` 汇总状态与原因码: + +| 状态 | 含义 | +|---|---| +| CONFIRMED | 双通道均确认且 KF 融合 | +| CONFLICT | 双通道均确认但未过 NIS 门控 | +| CANDIDATE | 单通道确认 | +| CONFIRMING | 任一通道在追踪中 | +| UNOBSERVABLE | 无数据 | +| EARLY_WARNING | 已有部分数据但无候选 | + +诊断还携带数据质量(电位/光谱样本数、有效帧、重复体积、非单调体积、基线就绪)、KF 快照(endpoint_std、NIS、新息)与原因码(`kf_innovation_gate`、`spectral_endpoint_superseded`、`baseline_pending` 等),随 `backend://state` 快照推送给前端。 + +## 8. 参数速查 + +| 通道 | 参数 | 值 | 含义 | +|---|---|---|---| +| 电位 | POT_V_ALPHA / POT_D_ALPHA | 0.15 / 0.05 | 电压、导数 EWMA | +| 电位 | POT_ENTER/EXIT_SIGMA | 2.5 / 2.5 | 阈值 $\sigma$ 倍数 | +| 电位 | POT_CONFIRM_VOL | 0.15 mL | 确认所需体积增量 | +| 光谱 | SPEC_JS_ENTER / EXIT | 0.05 / 0.008 | JS 速度进入/退出阈值 | +| 光谱 | SPEC_SUPERSEDE_RATIO | 1.5 | 顶替滞回倍数 | +| 光谱 | JS_FLOOR | 1e-14 | 舍入下界 | +| KF | DEFAULT_NIS_GATE | 6.635 | 卡方分布(自由度 1)99 分位 | +| AMPD | AMPD_MAX_POSITION | 0.9 | 微调峰位上限 | + +## 9. 验证 + +- 单元测试 `tests/endpoint_reliability.rs` 把行为约定写成测试:JS 对称有界、特征因果、重复体积保持电平、顶替滞回、舍入下界、KF 重置、AMPD 与稠密参照实现一致。 +- `tests/workflow.rs` 防止 T=1 时的死锁回归:当双模态冲突时我们取电位数据作为 T=1 的判据;仅光谱时不控泵。 +- `tests/tmp_diff_python.rs` 是一次性差分测试,依赖仓库外的本地生成数据 `tmp_diff/dataA_python.json`,与 Python 参考实现在真实滴定数据上逐帧比对;缺文件时自动跳过。 diff --git a/docs/algorithm-report_EN.md b/docs/algorithm-report_EN.md new file mode 100644 index 0000000..65a9d56 --- /dev/null +++ b/docs/algorithm-report_EN.md @@ -0,0 +1,203 @@ +# Algorithm Technical Report: Multimodal Fusion and Titration Endpoint Detection + +[中文](algorithm-report.md) | English + +This document describes the actual algorithm implementation for online titration endpoint detection in AutoTitrator: the potential channel, the spectral channel, the two-state Kalman fusion, the final refinement, and the reliability diagnostics. The implementation lives in `TController/crates/controller-core/src/processing/`; the workflow wiring is in `src/workflow.rs`. The protocol and data formats are covered in companion documents. + +## 1. Problem and overall design + +During a titration, the sample pump injects sample into the reaction vessel and the titrant pump adds titrant at a fixed rate. The endpoint is the point of sharp change in potential or spectrum near the equivalence point. Online detection must produce a verdict while liquid is still being added, using only samples collected so far (causal). + +The system uses two independent channels, each producing an endpoint candidate, then fuses them with a Kalman filter: + +The system uses two independent channels, each producing an endpoint candidate, then fuses them with a Kalman filter: + +```mermaid +flowchart TD + A[Potential channel
EWMA derivative state machine] --> C[Potential candidate] + B[Spectral channel
feature tracker state machine] --> D[Spectral candidate] + C --> E[Two-state KF fusion
endpoint volume + spectral delay] + D --> E + E --> F[EndpointResult
volume / method / confidence / reliability] +``` + +Both channels consume historical samples only. Either modality's endpoint can be revised later (the spectral candidate can be superseded by a stronger excursion, the potential candidate by AMPD refinement), so the KF reruns from scratch whenever the observation pair changes, rather than gating a revision with stale state. + +## 2. Potential channel + +The potential channel locates the steepest downward excursion in the dV/dt curve. It works in three stages: smoothing, threshold estimation, and a state machine. + +### 2.1 Smoothing + +The voltage and its derivative each pass through a first-order causal EWMA: + +$$ +v_{\text{sm},t} = \alpha_v \, v_t + (1-\alpha_v)\, v_{\text{sm},t-1}, \qquad \alpha_v = 0.15 +$$ + +$$ +d_t = \frac{v_{\text{sm},t} - v_{\text{sm},t-1}}{t_t - t_{t-1}}, \qquad +d_{\text{sm},t} = \alpha_d \, d_t + (1-\alpha_d)\, d_{\text{sm},t-1}, \qquad \alpha_d = 0.05 +$$ + +### 2.2 Threshold estimation (observation period) + +Before the accumulated volume exceeds `POT_OBSERVE_VOL = 0.1` mL, the channel only collects derivative samples. At the end of the observation period, it uses the unbiased sample standard deviation to set two thresholds (in the negative direction, since the derivative drops sharply at the endpoint): + +$$ +\text{enter\_th} = \bar d - \max(\text{POT\_MIN\_ENTER},\, 2.5\,\sigma_d), \qquad \text{POT\_MIN\_ENTER} = 0.005 +$$ + +$$ +\text{exit\_th} = \bar d - \max(\text{POT\_MIN\_EXIT},\, 2.5\,\sigma_d), \qquad \text{POT\_MIN\_EXIT} = 0.001 +$$ + +### 2.3 State machine + +- `Idle` → `Tracking`: the smoothed derivative falls below the enter threshold; record the candidate volume. +- `Tracking`: when the derivative sets a new low, update the candidate volume to the minimum point. +- `Tracking` → `EndConfirmed`: the derivative rises back above the exit threshold, and the volume gained since the entry point exceeds `POT_CONFIRM_VOL = 0.15` mL. + +The confirmed candidate is the potential endpoint. The thresholds use $2.5\sigma$ as a compromise that works above the noise level without mistaking normal fluctuations for an endpoint. + +## 3. Spectral channel + +The spectral channel turns "shape change" into a scalar speed and drives a reentrant state machine. The core metric is the Jensen–Shannon divergence. + +### 3.1 Metric choice + +The early implementation drove events with cross entropy: + +$$ +\text{CE}(p, q) = - \sum_i p_i \log q_i +$$ + +which has a structural flaw: $\text{CE}(p, p)$ equals the entropy of p (about $\ln n$), not 0. After dividing by the volume step squared, the speed signal saturates at a high value from the first frame, always above the exit threshold, so the state machine never leaves the change state. Switching to the JS divergence gives $\text{JS}(p, p) = 0$, and JS is symmetric and bounded (range $[0,\, \ln 2]$): + +$$ +\text{JS}(p, q) = \frac{1}{2} \sum_i p_i \ln\frac{p_i}{(p_i+q_i)/2} + \frac{1}{2} \sum_i q_i \ln\frac{q_i}{(p_i+q_i)/2} +$$ + +$\text{cross\_entropy\_excess}$ (= $\text{KL}(p \| q)$, which is 0 after subtracting its own lower bound) is kept as a compatibility path for `use_jsd = false` and does not drive the default state machine. + +### 3.2 Volume-normalized speed + +The speed is the JS divergence between the current smoothed spectrum and the last advancing-frame anchor, divided by the squared volume step: + +$$ +s_t = \frac{\text{JS}(\tilde p_t,\, \tilde p_{\text{anchor}})}{\Delta V_t^2} +$$ + +Anchoring to the advancing frame is deliberate. In production, one pump volume corresponds to many spectral frames; anchoring to the previous frame would inject zero steps on repeated-volume frames and dilute the real event. Anchoring to the advancing frame lets the speed filter hold its level while the volume is stationary, instead of feeding zeros. + +**Rounding lower bound**: on float64, the JS rounding lower bound of a real 8-channel frame is about $5 \times 10^{-17}$ and about $2 \times 10^{-12}$ on a plateau; dividing by $\Delta V^2$ (about $2.4 \times 10^{-8}$) amplifies it about $4 \times 10^7$ times into arithmetic noise. Therefore: + +$$ +s_t = 0 \quad \text{when} \quad \text{JS}(\tilde p_t,\, \tilde p_{\text{anchor}}) \le \text{JS\_FLOOR} = 10^{-14} +$$ + +### 3.3 Baseline + +While the volume stays below `SPEC_BASELINE_MAX_VOL = 0.30` mL and the frame count is below `SPEC_BASELINE_FRAMES = 12`, the tracker accumulates normalized spectra and averages them into a baseline. The baseline JS must be at least `SPEC_BASELINE_ENTER = 3e-7` for an event to start, so the state machine does not fire before the baseline is established. + +### 3.4 State machine (reentrant) + +- `Idle` / `EndConfirmed` → `InChange`: the smoothed speed is at least `SPEC_JS_ENTER = 0.05` and the baseline JS is at least `3e-7`. A lookback window (8 frames) seeds the peak so the candidate lands on the strongest frame in the window rather than the first threshold crossing. +- `InChange`: when the speed sets a new high, update the peak; when it falls to at most `SPEC_JS_EXIT = 0.008`, accumulate recovery frames. +- `InChange` → `EndConfirmed`: at least `SPEC_CONFIRM_FRAMES = 10` recovery frames, and the volume gained since entry is at least `SPEC_MIN_EVENT_VOL = 0.08` mL. + +`EndConfirmed` is reentrant: excursions are recorded in an event list, and the reported endpoint is the strongest event so far. A later event supersedes an earlier one only if its peak speed exceeds the old peak by `SPEC_SUPERSEDE_RATIO = 1.5`. This hysteresis fixed a real regression: a one-shot latch once locked an early transient 0.97 mL before the true endpoint, and the KF gate could only reject, not correct. + +## 4. Two-state Kalman fusion + +The fusion layer combines the potential endpoint with the spectral endpoint into one estimate. It is a two-state linear KF: state = [endpoint volume, spectral delay]. + +$$ +x = \begin{bmatrix} V_{\text{ep}} \\ \delta \end{bmatrix}, \qquad +H_{\text{pot}} = [1, 0], \qquad H_{\text{spec}} = [1, 1] +$$ + +The potential observation is the endpoint volume directly; the spectral observation equals endpoint + delay, so the delay is estimated. + +### 4.1 Observation model and variances + +| Parameter | Value | +|---|---| +| Potential observation noise std | 0.012 | +| Spectral observation noise std | 0.025 | +| Delay std | 0.08 | +| Process noise std | 0.004 | +| Delay prior | 0.02 | +| NIS gate | 6.635 | + +The first observation decides initialization: the first potential observation sets the state to $[z,\, 0]$, the first spectral observation to $[z - \delta_0,\, \delta_0]$. Later observations follow the standard predict-update cycle, deduplicated by token so the same observation is idempotent. + +### 4.2 NIS gating + +The innovation of each observation is a scalar. It is normalized and compared with a chi-squared distribution with one degree of freedom: + +$$ +\text{NIS} = \frac{(z - H x^-)^2}{H P^- H^\top + R}, \qquad \text{accepted when NIS} \le 6.635 +$$ + +6.635 is the 99th percentile of the chi-squared distribution with one degree of freedom. The old implementation used 9.21, the 99th percentile with two degrees of freedom; the mismatch widened the acceptance boundary. An observation rejected by the gate does not update the state and is recorded in the snapshot for diagnosis. + +### 4.3 Refusing revisions with stale state + +A superseded spectral candidate or an AMPD-refined potential candidate changes the observation pair. `endpoint.rs` detects the change, calls `kf.reset()`, and re-observes, so a revision is never rejected by gating against stale state. + +## 5. Summary result + +`detect()` returns a result based on the two channel candidates and the KF fusion state: + +| Potential | Spectral | KF can fuse | method | confidence | +|---|---|---|---|---| +| yes | yes | yes | consensus | high | +| yes | yes | no (KF off and $\lvert\Delta V\rvert < 0.3$) | consensus | high | +| yes | yes | no (NIS gate rejected) | conflict | low | +| yes | no | — | potential_only | medium | +| no | yes | — | spectral_only | medium | + +`conflict` means "both modalities confirmed but failed the innovation-consistency gate; fall back to the potential endpoint". It still carries potential evidence, so the workflow allows pump control. `spectral_only` has no electrode evidence, and the workflow does not allow pump control. + +## 6. Final refinement (AMPD) + +When the titration reaches 2x the T=1 volume or is stopped manually, the potential derivative is refined offline. AMPD (automatic multiscale peak detection) finds the most significant peak on the negated derivative sequence; the implementation reduces per-scale on the fly instead of materializing a dense matrix: + +- At least 20 derivative samples are required. +- The peak must lie within `AMPD_MAX_POSITION = 0.9`: the largest scale covers only the middle of the window, so tail peaks have little scale support. 0.75 once rejected a legitimate endpoint from a manual stop slightly after the equivalence point, so the limit was relaxed to 0.9. +- The refined value overrides the candidate endpoint and triggers a KF re-fusion. + +## 7. Reliability diagnostics + +`Reliability` summarizes status and reason codes: + +| Status | Meaning | +|---|---| +| CONFIRMED | both channels confirmed and KF fused | +| CONFLICT | both confirmed but failed the NIS gate | +| CANDIDATE | one channel confirmed | +| CONFIRMING | either channel tracking | +| UNOBSERVABLE | no data | +| EARLY_WARNING | partial data but no candidate | + +The diagnostics also carry data quality (potential/spectral sample counts, valid frames, repeated volume, non-monotonic volume, baseline ready), a KF snapshot (endpoint_std, NIS, innovation), and reason codes (`kf_innovation_gate`, `spectral_endpoint_superseded`, `baseline_pending`, etc.), pushed to the frontend with the `backend://state` snapshot. + +## 8. Parameter reference + +| Channel | Parameter | Value | Meaning | +|---|---|---|---| +| Potential | POT_V_ALPHA / POT_D_ALPHA | 0.15 / 0.05 | voltage, derivative EWMA | +| Potential | POT_ENTER/EXIT_SIGMA | 2.5 / 2.5 | threshold sigma multiple | +| Potential | POT_CONFIRM_VOL | 0.15 mL | volume gain needed to confirm | +| Spectral | SPEC_JS_ENTER / EXIT | 0.05 / 0.008 | JS speed enter/exit threshold | +| Spectral | SPEC_SUPERSEDE_RATIO | 1.5 | supersede hysteresis multiple | +| Spectral | JS_FLOOR | 1e-14 | rounding lower bound | +| KF | DEFAULT_NIS_GATE | 6.635 | chi-squared (1 dof) 99th percentile | +| AMPD | AMPD_MAX_POSITION | 0.9 | refinement peak position limit | + +## 9. Verification + +- The unit tests in `tests/endpoint_reliability.rs` freeze the behavior contracts: JS symmetric and bounded, causal features, hold-on-repeated-volume, supersede hysteresis, rounding lower bound, KF reset, AMPD matching the dense reference implementation. +- `tests/workflow.rs` freezes the T=1 deadlock regression: on modal conflict, T=1 must fire whenever potential evidence exists; spectral-only must not control the pump. +- `tests/tmp_diff_python.rs` is a one-off differential test that depends on a local, repo-external data file `tmp_diff/dataA_python.json`, comparing frame by frame against the Python reference implementation on real titration data; it skips automatically when the file is missing. diff --git a/docs/host-user-guide.md b/docs/host-user-guide.md index 1b36566..bc6fff9 100644 --- a/docs/host-user-guide.md +++ b/docs/host-user-guide.md @@ -75,7 +75,7 @@ Linux 产物需要在 glibc 2.39 以上的系统运行(Ubuntu 24.04 及更新 ```mermaid flowchart TD A[设置样品体积与浓度] --> B[开始滴定] - B --> C[进样泵 泵1 打进样品] + B --> C[进样泵 泵1 抽取样品] C --> D{进样完成?} D -- 否 --> C D -- 是 --> E[滴定泵 泵2 匀速加液] @@ -83,17 +83,17 @@ flowchart TD F --> G{T=1 初判?} G -- 否 --> E G -- 是 --> H[继续过量滴定至 2 倍体积] - H --> I[自动停泵, AMPD 精修] + H --> I[自动停泵, AMPD 微调] I --> J[显示最终终点] ``` -1. 进样泵(泵 1)把样品打进反应杯,直到设定的体积。 +1. 进样泵(泵 1)抽取样品到反应容器,直到设定的体积。 2. 进样完成,滴定泵(泵 2)开始以固定速度加滴定剂。 3. 上位机实时看电位曲线和光谱,双路信号在终点附近会先后给出判断。 4. T=1 初判出现后提示一个候选终点,同时继续过量滴定直到 2 倍体积。 -5. 到达后自动停泵,用 AMPD 对电位曲线做一次离线精修,给出最终终点。 +5. 到达后自动停泵,用 AMPD 对电位曲线做一次离线微调,给出最终终点。 -滴定过程中随时可以「手动停止」(会立即用已有数据做精修),或「中止」(回到待机,保留曲线不写入结果)。「急停」会立刻让所有泵停下并复位工作流。 +滴定过程中随时可以「手动停止」(会立即用已有数据做微调),或「中止」(回到待机,保留曲线不写入结果)。「急停」会立刻让所有泵停下并复位工作流。 结果面板显示终点体积、判定方法、置信度、可靠性,以及由滴定剂浓度和计量比换算出的分析物浓度。