diff --git a/docs/superpowers/specs/2026-06-11-judgment-logic-design.md b/docs/superpowers/specs/2026-06-11-judgment-logic-design.md new file mode 100644 index 0000000..3451e80 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-judgment-logic-design.md @@ -0,0 +1,245 @@ +# 判定逻辑补全设计(二期) + +> 关联文档:一期设计 `2026-05-15-rust-tauri-mvp-design.md`;需求来源 `docs/建材放射性检测结果分析软件开发计算逻辑(1).pdf`。 + +## 1. 目标 + +一期已完成算法骨架(IRa/Ir、A/B 类不确定度、GUM 合成、MCM 仿真)。本期补齐 PDF 第 3、6 节缺失的**判定层与输出层**,使软件能给出最终交付给用户的"有效性 + 合格/不合格 + 材料分级"结论,而不只是中间量。 + +补齐范围: + +- 1.3 样品信息:材料类型选择与对应限值集。 +- 2.2.4–2.2.6:扩展不确定度 U、k=1/k=2 相对扩展不确定度、GUM 解析真值区间 P2.5/P97.5。 +- 3.1 有效性判定(37 Bq/kg 低活度豁免 + Ur(IRa) 阈值)。 +- 3.2 临界值判定(真值区间是否跨越极限值 → 合格/不合格/建议增加次数)。 +- 3.2.3 装饰装修材料 A/B/C 三级分类。 +- 单次测量(n=1)主流程支持。 +- 6.1 输出元信息(样品编号、计算日期)。 +- 修正合成不确定度中 A 类项缺少校准因子 `a` 的问题。 + +非目标(仍延后):SQLite 历史、Excel/PDF 报告导出。本期只在内存结构和界面上给出完整结论,导出留三期。 + +## 2. 与现状的差距映射 + +| PDF 节 | 现状 | 本期动作 | +|---|---|---| +| 1.3 材料类型 | 无,仅手填 limit | 新增 `MaterialType`,按类型派生限值 | +| 2.2.4 扩展不确定度 | 无 | `IndexResult` 增 `expanded_uncertainty` | +| 2.2.5 k=1/k=2 相对 | 仅一套(k=1) | 增 `relative_expanded_uncertainty_percent` | +| 2.2.6 GUM 真值区间 | 仅 MCM 百分位 | `IndexResult` 增 `p2_5/p97_5 = value ± U` | +| 3.1 有效性 | 仅 Ur≤20% 双指数 | 实现 37 Bq/kg 豁免,仅看 Ur(IRa) | +| 3.2 临界值判定 | 无 | 新增 `Verdict` 判定函数 | +| 3.2.3 A/B/C | 无 | 装饰材料级联分类 | +| 单次测量 | `count<2` 报错 | n=1 时 uA=0 放行 | +| 6.1 元信息 | 无 | 输入/输出增 `sample_id`、`calculation_date` | +| 合成不确定度 A 类项 | 漏 `a` 因子 | 修正为 `a·uA` | + +## 3. 领域模型变更(`domain.rs`) + +### 3.1 输入 + +```rust +pub struct SampleInput { + pub ra: NuclideMeasurements, + pub th: NuclideMeasurements, + pub k: NuclideMeasurements, + #[serde(default)] + pub material_type: MaterialType, // 新增,默认 BuildingMainBody + #[serde(default)] + pub sample_id: Option, // 新增 6.1 + #[serde(default)] + pub calculation_date: Option, // 新增 6.1,前端传 ISO 字符串 +} + +#[derive(Default)] +pub enum MaterialType { + #[default] + BuildingMainBody, // 建筑主体材料 IRa≤1.0, Ir≤1.0 + HollowBuildingMainBody, // 空心率>25%主体材料 IRa≤1.0, Ir≤1.3 + DecorativeMaterial, // 装饰装修材料 A/B/C 分级 +} +``` + +`AcceptanceLimits` 不再由前端手填,改为由 `MaterialType` 派生(保留结构体用于 MCM 比较)。装饰材料有多级限值,用一张分级表表达: + +```rust +pub struct LimitTier { pub label: &'static str, pub ira_limit: Option, pub ir_limit: Option } + +impl MaterialType { + /// 返回从严到宽的限值阶梯。主体材料/空心材料各 1 级;装饰材料 A/B/C 三级。 + pub fn tiers(&self) -> &'static [LimitTier] { /* 见下表 */ } +} +``` + +限值表(来自 PDF 3.2): + +| 材料类型 | 级别 | IRa 限 | Ir 限 | +|---|---|---|---| +| 主体材料 | 合格 | 1.0 | 1.0 | +| 空心率>25% | 合格 | 1.0 | 1.3 | +| 装饰装修 | A | 1.0 | 1.3 | +| 装饰装修 | B | 1.3 | 1.9 | +| 装饰装修 | C | — | 2.8 | + +> C 类只约束 Ir(`ira_limit = None`)。 + +### 3.2 指数结果扩展 + +```rust +pub struct IndexResult { + pub value: f64, + pub standard_uncertainty: f64, // u, 已有 + pub expanded_uncertainty: f64, // U = u·k (k=2),新增 + pub relative_uncertainty_percent: f64, // k=1,已有 + pub relative_expanded_uncertainty_percent: f64, // k=2,新增 + pub p2_5: f64, // value - U,新增(GUM 解析区间) + pub p97_5: f64, // value + U,新增 +} +``` + +指数包含因子固定 `k = 2`(PDF 2.2.4/2.2.6)。 + +### 3.3 判定结果 + +```rust +pub struct CalculationResult { + /* 既有字段 ... */ + pub analysis: AnalysisResult, // 新增 +} + +pub struct AnalysisResult { + pub total_calibrated_activity: f64, // A1·a+A2·b+A3·c + pub validity: Validity, + pub verdict: Verdict, +} + +pub enum Validity { + LowActivityExempt, // ≤37 Bq/kg,直接有效 + UncertaintyAcceptable, // >37 且 Ur(IRa)≤20% + Invalid, // >37 且 Ur(IRa)>20% +} + +pub enum Verdict { + Qualified, // 主体/空心:合格 + Unqualified, // 不合格 + DecorativeClass(DecorClass), // 装饰:A/B/C + NeedMoreMeasurements, // 真值区间跨越极限值,建议增加到 6 次 + InvalidResult, // 有效性不成立 +} + +pub enum DecorClass { A, B, C, Unqualified } +``` + +`Conclusion` 枚举保留(向后兼容),但 UI 主结论改用 `Verdict`。 + +## 4. 计算逻辑变更(`calculator.rs`) + +### 4.1 单次测量支持 + +`validate_input`:`count` 下限由 2 改为 1,且三核素次数一致。`type_a_uncertainty`:`n == 1` 时返回 `0.0`(PDF 2.2.1 uA=0),`n>=6` 走标准差法,`2<=n<6` 走极差法,`n` 其它非法值仍报错。 + +### 4.2 合成不确定度修正 + +校准比活度 `C = mean·a`,对测量值 A 的灵敏系数是 `a`: + +```rust +// 修正前: combined = sqrt(uA² + (mean·uB)²) +// 修正后: combined = sqrt((a·uA)² + (mean·uB)²) +let combined = ((factor*type_a).powi(2) + (mean*type_b_uncertainty).powi(2)).sqrt(); +``` + +n=1(uA=0)时与现状一致,可与 PDF 单次算例对齐校验。 + +### 4.3 GUM 真值区间 + +每个 `IndexResult` 计算 `U = u·2`,`p2_5 = value - U`,`p97_5 = value + U`,相对量两套(u/value 与 U/value)。 + +### 4.4 有效性判定(3.1) + +```rust +let total = ra.mean_calibrated + th.mean_calibrated + k.mean_calibrated; // A1·a+A2·b+A3·c +let validity = if total <= 37.0 { + Validity::LowActivityExempt +} else if ira.relative_expanded_uncertainty_percent <= 20.0 { + Validity::UncertaintyAcceptable +} else { + Validity::Invalid +}; +``` + +> **待确认 1**:3.1 的 `Ur(IRa)≤20%` 用 k=2 的相对扩展不确定度(与 2.2.5 命名一致)还是 k=1。本设计先取 k=2,见第 7 节。 + +### 4.5 临界值判定(3.2) + +核心是"真值区间 [P2.5,P97.5] 相对极限值 L 的位置"的三态函数: + +```rust +enum TierCheck { Pass, Fail, Straddle } // 全在限下 / 全在限上 / 跨越 + +fn check(idx: &IndexResult, limit: Option) -> TierCheck { + let Some(l) = limit else { return TierCheck::Pass }; // 无约束(如 C 类的 IRa) + if idx.p97_5 < l { TierCheck::Pass } // 区间不含 L 且值 < L + else if idx.p2_5 > l { TierCheck::Fail } // 区间不含 L 且值 > L + else { TierCheck::Straddle } // 区间含 L → 建议增加次数 +} +``` + +组合规则: + +- 若 `validity == Invalid` → `Verdict::InvalidResult`。 +- **主体/空心材料**(单级):IRa、Ir 两项 `check`。 + - 任一 `Straddle` → `NeedMoreMeasurements`。 + - 两项均 `Pass` → `Qualified`。 + - 否则(存在 `Fail`)→ `Unqualified`。 +- **装饰材料**(A→B→C 级联):自严到宽逐级判断。 + - 某级两项(C 级仅 Ir)均 `Pass` → 归该级(`DecorClass::A/B/C`)。 + - 某级存在 `Straddle` 且尚未在更严级别通过 → `NeedMoreMeasurements`。 + - 全部级别都不通过且 C 级 `Fail` → `DecorClass::Unqualified`(不可用于建材)。 + +### 4.6 与 MCM 的衔接 + +GUM 路径给出确定性 `Verdict`;当 `Verdict::NeedMoreMeasurements`(区间跨越极限值)时,MCM 的 `overall_fail_probability` 即 PDF 6.3 的"95% 置信概率下不符合概率",作为补充量化结论展示。MCM 仍每次都算(用当前限值),无需新增接口。 + +> **待确认 2**:6 次测量后若仍跨越极限值,最终合格判据用 MCM 不符合概率阈值(如 <5% 判合格)还是仅展示概率由人判断。现有代码用 `pass_probability>=0.95`,本设计沿用并在第 7 节标注。 + +## 5. 界面变更(`App.tsx`) + +- 顶部输入区:新增**材料类型**下拉(三选一)、**样品编号**输入、**计算日期**选择(默认今日)。移除手填 IRa/Ir 限值,改为根据材料类型只读展示当前限值表。 +- 结果区新增"**分析判定**"卡片: + - 有效性标签(有效/低活度豁免/无效)+ 总比活度 `A1·a+A2·b+A3·c` 与 37 对比。 + - 最终判定标签:合格 / 不合格 / A 类·B 类·C 类 / 建议增加至 6 次 / 结果无效。 + - 各指数 GUM 真值区间 `[P2.5, P97.5]`、U、相对扩展不确定度(k=1/k=2)。 +- 报告头展示样品编号与计算日期,为三期导出做准备。 + +类型定义(TS)同步扩展 `MaterialType`、`AnalysisResult`、`Verdict`、`IndexResult` 新字段。 + +## 6. 测试(`tests/calculator_tests.rs`) + +新增: + +- **单次测量对齐 PDF 算例**:A1=83.439/a=0.916 等,断言 IRa≈0.38、Ir≈0.73、u(IRa)≈0.012、U(IRa)≈0.024、P2.5≈0.36、P97.5≈0.40、Ur(IRa,k=2)≈6.3%。 +- **有效性**:total≤37 → `LowActivityExempt`;total>37 且 Ur 大 → `Invalid`。 +- **临界值三态**:构造区间全低/全高/跨越,断言 `Qualified`/`Unqualified`/`NeedMoreMeasurements`。 +- **装饰材料级联**:分别命中 A、B、C 与 Unqualified。 +- **合成不确定度修正**:多次测量下 A 类项含 `a` 因子(与手算对比)。 +- 既有 6 次测量与 MCM 测试保持通过(注意修正后数值会有小幅变化,需更新期望值)。 + +## 7. 已确认决策 + +1. **3.1 的 Ur(IRa) 取 k=2 的相对扩展不确定度**(`relative_expanded_uncertainty_percent`),与 2.2.5 命名一致。✅ 已定 +2. **6 次测量后的最终合格判据**:用 MCM 不符合概率,`overall_fail_probability < 5%`(即 `pass_probability ≥ 0.95`)自动判合格/不合格。✅ 已定 +3. **材料类型限值只读**:移除前端手填 IRa/Ir 限值,严格按材料类型派生。✅ 已定 + +仍按本设计默认处理、无需进一步确认: + +4. **计算日期来源**:由前端生成 ISO 字符串传入(本地时区可控,Rust 侧不引入 `chrono`)。 +5. **单次测量仍跑 MCM**:n=1 时 uA=0,MCM 仅传播 B 类不确定度,仍有意义,默认保留。 + +## 8. 实施顺序 + +1. `domain.rs`:`MaterialType`/限值表 + `IndexResult` 扩展 + `AnalysisResult/Verdict`。 +2. `calculator.rs`:单次测量、合成不确定度修正、GUM 区间、有效性、临界值判定。 +3. 补充/更新 Rust 测试至全绿(先对齐 PDF 单次算例)。 +4. `lib.rs` 导出新类型;`main.rs` 命令签名无需变更(结构体透传)。 +5. `App.tsx`:材料类型/样品信息输入 + 分析判定卡片。 +6. 端到端手测三类材料的合格/不合格/分级/建议增加次数路径。 diff --git a/docs/superpowers/specs/2026-06-11-persistence-report-design.md b/docs/superpowers/specs/2026-06-11-persistence-report-design.md new file mode 100644 index 0000000..dc9ba10 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-persistence-report-design.md @@ -0,0 +1,152 @@ +# 历史存储与报告导出设计(三期) + +> 关联:一期 `2026-05-15-rust-tauri-mvp-design.md`、二期 `2026-06-11-judgment-logic-design.md`。 +> 对应 PDF 第 6 节「输出」:样品编号、计算日期、最佳估计值、95% 置信区间、k=1 相对扩展不确定度、不符合概率。 + +## 1. 目标 + +一/二期已完成计算与判定。本期补齐"留痕与出报告"两块工程能力: + +- **历史存储**:每次计算的输入与结果落库,可检索、回看、复算、删除。 +- **报告导出**:将一次完整结果导出为可交付文件(PDF 检测报告 + Excel 数据表)。 + +非目标:多用户/权限、云同步、检测报告的官方排版模板(先满足内容完整、可打印)。 + +## 2. 范围 + +| 能力 | 内容 | +|---|---| +| 历史存储 | 保存/列表/详情/删除/复算一条记录 | +| Excel 导出 | 单条记录导出为 `.xlsx`(输入表 + 结果表 + 判定) | +| PDF 报告 | 单条记录导出为 A4 检测报告(中文) | +| 列表检索 | 按样品编号 / 日期范围 / 材料类型 / 判定结论过滤 | + +## 3. 数据模型与存储 + +### 3.1 选型 + +SQLite,经由 **`rusqlite`(bundled 特性)** 直接在 `src-tauri` 内访问,不引入 `tauri-plugin-sql`。理由:计算结果是结构化 JSON,存取简单;bundled 模式免去系统 SQLite 依赖,Windows 打包干净。 + +库文件位置:`tauri::api::path::app_data_dir()` 下的 `history.db`(随应用数据目录,卸载可清理)。 + +### 3.2 表结构 + +```sql +CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sample_id TEXT, -- 样品编号(可空) + material_type TEXT NOT NULL, -- MaterialType 序列化 + calc_date TEXT, -- 计算日期 YYYY-MM-DD + created_at TEXT NOT NULL, -- 入库时间 RFC3339(前端传入) + ira_value REAL NOT NULL, -- 冗余列,供列表展示/排序/过滤 + ir_value REAL NOT NULL, + validity TEXT NOT NULL, -- Validity 序列化 + verdict TEXT NOT NULL, -- Verdict 序列化(用于列表标签与过滤) + input_json TEXT NOT NULL, -- 完整 SampleInput + result_json TEXT NOT NULL -- 完整 CalculationResult +); +CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at); +CREATE INDEX IF NOT EXISTS idx_records_sample ON records(sample_id); +``` + +设计要点:完整 `input_json` / `result_json` 保证回看与复算的保真;冗余标量列只为列表查询与过滤,避免反序列化全表。 + +### 3.3 复算保真 + +复算 = 读 `input_json` → `calculate_sample` 重新计算。因 MCM 用固定种子,结果可复现,可与存档 `result_json` 比对作为一致性自检(库内核版本升级时用于回归)。 + +## 4. 后端命令(`src-tauri`) + +新增 Tauri 命令,全部返回 `Result<_, String>`: + +```rust +save_record(input: SampleInput, result: CalculationResult, created_at: String) -> i64 // 返回 id +list_records(filter: RecordFilter) -> Vec +get_record(id: i64) -> RecordDetail // 含 input + result +delete_record(id: i64) -> () +export_excel(id: i64, path: String) -> () // 写 .xlsx 到用户选定路径 +export_report(id: i64, path: String) -> () // 见第 6 节 +``` + +```rust +struct RecordFilter { + sample_id: Option, // 模糊匹配 + material_type: Option, + date_from: Option, + date_to: Option, + verdict_kind: Option, // "Qualified" / "Unqualified" / ... +} +struct RecordSummary { + id: i64, sample_id: Option, material_type: MaterialType, + calc_date: Option, created_at: String, + ira_value: f64, ir_value: f64, validity: Validity, verdict: Verdict, +} +``` + +数据库连接用 `tauri::State>` 在 `setup` 中初始化并建表。 + +新增 crate(`src-tauri/Cargo.toml`):`rusqlite { features = ["bundled"] }`、`rust_xlsxwriter`。PDF 方案见第 6 节(可能 0 依赖)。 + +## 5. Excel 导出 + +用 `rust_xlsxwriter`(纯 Rust、原生 UTF-8,无 CJK 字体问题)。生成单工作簿三段: + +1. **样品信息**:编号、日期、材料类型、有效性、最终判定。 +2. **测量与校准**:各次 Ra/Th/K 测量值;校准系数 a/U/k。 +3. **结果**:IRa/Ir 值、u、U、相对扩展不确定度(k=1/k=2)、真值区间;各核素均值/校准活度/A 类/B 类/合成;MCM 平均值/标准偏差/区间/合格概率/不符合概率。 + +路径经 `@tauri-apps/plugin-dialog` 的保存对话框选定,Rust 侧写文件。 + +## 6. PDF 报告 + +中文 PDF 字体内嵌是主要难点,给两条路线,**推荐路线 A**: + +- **路线 A(推荐)— Webview 打印**:前端用一个独立的、A4 排版的「报告视图」组件渲染完整结果,调用 `window.print()`(或 Tauri 打印 API),用户在打印对话框选「另存为 PDF」。零新依赖、零字体问题、排版用 CSS 即可,所见即所得。代价:依赖系统打印到 PDF,非完全静默导出。 +- **路线 B — Rust 生成**:`printpdf` + 内嵌中文子集字体(如 Noto Sans SC)。可完全静默、可定制页眉页脚,但需打包字体、自行处理换行与分页,工作量大。 + +建议本期落 A,把"报告视图"做成可复用组件(同一组件既用于屏幕预览也用于打印),路线 B 留作后续真正"一键静默导出"需求时再做。 + +报告内容(对应 PDF 6.1–6.3): + +- 抬头:样品编号、计算日期、材料类型、限值表。 +- 检测结果:IRa、Ir 最佳估计值;95% 真值区间 [P2.5, P97.5];k=1 / k=2 相对扩展不确定度。 +- 有效性判定(总比活度 vs 37)、最终判定(合格/不合格/A·B·C/建议增加次数)。 +- MCM:95% 置信概率下不符合概率。 +- 各核素中间量明细表。 + +## 7. 界面变更 + +- 顶部加「保存到历史」按钮(计算成功后可用);保存成功后提示。 +- 新增「历史」入口(抽屉或独立 Tab):列表(编号/日期/材料/IRa/Ir/判定)+ 过滤栏;行操作「查看 / 复算 / 导出 Excel / 导出 PDF / 删除」。 +- 「查看」打开报告视图(即打印用组件)。 +- 计算日期从已有的 DatePicker 复用,`created_at` 取前端当前时间戳。 + +## 8. 测试 + +- Rust:DB 用内存库(`:memory:`)测 save→list→get→delete 往返;过滤条件命中;`export_excel` 生成非空且能被重新打开(校验首部魔数/可解析)。 +- 复算一致性:存档 `result_json` 与重算结果逐字段相等(固定种子)。 +- 前端:报告视图快照(关键字段渲染)、历史列表过滤交互。 + +## 9. 已确认决策 + +1. **PDF 路线**:路线 A —— 前端 A4 报告视图 + `window.print()` 另存为 PDF。✅ +2. **历史入口形态**:顶部 Tab 切换(计算 / 历史两页)。✅ +3. **保存时机**:仅用户点「保存到历史」时入库,避免调参脏数据。✅ +4. **导出范围**:PDF 报告 + Excel 数据表都做(实现独立,可分批落地)。✅ + +## 9.5 实现说明(落地后补记) + +- **rusqlite 版本**:本地 Rust 为 1.93,`libsqlite3-sys 0.38` 使用了未稳定的 `cfg_select!`,编译失败。已将 `rusqlite` 固定为 `0.37`(拉取 `libsqlite3-sys 0.35`)规避。升级工具链到 ≥1.94 后可放开。 +- **Excel 落盘**:`export_excel` 写入系统下载目录(取不到则回退文档目录),返回完整路径,前端以提示展示,无需对话框插件。 +- **PDF**:前端 `ReportView` + `ReportModal`,「打印 / 导出 PDF」走 `window.print()`,打印 CSS 用 visibility 隔离 `.report-root`,仅输出报告本体(隐藏 Modal 外框与遮罩)。 +- **命令入参**:`save_record` 用 `SaveArgs` 结构体包裹 `input/result/created_at`,避免 JS↔Rust 多词参数名映射歧义。 +- **f64 存储**:完整结果以 JSON 落库;serde_json 解析可能有 ~1e-14 的末位 ULP 偏差,对指数无实际影响,测试以容差比较。 + +## 10. 实施顺序 + +1. `src-tauri`:加 `rusqlite`,`setup` 建表 + 连接 State。 +2. 实现 save/list/get/delete 命令 + Rust 往返测试。 +3. 前端历史列表 + 过滤 + 保存按钮。 +4. 报告视图组件(屏幕预览)→ 接 `window.print()` 出 PDF。 +5. `rust_xlsxwriter` 导出命令 + 保存对话框。 +6. 端到端:算→存→列表过滤→查看→导出。 diff --git a/docs/建材放射性检测结果分析软件开发计算逻辑(1).pdf b/docs/建材放射性检测结果分析软件开发计算逻辑(1).pdf new file mode 100644 index 0000000..283e2cd Binary files /dev/null and b/docs/建材放射性检测结果分析软件开发计算逻辑(1).pdf differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 55fe52e..7a3d147 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -288,9 +288,13 @@ name = "ceramic-radioactivity-tauri" version = "0.1.0" dependencies = [ "ceramic-radioactivity", + "rusqlite", + "rust_xlsxwriter", "serde", + "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", ] [[package]] @@ -736,6 +740,18 @@ dependencies = [ "typeid", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -775,6 +791,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1216,6 +1233,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1695,6 +1721,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.2" @@ -1980,6 +2017,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2454,6 +2492,53 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust_xlsxwriter" +version = "0.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f281b687352597d29efaad39701d1167d5c48aa76fb973e392bc13e9d44e7f36" +dependencies = [ + "zip", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2625,9 +2710,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3098,6 +3183,64 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eefb2c18e8a605c23edb48fc56bb77381199e1a1e7f6ff0c9b970afe7b3cb8ee" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.1" @@ -3543,6 +3686,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -3663,6 +3812,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4145,6 +4300,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4178,13 +4342,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4215,6 +4396,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4227,6 +4414,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4239,12 +4432,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4257,6 +4462,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4269,6 +4480,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4281,6 +4498,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4293,6 +4516,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -4569,8 +4798,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 623fcb4..41abf88 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -8,5 +8,9 @@ tauri-build = { version = "2", features = [] } [dependencies] ceramic-radioactivity = { path = ".." } +rusqlite = { version = "0.37", features = ["bundled"] } +rust_xlsxwriter = "0.95.0" serde = { version = "1", features = ["derive"] } +serde_json = "1.0.150" tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2.7.1" diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs new file mode 100644 index 0000000..a591d21 --- /dev/null +++ b/src-tauri/src/db.rs @@ -0,0 +1,361 @@ +//! 历史记录的 SQLite 存储。完整 input/result 以 JSON 落库,另冗余若干标量列供列表查询与过滤。 + +use std::sync::Mutex; + +use ceramic_radioactivity::{CalculationResult, MaterialType, SampleInput, Validity, Verdict}; +use rusqlite::{Connection, ToSql}; +use serde::{Deserialize, Serialize}; + +/// 由 Tauri 托管的数据库连接。 +pub struct Db(pub Mutex); + +const CREATE_SQL: &str = " +CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sample_id TEXT, + material_type TEXT NOT NULL, + calc_date TEXT, + created_at TEXT NOT NULL, + ira_value REAL NOT NULL, + ir_value REAL NOT NULL, + validity TEXT NOT NULL, + verdict_kind TEXT NOT NULL, + verdict_json TEXT NOT NULL, + input_json TEXT NOT NULL, + result_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at); +CREATE INDEX IF NOT EXISTS idx_records_sample ON records(sample_id); +"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordFilter { + pub sample_id: Option, + pub material_type: Option, + pub date_from: Option, + pub date_to: Option, + pub verdict_kind: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordSummary { + pub id: i64, + pub sample_id: Option, + pub material_type: MaterialType, + pub calc_date: Option, + pub created_at: String, + pub ira_value: f64, + pub ir_value: f64, + pub validity: Validity, + pub verdict: Verdict, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordDetail { + pub summary: RecordSummary, + pub input: SampleInput, + pub result: CalculationResult, +} + +/// 建表(幂等)。 +pub fn init(conn: &Connection) -> Result<(), String> { + conn.execute_batch(CREATE_SQL).map_err(|e| e.to_string()) +} + +/// 稳定的判定大类,用于列表过滤(装饰材料的 A/B/C 归为 DecorativeClass)。 +pub fn verdict_kind(verdict: &Verdict) -> &'static str { + match verdict { + Verdict::Qualified => "Qualified", + Verdict::Unqualified => "Unqualified", + Verdict::NeedMoreMeasurements => "NeedMoreMeasurements", + Verdict::InvalidResult => "InvalidResult", + Verdict::DecorativeClass(_) => "DecorativeClass", + } +} + +pub fn save( + conn: &Connection, + input: &SampleInput, + result: &CalculationResult, + created_at: &str, +) -> Result { + let material_type = to_json(&input.material_type)?; + let validity = to_json(&result.analysis.validity)?; + let verdict_json = to_json(&result.analysis.verdict)?; + let input_json = to_json(input)?; + let result_json = to_json(result)?; + + conn.execute( + "INSERT INTO records ( + sample_id, material_type, calc_date, created_at, + ira_value, ir_value, validity, verdict_kind, verdict_json, + input_json, result_json + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + input.sample_id, + material_type, + input.calculation_date, + created_at, + result.ira.value, + result.ir.value, + validity, + verdict_kind(&result.analysis.verdict), + verdict_json, + input_json, + result_json, + ], + ) + .map_err(|e| e.to_string())?; + + Ok(conn.last_insert_rowid()) +} + +/// 列表查询行(标量 + 用于反序列化的 JSON 文本)。 +struct RawSummary { + id: i64, + sample_id: Option, + material_type: String, + calc_date: Option, + created_at: String, + ira_value: f64, + ir_value: f64, + validity: String, + verdict_json: String, +} + +pub fn list(conn: &Connection, filter: &RecordFilter) -> Result, String> { + let mut sql = String::from( + "SELECT id, sample_id, material_type, calc_date, created_at, + ira_value, ir_value, validity, verdict_json + FROM records WHERE 1 = 1", + ); + let mut args: Vec> = Vec::new(); + + if let Some(sample_id) = filter.sample_id.as_ref().filter(|s| !s.is_empty()) { + sql.push_str(" AND sample_id LIKE ?"); + args.push(Box::new(format!("%{sample_id}%"))); + } + if let Some(material_type) = &filter.material_type { + sql.push_str(" AND material_type = ?"); + args.push(Box::new(to_json(material_type)?)); + } + if let Some(date_from) = filter.date_from.as_ref().filter(|s| !s.is_empty()) { + sql.push_str(" AND calc_date >= ?"); + args.push(Box::new(date_from.clone())); + } + if let Some(date_to) = filter.date_to.as_ref().filter(|s| !s.is_empty()) { + sql.push_str(" AND calc_date <= ?"); + args.push(Box::new(date_to.clone())); + } + if let Some(kind) = filter.verdict_kind.as_ref().filter(|s| !s.is_empty()) { + sql.push_str(" AND verdict_kind = ?"); + args.push(Box::new(kind.clone())); + } + sql.push_str(" ORDER BY created_at DESC, id DESC"); + + let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?; + let params: Vec<&dyn ToSql> = args.iter().map(|b| b.as_ref()).collect(); + let raw_rows = stmt + .query_map(params.as_slice(), |row| { + Ok(RawSummary { + id: row.get(0)?, + sample_id: row.get(1)?, + material_type: row.get(2)?, + calc_date: row.get(3)?, + created_at: row.get(4)?, + ira_value: row.get(5)?, + ir_value: row.get(6)?, + validity: row.get(7)?, + verdict_json: row.get(8)?, + }) + }) + .map_err(|e| e.to_string())?; + + let mut summaries = Vec::new(); + for raw in raw_rows { + let raw = raw.map_err(|e| e.to_string())?; + summaries.push(raw_to_summary(raw)?); + } + Ok(summaries) +} + +pub fn get(conn: &Connection, id: i64) -> Result { + conn.query_row( + "SELECT id, sample_id, material_type, calc_date, created_at, + ira_value, ir_value, validity, verdict_json, input_json, result_json + FROM records WHERE id = ?1", + [id], + |row| { + Ok(( + RawSummary { + id: row.get(0)?, + sample_id: row.get(1)?, + material_type: row.get(2)?, + calc_date: row.get(3)?, + created_at: row.get(4)?, + ira_value: row.get(5)?, + ir_value: row.get(6)?, + validity: row.get(7)?, + verdict_json: row.get(8)?, + }, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + )) + }, + ) + .map_err(|e| e.to_string()) + .and_then(|(raw, input_json, result_json)| { + Ok(RecordDetail { + summary: raw_to_summary(raw)?, + input: from_json(&input_json)?, + result: from_json(&result_json)?, + }) + }) +} + +pub fn delete(conn: &Connection, id: i64) -> Result<(), String> { + conn.execute("DELETE FROM records WHERE id = ?1", [id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn raw_to_summary(raw: RawSummary) -> Result { + Ok(RecordSummary { + id: raw.id, + sample_id: raw.sample_id, + material_type: from_json(&raw.material_type)?, + calc_date: raw.calc_date, + created_at: raw.created_at, + ira_value: raw.ira_value, + ir_value: raw.ir_value, + validity: from_json(&raw.validity)?, + verdict: from_json(&raw.verdict_json)?, + }) +} + +fn to_json(value: &T) -> Result { + serde_json::to_string(value).map_err(|e| e.to_string()) +} + +fn from_json Deserialize<'de>>(text: &str) -> Result { + serde_json::from_str(text).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ceramic_radioactivity::{calculate_sample, CalibrationParams, NuclideMeasurements}; + + fn cal(factor: f64, u: f64) -> CalibrationParams { + CalibrationParams { + factor, + expanded_uncertainty_percent: u, + coverage_factor: 2.0, + } + } + + fn sample() -> (SampleInput, CalculationResult) { + let input = SampleInput { + ra: NuclideMeasurements { + measured_values: vec![100.0], + calibration: cal(0.916, 6.3), + }, + th: NuclideMeasurements { + measured_values: vec![110.0], + calibration: cal(0.884, 6.9), + }, + k: NuclideMeasurements { + measured_values: vec![560.0], + calibration: cal(0.961, 6.7), + }, + material_type: MaterialType::BuildingMainBody, + sample_id: Some("S-1".into()), + calculation_date: Some("2026-06-11".into()), + }; + let result = calculate_sample(input.clone()).unwrap(); + (input, result) + } + + fn empty_filter() -> RecordFilter { + RecordFilter { + sample_id: None, + material_type: None, + date_from: None, + date_to: None, + verdict_kind: None, + } + } + + #[test] + fn save_list_get_delete_roundtrip() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + let (input, result) = sample(); + + let id = save(&conn, &input, &result, "2026-06-11T10:00:00Z").unwrap(); + assert!(id > 0); + + let all = list(&conn, &empty_filter()).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].sample_id.as_deref(), Some("S-1")); + assert_eq!(all[0].verdict, result.analysis.verdict); + assert_eq!(all[0].material_type, MaterialType::BuildingMainBody); + + let detail = get(&conn, id).unwrap(); + // 离散字段精确比较;数值字段用容差(serde_json 的 f64 解析可能有末位 ULP 偏差,~1e-14,可忽略)。 + assert_eq!(detail.input.sample_id, input.sample_id); + assert_eq!(detail.input.material_type, input.material_type); + assert_eq!(detail.input.ra.measured_values, input.ra.measured_values); + assert_eq!(detail.result.measurement_count, result.measurement_count); + assert_eq!(detail.result.analysis.validity, result.analysis.validity); + assert_eq!(detail.result.analysis.verdict, result.analysis.verdict); + assert!((detail.result.ira.value - result.ira.value).abs() < 1e-9); + assert!((detail.result.ir.value - result.ir.value).abs() < 1e-9); + assert!( + (detail.result.mcm.overall_fail_probability - result.mcm.overall_fail_probability).abs() + < 1e-9 + ); + + delete(&conn, id).unwrap(); + assert!(list(&conn, &empty_filter()).unwrap().is_empty()); + } + + #[test] + fn filters_narrow_results() { + let conn = Connection::open_in_memory().unwrap(); + init(&conn).unwrap(); + let (input, result) = sample(); + save(&conn, &input, &result, "2026-06-11T10:00:00Z").unwrap(); + + let hit = list( + &conn, + &RecordFilter { + verdict_kind: Some("Qualified".into()), + sample_id: Some("S".into()), + ..empty_filter() + }, + ) + .unwrap(); + assert_eq!(hit.len(), 1); + + let miss = list( + &conn, + &RecordFilter { + verdict_kind: Some("Unqualified".into()), + ..empty_filter() + }, + ) + .unwrap(); + assert!(miss.is_empty()); + + let wrong_date = list( + &conn, + &RecordFilter { + date_from: Some("2026-07-01".into()), + ..empty_filter() + }, + ) + .unwrap(); + assert!(wrong_date.is_empty()); + } +} diff --git a/src-tauri/src/excel.rs b/src-tauri/src/excel.rs new file mode 100644 index 0000000..eb102cd --- /dev/null +++ b/src-tauri/src/excel.rs @@ -0,0 +1,217 @@ +//! 将一条历史记录导出为 .xlsx。rust_xlsxwriter 原生 UTF-8,无需处理中文字体。 + +use std::path::Path; + +use ceramic_radioactivity::{MaterialType, NuclideResult, Validity, Verdict}; +use rust_xlsxwriter::{Format, Workbook}; + +use crate::db::RecordDetail; + +/// 「另存为」对话框的默认文件名。 +pub fn default_file_name(detail: &RecordDetail) -> String { + let sample = detail + .summary + .sample_id + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or("未编号"); + let safe: String = sample + .chars() + .map(|c| if r#"\/:*?"<>|"#.contains(c) { '_' } else { c }) + .collect(); + format!("建材放射性检测_{safe}_{}.xlsx", detail.summary.id) +} + +/// 将 xlsx 写到用户选定的路径,返回完整路径。 +pub fn write_to_path(path: &Path, detail: &RecordDetail) -> Result { + let mut workbook = Workbook::new(); + let bold = Format::new().set_bold(); + + write_summary(&mut workbook, detail, &bold)?; + write_measurements(&mut workbook, detail, &bold)?; + write_results(&mut workbook, detail, &bold)?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + workbook + .save(path) + .map_err(|e| e.to_string()) + .map(|_| path.to_string_lossy().into_owned()) +} + +fn write_summary( + workbook: &mut Workbook, + detail: &RecordDetail, + bold: &Format, +) -> Result<(), String> { + let sheet = workbook.add_worksheet(); + sheet.set_name("概要").map_err(|e| e.to_string())?; + let s = &detail.summary; + let rows: [(&str, String); 7] = [ + ("样品编号", s.sample_id.clone().unwrap_or_else(|| "未编号".into())), + ("计算日期", s.calc_date.clone().unwrap_or_default()), + ("入库时间", s.created_at.clone()), + ("材料类型", material_text(&s.material_type).into()), + ("测量次数", detail.result.measurement_count.to_string()), + ("有效性", validity_text(&s.validity).into()), + ("最终判定", verdict_text(&s.verdict)), + ]; + for (i, (label, value)) in rows.iter().enumerate() { + let row = i as u32; + sheet.write_with_format(row, 0, *label, bold).map_err(|e| e.to_string())?; + sheet.write(row, 1, value.as_str()).map_err(|e| e.to_string())?; + } + sheet.set_column_width(0, 14).ok(); + sheet.set_column_width(1, 28).ok(); + Ok(()) +} + +fn write_measurements( + workbook: &mut Workbook, + detail: &RecordDetail, + bold: &Format, +) -> Result<(), String> { + let sheet = workbook.add_worksheet(); + sheet.set_name("测量与校准").map_err(|e| e.to_string())?; + let input = &detail.input; + + for (col, header) in ["序号", "Ra-226", "Th-232", "K-40"].iter().enumerate() { + sheet.write_with_format(0, col as u16, *header, bold).map_err(|e| e.to_string())?; + } + let n = input.ra.measured_values.len(); + for i in 0..n { + let row = (i + 1) as u32; + sheet.write(row, 0, (i + 1) as f64).map_err(|e| e.to_string())?; + sheet.write(row, 1, input.ra.measured_values[i]).map_err(|e| e.to_string())?; + sheet.write(row, 2, input.th.measured_values[i]).map_err(|e| e.to_string())?; + sheet.write(row, 3, input.k.measured_values[i]).map_err(|e| e.to_string())?; + } + + let base = (n + 2) as u32; + sheet.write_with_format(base, 0, "校准参数", bold).map_err(|e| e.to_string())?; + for (col, header) in ["核素", "校准系数 a", "U(%)", "k"].iter().enumerate() { + sheet.write_with_format(base + 1, col as u16, *header, bold).map_err(|e| e.to_string())?; + } + let cals = [ + ("Ra", input.ra.calibration), + ("Th", input.th.calibration), + ("K", input.k.calibration), + ]; + for (i, (name, cal)) in cals.iter().enumerate() { + let row = base + 2 + i as u32; + sheet.write(row, 0, *name).map_err(|e| e.to_string())?; + sheet.write(row, 1, cal.factor).map_err(|e| e.to_string())?; + sheet.write(row, 2, cal.expanded_uncertainty_percent).map_err(|e| e.to_string())?; + sheet.write(row, 3, cal.coverage_factor).map_err(|e| e.to_string())?; + } + sheet.set_column_width(0, 10).ok(); + Ok(()) +} + +fn write_results( + workbook: &mut Workbook, + detail: &RecordDetail, + bold: &Format, +) -> Result<(), String> { + let sheet = workbook.add_worksheet(); + sheet.set_name("结果与MCM").map_err(|e| e.to_string())?; + let r = &detail.result; + + // 指数结果。 + for (col, header) in ["指数", "值", "U(k=2)", "相对(k=2)%", "P2.5", "P97.5"] + .iter() + .enumerate() + { + sheet.write_with_format(0, col as u16, *header, bold).map_err(|e| e.to_string())?; + } + let indices = [("IRa", &r.ira), ("Ir", &r.ir)]; + for (i, (name, idx)) in indices.iter().enumerate() { + let row = (i + 1) as u32; + sheet.write(row, 0, *name).map_err(|e| e.to_string())?; + sheet.write(row, 1, idx.value).map_err(|e| e.to_string())?; + sheet.write(row, 2, idx.expanded_uncertainty).map_err(|e| e.to_string())?; + sheet.write(row, 3, idx.relative_expanded_uncertainty_percent).map_err(|e| e.to_string())?; + sheet.write(row, 4, idx.p2_5).map_err(|e| e.to_string())?; + sheet.write(row, 5, idx.p97_5).map_err(|e| e.to_string())?; + } + + // 核素中间量。 + let base = 4u32; + for (col, header) in ["核素", "均值", "校准活度", "A类", "B类相对", "合成不确定度"] + .iter() + .enumerate() + { + sheet.write_with_format(base, col as u16, *header, bold).map_err(|e| e.to_string())?; + } + let nuclides: [(&str, &NuclideResult); 3] = + [("Ra-226", &r.ra), ("Th-232", &r.th), ("K-40", &r.k)]; + for (i, (name, nr)) in nuclides.iter().enumerate() { + let row = base + 1 + i as u32; + sheet.write(row, 0, *name).map_err(|e| e.to_string())?; + sheet.write(row, 1, nr.mean_measured).map_err(|e| e.to_string())?; + sheet.write(row, 2, nr.mean_calibrated).map_err(|e| e.to_string())?; + sheet.write(row, 3, nr.type_a_uncertainty).map_err(|e| e.to_string())?; + sheet.write(row, 4, nr.type_b_relative).map_err(|e| e.to_string())?; + sheet.write(row, 5, nr.combined_uncertainty).map_err(|e| e.to_string())?; + } + + // MCM。 + let mbase = base + 5; + sheet.write_with_format(mbase, 0, format!("蒙特卡洛仿真({} 次)", r.mcm.iterations), bold) + .map_err(|e| e.to_string())?; + for (col, header) in ["指数", "平均值", "标准偏差", "P2.5", "P97.5", "标准值", "合格概率"] + .iter() + .enumerate() + { + sheet.write_with_format(mbase + 1, col as u16, *header, bold).map_err(|e| e.to_string())?; + } + let mcm = [("IRa", &r.mcm.ira), ("Ir", &r.mcm.ir)]; + for (i, (name, stats)) in mcm.iter().enumerate() { + let row = mbase + 2 + i as u32; + sheet.write(row, 0, *name).map_err(|e| e.to_string())?; + sheet.write(row, 1, stats.mean).map_err(|e| e.to_string())?; + sheet.write(row, 2, stats.std_dev).map_err(|e| e.to_string())?; + sheet.write(row, 3, stats.p2_5).map_err(|e| e.to_string())?; + sheet.write(row, 4, stats.p97_5).map_err(|e| e.to_string())?; + sheet.write(row, 5, stats.standard_value).map_err(|e| e.to_string())?; + sheet.write(row, 6, stats.pass_probability).map_err(|e| e.to_string())?; + } + let frow = mbase + 4; + sheet.write_with_format(frow, 0, "综合不符合概率", bold).map_err(|e| e.to_string())?; + sheet.write(frow, 1, r.mcm.overall_fail_probability).map_err(|e| e.to_string())?; + + sheet.set_column_width(0, 12).ok(); + Ok(()) +} + +fn material_text(material: &MaterialType) -> &'static str { + match material { + MaterialType::BuildingMainBody => "建筑主体材料", + MaterialType::HollowBuildingMainBody => "空心率>25% 主体材料", + MaterialType::DecorativeMaterial => "装饰装修材料", + } +} + +fn validity_text(validity: &Validity) -> &'static str { + match validity { + Validity::LowActivityExempt => "有效(低活度豁免 ≤37 Bq/kg)", + Validity::UncertaintyAcceptable => "有效(Ur(IRa) ≤ 20%)", + Validity::Invalid => "无效(Ur(IRa) > 20%)", + } +} + +fn verdict_text(verdict: &Verdict) -> String { + match verdict { + Verdict::Qualified => "合格".into(), + Verdict::Unqualified => "不合格".into(), + Verdict::NeedMoreMeasurements => "建议增加至 6 次测量".into(), + Verdict::InvalidResult => "结果无效".into(), + Verdict::DecorativeClass(class) => match class { + ceramic_radioactivity::DecorClass::A => "装饰装修 A 类".into(), + ceramic_radioactivity::DecorClass::B => "装饰装修 B 类".into(), + ceramic_radioactivity::DecorClass::C => "装饰装修 C 类".into(), + ceramic_radioactivity::DecorClass::Unqualified => "不合格(不可用于建材)".into(), + }, + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aa15110..47e42aa 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,15 +1,102 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod db; +mod excel; + +use std::sync::Mutex; + use ceramic_radioactivity::{calculate_sample, CalculationResult, SampleInput}; +use db::{Db, RecordDetail, RecordFilter, RecordSummary}; +use rusqlite::Connection; +use tauri::{Manager, State}; +use tauri_plugin_dialog::DialogExt; #[tauri::command] fn calculate(input: SampleInput) -> Result { calculate_sample(input).map_err(|error| error.to_string()) } +#[derive(serde::Deserialize)] +struct SaveArgs { + input: SampleInput, + result: CalculationResult, + created_at: String, +} + +#[tauri::command] +fn save_record(db: State, args: SaveArgs) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + db::save(&conn, &args.input, &args.result, &args.created_at) +} + +#[tauri::command] +fn list_records(db: State, filter: RecordFilter) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + db::list(&conn, &filter) +} + +#[tauri::command] +fn get_record(db: State, id: i64) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + db::get(&conn, id) +} + +#[tauri::command] +fn delete_record(db: State, id: i64) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + db::delete(&conn, id) +} + +/// 弹出「另存为」对话框,用户选定路径后写出 xlsx。取消则返回 `None`。 +#[tauri::command] +fn export_excel(app: tauri::AppHandle, db: State, id: i64) -> Result, String> { + let detail = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + db::get(&conn, id)? + }; + + let start_dir = app + .path() + .download_dir() + .or_else(|_| app.path().document_dir()) + .ok(); + + let mut builder = app + .dialog() + .file() + .set_title("导出 Excel") + .set_file_name(excel::default_file_name(&detail)) + .add_filter("Excel 工作簿", &["xlsx"]); + if let Some(dir) = start_dir { + builder = builder.set_directory(dir); + } + + let Some(file_path) = builder.blocking_save_file() else { + return Ok(None); // 用户取消 + }; + let path = file_path.into_path().map_err(|e| e.to_string())?; + excel::write_to_path(&path, &detail).map(Some) +} + fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![calculate]) + .plugin(tauri_plugin_dialog::init()) + .setup(|app| { + let dir = app.path().app_data_dir()?; + std::fs::create_dir_all(&dir)?; + let conn = Connection::open(dir.join("history.db"))?; + db::init(&conn).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + app.manage(Db(Mutex::new(conn))); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + calculate, + save_record, + list_records, + get_record, + delete_record, + export_excel + ]) .run(tauri::generate_context!()) .expect("failed to run Tauri application"); } diff --git a/src/calculator.rs b/src/calculator.rs index 85578ab..488747f 100644 --- a/src/calculator.rs +++ b/src/calculator.rs @@ -1,18 +1,23 @@ use crate::domain::{ - CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements, - NuclideResult, SampleInput, + AnalysisResult, CalculationError, CalculationResult, Conclusion, DecorClass, IndexResult, + MaterialType, NuclideMeasurements, NuclideResult, SampleInput, Validity, Verdict, }; use crate::mcm::run_monte_carlo; +/// 相对(扩展)不确定度可接受上限,用于 3.1 有效性判定与 legacy conclusion。 const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0; +/// 3.1 低活度豁免阈值:总比活度 ≤ 37 Bq/kg 时结果直接有效。 +const TOTAL_ACTIVITY_EXEMPT: f64 = 37.0; +/// 指数的包含因子 k(GUM 2.2.4 / 2.2.6)。 +const COVERAGE_FACTOR: f64 = 2.0; pub fn calculate_sample(input: SampleInput) -> Result { validate_input(&input)?; let n = input.ra.measured_values.len(); - let ra = calculate_nuclide("Ra", &input.ra)?; - let th = calculate_nuclide("Th", &input.th)?; - let k = calculate_nuclide("K", &input.k)?; + let ra = calculate_nuclide(&input.ra)?; + let th = calculate_nuclide(&input.th)?; + let k = calculate_nuclide(&input.k)?; let ira = calculate_ira(&ra); let ir = calculate_ir(&ra, &th, &k); @@ -26,7 +31,8 @@ pub fn calculate_sample(input: SampleInput) -> Result Result Result<(), CalculationError> { ]; for (nuclide, count) in counts { - if count < 2 { + if count < 1 { return Err(CalculationError::TooFewMeasurements { nuclide, count }); } } @@ -90,27 +97,21 @@ fn validate_nuclide( Ok(()) } -fn calculate_nuclide( - nuclide: &'static str, - measurements: &NuclideMeasurements, -) -> Result { - let n = measurements.measured_values.len(); +fn calculate_nuclide(measurements: &NuclideMeasurements) -> Result { + let factor = measurements.calibration.factor; let mean_measured = mean(&measurements.measured_values); - let mean_calibrated = mean_measured * measurements.calibration.factor; + let mean_calibrated = mean_measured * factor; let type_a_uncertainty = type_a_uncertainty(&measurements.measured_values)?; - let type_b_relative = - measurements.calibration.expanded_uncertainty_percent / 100.0 / measurements.calibration.coverage_factor; - let type_b_uncertainty = measurements.calibration.factor * type_b_relative; + let type_b_relative = measurements.calibration.expanded_uncertainty_percent + / 100.0 + / measurements.calibration.coverage_factor; + let type_b_uncertainty = factor * type_b_relative; let sensitivity_coefficient = mean_measured; - let combined_uncertainty = (type_a_uncertainty.powi(2) + // 校准比活度 C = mean·a,对测量值 A 的灵敏系数为 a,故 A 类项为 a·uA。 + let combined_uncertainty = ((factor * type_a_uncertainty).powi(2) + (sensitivity_coefficient * type_b_uncertainty).powi(2)) .sqrt(); - if n < 6 && range_coefficient(n).is_none() { - return Err(CalculationError::UnsupportedRangeMethodCount { count: n }); - } - - let _ = nuclide; Ok(NuclideResult { mean_measured, mean_calibrated, @@ -125,11 +126,7 @@ fn calculate_nuclide( fn calculate_ira(ra: &NuclideResult) -> IndexResult { let value = ra.mean_calibrated / 200.0; let standard_uncertainty = ra.combined_uncertainty / 200.0; - IndexResult { - value, - standard_uncertainty, - relative_uncertainty_percent: relative_percent(standard_uncertainty, value), - } + make_index(value, standard_uncertainty) } fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> IndexResult { @@ -138,16 +135,134 @@ fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> In + (th.combined_uncertainty / 260.0).powi(2) + (k.combined_uncertainty / 4200.0).powi(2)) .sqrt(); + make_index(value, standard_uncertainty) +} + +/// 由指数值与标准不确定度构造完整的 `IndexResult`(含扩展不确定度与 GUM 真值区间)。 +fn make_index(value: f64, standard_uncertainty: f64) -> IndexResult { + let expanded_uncertainty = standard_uncertainty * COVERAGE_FACTOR; IndexResult { value, standard_uncertainty, + expanded_uncertainty, relative_uncertainty_percent: relative_percent(standard_uncertainty, value), + relative_expanded_uncertainty_percent: relative_percent(expanded_uncertainty, value), + p2_5: value - expanded_uncertainty, + p97_5: value + expanded_uncertainty, } } +/// 3.1 有效性 + 3.2 临界值判定。 +fn analyze( + material: MaterialType, + ra: &NuclideResult, + th: &NuclideResult, + k: &NuclideResult, + ira: &IndexResult, + ir: &IndexResult, +) -> AnalysisResult { + let total_calibrated_activity = ra.mean_calibrated + th.mean_calibrated + k.mean_calibrated; + + // 3.1 有效性:低活度豁免,否则看 IRa 的相对扩展不确定度(k=2)。 + let validity = if total_calibrated_activity <= TOTAL_ACTIVITY_EXEMPT { + Validity::LowActivityExempt + } else if ira.relative_expanded_uncertainty_percent <= ACCEPTANCE_LIMIT_PERCENT { + Validity::UncertaintyAcceptable + } else { + Validity::Invalid + }; + + // 3.2 临界值判定。 + let verdict = if validity == Validity::Invalid { + Verdict::InvalidResult + } else { + match material { + MaterialType::DecorativeMaterial => judge_decorative(ira, ir), + single_tier => judge_single_tier(single_tier, ira, ir), + } + }; + + AnalysisResult { + total_calibrated_activity, + validity, + verdict, + } +} + +/// 单个指数真值区间相对极限值的三态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TierCheck { + /// 区间整体在限值之下:合格。 + Pass, + /// 区间整体在限值之上:超标。 + Fail, + /// 区间跨越限值:需增加测量次数。 + Straddle, +} + +/// 判断指数真值区间相对极限值的位置;`None` 表示该指数无约束。 +fn check_index(index: &IndexResult, limit: Option) -> TierCheck { + match limit { + None => TierCheck::Pass, + Some(limit) => { + if index.p97_5 < limit { + TierCheck::Pass + } else if index.p2_5 > limit { + TierCheck::Fail + } else { + TierCheck::Straddle + } + } + } +} + +/// 合并同一级别多个指数的判定:超标优先,其次跨越,全部合格才合格。 +fn combine(checks: &[TierCheck]) -> TierCheck { + if checks.contains(&TierCheck::Fail) { + TierCheck::Fail + } else if checks.contains(&TierCheck::Straddle) { + TierCheck::Straddle + } else { + TierCheck::Pass + } +} + +/// 主体/空心材料:单级判定。 +fn judge_single_tier(material: MaterialType, ira: &IndexResult, ir: &IndexResult) -> Verdict { + let tier = &material.tiers()[0]; + match combine(&[ + check_index(ira, tier.ira_limit), + check_index(ir, tier.ir_limit), + ]) { + TierCheck::Pass => Verdict::Qualified, + TierCheck::Straddle => Verdict::NeedMoreMeasurements, + TierCheck::Fail => Verdict::Unqualified, + } +} + +/// 装饰装修材料:A→B→C 级联分类。 +fn judge_decorative(ira: &IndexResult, ir: &IndexResult) -> Verdict { + let tiers = MaterialType::DecorativeMaterial.tiers(); + let classes = [DecorClass::A, DecorClass::B, DecorClass::C]; + for (tier, class) in tiers.iter().zip(classes) { + match combine(&[ + check_index(ira, tier.ira_limit), + check_index(ir, tier.ir_limit), + ]) { + TierCheck::Pass => return Verdict::DecorativeClass(class), + TierCheck::Straddle => return Verdict::NeedMoreMeasurements, + TierCheck::Fail => continue, + } + } + Verdict::DecorativeClass(DecorClass::Unqualified) +} + fn type_a_uncertainty(values: &[f64]) -> Result { let n = values.len(); - if n >= 6 { + if n <= 1 { + // 单次测量:A 类不确定度为 0(PDF 2.2.1)。 + Ok(0.0) + } else if n >= 6 { Ok(sample_standard_deviation(values) / (n as f64).sqrt()) } else { let coefficient = diff --git a/src/domain.rs b/src/domain.rs index 368eb5c..2c50e28 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -8,12 +8,82 @@ pub struct SampleInput { pub ra: NuclideMeasurements, pub th: NuclideMeasurements, pub k: NuclideMeasurements, - /// 合格判定标准值(限值)。前端可省略,默认 IRa ≤ 1.0、Ir ≤ 1.0(GB 6566 主体材料)。 + /// 样品材料类型,决定限值集(GB 6566)。默认建筑主体材料。 #[serde(default)] - pub limits: AcceptanceLimits, + pub material_type: MaterialType, + /// 样品编号(输出元信息,6.1)。 + #[serde(default)] + pub sample_id: Option, + /// 计算日期(前端传入的 ISO 字符串,6.1)。 + #[serde(default)] + pub calculation_date: Option, } -/// 内照射指数 IRa 与外照射指数 Ir 的合格判定标准值(限值)。 +/// 样品材料类型。每种类型对应一组(或多级)限值。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum MaterialType { + /// 建筑主体材料:IRa ≤ 1.0、Ir ≤ 1.0。 + #[default] + BuildingMainBody, + /// 空心率大于 25% 的建筑主体材料:IRa ≤ 1.0、Ir ≤ 1.3。 + HollowBuildingMainBody, + /// 装饰装修材料:按 A / B / C 三级分类。 + DecorativeMaterial, +} + +/// 一级限值(装饰材料的 C 类无 IRa 约束,故用 `Option`)。 +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct LimitTier { + pub label: &'static str, + pub ira_limit: Option, + pub ir_limit: Option, +} + +impl MaterialType { + /// 从严到宽的限值阶梯。主体/空心材料各 1 级;装饰材料 A/B/C 三级。 + pub fn tiers(&self) -> &'static [LimitTier] { + match self { + MaterialType::BuildingMainBody => &[LimitTier { + label: "合格", + ira_limit: Some(1.0), + ir_limit: Some(1.0), + }], + MaterialType::HollowBuildingMainBody => &[LimitTier { + label: "合格", + ira_limit: Some(1.0), + ir_limit: Some(1.3), + }], + MaterialType::DecorativeMaterial => &[ + LimitTier { + label: "A", + ira_limit: Some(1.0), + ir_limit: Some(1.3), + }, + LimitTier { + label: "B", + ira_limit: Some(1.3), + ir_limit: Some(1.9), + }, + LimitTier { + label: "C", + ira_limit: None, + ir_limit: Some(2.8), + }, + ], + } + } + + /// MCM 比较所用的主限值(取最严一级,无约束项以 +∞ 表示)。 + pub fn primary_limits(&self) -> AcceptanceLimits { + let tier = &self.tiers()[0]; + AcceptanceLimits { + ira_limit: tier.ira_limit.unwrap_or(f64::INFINITY), + ir_limit: tier.ir_limit.unwrap_or(f64::INFINITY), + } + } +} + +/// 内照射指数 IRa 与外照射指数 Ir 的合格判定标准值(限值)。MCM 仿真比较使用。 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct AcceptanceLimits { pub ira_limit: f64, @@ -51,10 +121,59 @@ pub struct CalculationResult { pub ira: IndexResult, pub ir: IndexResult, pub conclusion: Conclusion, + /// 分析判定结果(有效性 + 合格/不合格/分级)。 + pub analysis: AnalysisResult, /// 蒙特卡洛法(MCM)仿真结果。 pub mcm: McmResult, } +/// 分析判定结果(PDF 第 3 节)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnalysisResult { + /// 总比活度 A1·a + A2·b + A3·c(Bq/kg),用于 3.1 有效性判定。 + pub total_calibrated_activity: f64, + /// 有效性判定结果。 + pub validity: Validity, + /// 最终判定结论。 + pub verdict: Verdict, +} + +/// 3.1 有效性判定结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Validity { + /// 总比活度 ≤ 37 Bq/kg,直接有效。 + LowActivityExempt, + /// 总比活度 > 37 Bq/kg 且 Ur(IRa) ≤ 20%(k=2),有效。 + UncertaintyAcceptable, + /// 总比活度 > 37 Bq/kg 且 Ur(IRa) > 20%,无效。 + Invalid, +} + +/// 3.2 临界值判定的最终结论。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Verdict { + /// 主体/空心材料:合格。 + Qualified, + /// 不合格。 + Unqualified, + /// 装饰装修材料:A/B/C 分级结果。 + DecorativeClass(DecorClass), + /// 真值区间跨越极限值,建议增加测量次数至 6 次。 + NeedMoreMeasurements, + /// 有效性不成立,结果无效。 + InvalidResult, +} + +/// 装饰装修材料分级。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DecorClass { + A, + B, + C, + /// 不满足 C 类,不可用于建筑材料。 + Unqualified, +} + /// 蒙特卡洛法(MCM)整体仿真结果。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct McmResult { @@ -103,8 +222,18 @@ pub struct NuclideResult { #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct IndexResult { pub value: f64, + /// 标准(合成)不确定度 u。 pub standard_uncertainty: f64, + /// 扩展不确定度 U = u·k(k=2)。 + pub expanded_uncertainty: f64, + /// 相对标准不确定度 u/value(k=1),百分数。 pub relative_uncertainty_percent: f64, + /// 相对扩展不确定度 U/value(k=2),百分数。 + pub relative_expanded_uncertainty_percent: f64, + /// 95% 真值区间下限 = value − U(GUM 解析法)。 + pub p2_5: f64, + /// 95% 真值区间上限 = value + U。 + pub p97_5: f64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/lib.rs b/src/lib.rs index c41623d..9ff9cbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod mcm; pub use calculator::calculate_sample; pub use domain::{ - AcceptanceLimits, CalculationError, CalculationResult, CalibrationParams, Conclusion, - IndexResult, McmIndexStats, McmResult, NuclideMeasurements, NuclideResult, SampleInput, + AcceptanceLimits, AnalysisResult, CalculationError, CalculationResult, CalibrationParams, + Conclusion, DecorClass, IndexResult, LimitTier, MaterialType, McmIndexStats, McmResult, + NuclideMeasurements, NuclideResult, SampleInput, Validity, Verdict, }; diff --git a/tests/calculator_tests.rs b/tests/calculator_tests.rs index 1189afe..4191d66 100644 --- a/tests/calculator_tests.rs +++ b/tests/calculator_tests.rs @@ -1,41 +1,45 @@ use ceramic_radioactivity::{ - calculate_sample, AcceptanceLimits, CalibrationParams, Conclusion, NuclideMeasurements, - SampleInput, + calculate_sample, CalibrationParams, Conclusion, DecorClass, MaterialType, NuclideMeasurements, + SampleInput, Validity, Verdict, }; +fn calibration(factor: f64, expanded_uncertainty_percent: f64) -> CalibrationParams { + CalibrationParams { + factor, + expanded_uncertainty_percent, + coverage_factor: 2.0, + } +} + fn default_input() -> SampleInput { SampleInput { ra: NuclideMeasurements { measured_values: vec![100.0, 102.0, 98.0, 101.0, 99.0, 100.0], - calibration: CalibrationParams { - factor: 0.916, - expanded_uncertainty_percent: 6.3, - coverage_factor: 2.0, - }, + calibration: calibration(0.916, 6.3), }, th: NuclideMeasurements { measured_values: vec![110.0, 111.0, 109.0, 110.0, 112.0, 108.0], - calibration: CalibrationParams { - factor: 0.884, - expanded_uncertainty_percent: 6.9, - coverage_factor: 2.0, - }, + calibration: calibration(0.884, 6.9), }, k: NuclideMeasurements { measured_values: vec![560.0, 565.0, 555.0, 562.0, 558.0, 561.0], - calibration: CalibrationParams { - factor: 0.961, - expanded_uncertainty_percent: 6.7, - coverage_factor: 2.0, - }, - }, - limits: AcceptanceLimits { - ira_limit: 1.0, - ir_limit: 1.0, + calibration: calibration(0.961, 6.7), }, + material_type: MaterialType::BuildingMainBody, + sample_id: None, + calculation_date: None, } } +/// 由目标校准比活度构造 n=6 的等值输入(A 类不确定度为 0,仅保留 B 类)。 +fn from_calibrated(ra_cal: f64, th_cal: f64, k_cal: f64) -> SampleInput { + let mut input = default_input(); + input.ra.measured_values = vec![ra_cal / 0.916; 6]; + input.th.measured_values = vec![th_cal / 0.884; 6]; + input.k.measured_values = vec![k_cal / 0.961; 6]; + input +} + #[test] fn calculates_indices_and_ok_conclusion_for_six_measurements() { let result = calculate_sample(default_input()).expect("valid sample should calculate"); @@ -47,6 +51,146 @@ fn calculates_indices_and_ok_conclusion_for_six_measurements() { assert_close(result.ira.value, 0.458, 1e-9); assert_close(result.ir.value, 0.749_739_035_821_535_9, 1e-9); assert_eq!(result.conclusion, Conclusion::Ok); + assert_eq!(result.analysis.verdict, Verdict::Qualified); +} + +/// 对齐 PDF 单次测量算例(A1=83.439, A2=116.995, A3=554.268)。 +#[test] +fn single_measurement_matches_pdf_example() { + let input = SampleInput { + ra: NuclideMeasurements { + measured_values: vec![83.439], + calibration: calibration(0.916, 6.3), + }, + th: NuclideMeasurements { + measured_values: vec![116.995], + calibration: calibration(0.884, 6.9), + }, + k: NuclideMeasurements { + measured_values: vec![554.268], + calibration: calibration(0.961, 6.7), + }, + material_type: MaterialType::BuildingMainBody, + sample_id: Some("PDF-EX".to_string()), + calculation_date: Some("2026-06-11".to_string()), + }; + + let result = calculate_sample(input).expect("single measurement should calculate"); + + assert_eq!(result.measurement_count, 1); + // A 类不确定度为 0。 + assert_close(result.ra.type_a_uncertainty, 0.0, 1e-12); + + // 2.1 检测结果。 + assert_close(result.ira.value, 0.38, 5e-3); + assert_close(result.ir.value, 0.73, 5e-3); + + // 2.2.3 标准不确定度、2.2.4 扩展不确定度、2.2.6 真值区间。 + assert_close(result.ira.standard_uncertainty, 0.012, 5e-4); + assert_close(result.ira.expanded_uncertainty, 0.024, 1e-3); + // PDF 区间用已四舍五入的 0.38±0.024 得 0.36/0.40;此处用未舍入值,放宽容差。 + assert_close(result.ira.p2_5, 0.36, 1e-2); + assert_close(result.ira.p97_5, 0.40, 1e-2); + assert_close(result.ir.standard_uncertainty, 0.016, 5e-4); + assert_close(result.ir.expanded_uncertainty, 0.032, 1e-3); + + // 2.2.5 相对扩展不确定度 k=2。 + assert_close(result.ira.relative_expanded_uncertainty_percent, 6.3, 0.2); + assert_close(result.ir.relative_expanded_uncertainty_percent, 4.4, 0.2); + + // 3.1 有效性 + 3.2 判定。 + assert_close(result.analysis.total_calibrated_activity, 712.5, 1.0); + assert_eq!(result.analysis.validity, Validity::UncertaintyAcceptable); + assert_eq!(result.analysis.verdict, Verdict::Qualified); +} + +#[test] +fn low_activity_sample_is_exempt_and_valid() { + let input = SampleInput { + ra: NuclideMeasurements { + measured_values: vec![2.0], + calibration: calibration(0.916, 6.3), + }, + th: NuclideMeasurements { + measured_values: vec![2.0], + calibration: calibration(0.884, 6.9), + }, + k: NuclideMeasurements { + measured_values: vec![2.0], + calibration: calibration(0.961, 6.7), + }, + material_type: MaterialType::BuildingMainBody, + sample_id: None, + calculation_date: None, + }; + + let result = calculate_sample(input).expect("low activity sample should calculate"); + + assert!(result.analysis.total_calibrated_activity <= 37.0); + assert_eq!(result.analysis.validity, Validity::LowActivityExempt); + assert_eq!(result.analysis.verdict, Verdict::Qualified); +} + +#[test] +fn high_uncertainty_above_37_is_invalid() { + let mut input = default_input(); + input.ra.measured_values = vec![10.0, 200.0, 400.0]; + input.th.measured_values = vec![10.0, 200.0, 400.0]; + input.k.measured_values = vec![10.0, 200.0, 400.0]; + + let result = calculate_sample(input).expect("valid sample should calculate"); + + assert!(result.analysis.total_calibrated_activity > 37.0); + assert_eq!(result.analysis.validity, Validity::Invalid); + assert_eq!(result.analysis.verdict, Verdict::InvalidResult); +} + +#[test] +fn main_body_unqualified_when_interval_above_limit() { + // IRa ≈ 1.5,区间整体高于 1.0。 + let result = + calculate_sample(from_calibrated(300.0, 50.0, 50.0)).expect("valid sample should calculate"); + + assert!(result.ira.p2_5 > 1.0); + assert_eq!(result.analysis.verdict, Verdict::Unqualified); +} + +#[test] +fn main_body_needs_more_measurements_when_interval_straddles_limit() { + // IRa = 1.0,区间跨越 1.0。 + let result = + calculate_sample(from_calibrated(200.0, 50.0, 50.0)).expect("valid sample should calculate"); + + assert!(result.ira.p2_5 < 1.0 && result.ira.p97_5 > 1.0); + assert_eq!(result.analysis.verdict, Verdict::NeedMoreMeasurements); +} + +#[test] +fn decorative_material_classifies_into_tiers() { + // A 类:IRa、Ir 均低。 + let a = calculate_sample(decorative(100.0, 100.0, 100.0)).expect("valid"); + assert_eq!(a.analysis.verdict, Verdict::DecorativeClass(DecorClass::A)); + + // B 类:Ir 超 A 限(1.3) 但在 B 限(1.9) 内,IRa 低。 + let b = calculate_sample(decorative(100.0, 317.0, 42.0)).expect("valid"); + assert_eq!(b.analysis.verdict, Verdict::DecorativeClass(DecorClass::B)); + + // C 类:Ir 超 B 限(1.9) 但在 C 限(2.8) 内。 + let c = calculate_sample(decorative(100.0, 520.0, 100.0)).expect("valid"); + assert_eq!(c.analysis.verdict, Verdict::DecorativeClass(DecorClass::C)); + + // 不合格:Ir 超 C 限(2.8)。 + let fail = calculate_sample(decorative(100.0, 900.0, 100.0)).expect("valid"); + assert_eq!( + fail.analysis.verdict, + Verdict::DecorativeClass(DecorClass::Unqualified) + ); +} + +fn decorative(ra_cal: f64, th_cal: f64, k_cal: f64) -> SampleInput { + let mut input = from_calibrated(ra_cal, th_cal, k_cal); + input.material_type = MaterialType::DecorativeMaterial; + input } #[test] @@ -125,14 +269,12 @@ fn monte_carlo_is_deterministic_for_same_input() { } #[test] -fn monte_carlo_gives_about_half_pass_probability_when_limit_equals_mean() { - let mut input = default_input(); - let analytical = calculate_sample(input.clone()).expect("valid sample should calculate"); - - // 将 IRa 标准值设为其均值,合格概率应接近 0.5。 - input.limits.ira_limit = analytical.ira.value; - let result = calculate_sample(input).expect("valid sample should calculate"); +fn monte_carlo_gives_about_half_pass_probability_when_index_equals_limit() { + // 主体材料 IRa 标准值为 1.0;构造 IRa=1.0 的样本,合格概率应接近 0.5。 + let result = + calculate_sample(from_calibrated(200.0, 50.0, 50.0)).expect("valid sample should calculate"); + assert_close(result.ira.value, 1.0, 1e-9); assert_close(result.mcm.ira.pass_probability, 0.5, 0.03); assert_close( result.mcm.ira.fail_probability, diff --git a/ui/public/logo.png b/ui/public/logo.png new file mode 100644 index 0000000..0e793b7 Binary files /dev/null and b/ui/public/logo.png differ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 89cb4ee..90e8082 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,399 +1,78 @@ -import { useMemo, useRef, useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { Alert, Button, Card, ConfigProvider, InputNumber, Table, Tag } from "antd"; +import { useState } from "react"; +import { App as AntApp, ConfigProvider, Tabs } from "antd"; import zhCN from "antd/locale/zh_CN"; +import { CalculatorPanel } from "./CalculatorPanel"; +import { HistoryTab } from "./HistoryTab"; +import type { SampleInput } from "./types"; -type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument"; - -type CalibrationParams = { - factor: number; - expanded_uncertainty_percent: number; - coverage_factor: number; -}; - -type NuclideMeasurements = { - measured_values: number[]; - calibration: CalibrationParams; -}; - -type AcceptanceLimits = { - ira_limit: number; - ir_limit: number; -}; - -type SampleInput = { - ra: NuclideMeasurements; - th: NuclideMeasurements; - k: NuclideMeasurements; - limits: AcceptanceLimits; -}; - -type McmIndexStats = { - mean: number; - std_dev: number; - p2_5: number; - p97_5: number; - standard_value: number; - pass_probability: number; - fail_probability: number; -}; - -type McmResult = { - iterations: number; - ira: McmIndexStats; - ir: McmIndexStats; - overall_pass_probability: number; - overall_fail_probability: number; -}; - -type NuclideResult = { - mean_measured: number; - mean_calibrated: number; - type_a_uncertainty: number; - type_b_relative: number; - type_b_uncertainty: number; - sensitivity_coefficient: number; - combined_uncertainty: number; -}; - -type IndexResult = { - value: number; - standard_uncertainty: number; - relative_uncertainty_percent: number; -}; - -type CalculationResult = { - measurement_count: number; - ra: NuclideResult; - th: NuclideResult; - k: NuclideResult; - ira: IndexResult; - ir: IndexResult; - conclusion: Conclusion; - mcm: McmResult; -}; - -type MeasurementRow = { - key: number; - ra: number | null; - th: number | null; - k: number | null; -}; - -type ResultRow = { name: string } & NuclideResult; - -type McmRow = { name: string } & McmIndexStats; - -type CalibrationRow = { key: string; name: string } & CalibrationParams; - -type FocusableInput = { - focus: () => void; -}; - -const defaultCalibration = { - ra: { factor: 0.916, expanded_uncertainty_percent: 6.3, coverage_factor: 2 }, - th: { factor: 0.884, expanded_uncertainty_percent: 6.9, coverage_factor: 2 }, - k: { factor: 0.961, expanded_uncertainty_percent: 6.7, coverage_factor: 2 } -}; - -const defaultLimits: AcceptanceLimits = { ira_limit: 1.0, ir_limit: 1.0 }; - -const initialRows: MeasurementRow[] = [ - { key: 1, ra: 100, th: 110, k: 560 }, - { key: 2, ra: 102, th: 111, k: 565 }, - { key: 3, ra: 98, th: 109, k: 555 }, - { key: 4, ra: 101, th: 110, k: 562 }, - { key: 5, ra: 99, th: 112, k: 558 }, - { key: 6, ra: 100, th: 108, k: 561 } -]; - -const conclusionText: Record = { - Ok: "OK", - IncreaseMeasurementsToSix: "请增加试验次数至 6 次", - RecalibrateInstrument: "校准仪器后重新测量" -}; - -function formatNumber(value: number, digits = 4) { - if (!Number.isFinite(value)) return "-"; - return value.toFixed(digits); -} - -function formatPercent(value: number, digits = 2) { - if (!Number.isFinite(value)) return "-"; - return (value * 100).toFixed(digits) + "%"; -} - -function App() { - const [rows, setRows] = useState(initialRows); - const [limits, setLimits] = useState(defaultLimits); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const firstCellRefs = useRef>({}); - - const dataSource = useMemo(() => rows, [rows]); - - const updateRow = (key: number, field: keyof Omit, value: number | null) => { - setRows((current) => - current.map((row) => (row.key === key ? { ...row, [field]: value } : row)) - ); - }; - - const addRow = () => { - const key = Date.now(); - setRows((current) => [...current, { key, ra: null, th: null, k: null }]); - window.setTimeout(() => firstCellRefs.current[key]?.focus(), 0); - }; - - const removeRow = (key: number) => { - setRows((current) => current.filter((row) => row.key !== key)); - }; - - const buildInput = (): SampleInput => { - const toValues = (field: keyof Omit) => - rows.map((row) => row[field]).filter((value): value is number => typeof value === "number"); - - return { - ra: { measured_values: toValues("ra"), calibration: defaultCalibration.ra }, - th: { measured_values: toValues("th"), calibration: defaultCalibration.th }, - k: { measured_values: toValues("k"), calibration: defaultCalibration.k }, - limits - }; - }; - - const calculate = async () => { - setLoading(true); - setError(null); - try { - const response = await invoke("calculate", { input: buildInput() }); - setResult(response); - } catch (err) { - setResult(null); - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; - +function Brand() { return ( - -
-
-
- -
- - pagination={false} - dataSource={dataSource} - rowKey="key" - size="small" - columns={[ - { - title: "序号", - key: "index", - width: 56, - align: "center", - render: (_, _row, index) => index + 1 - }, - { - title: "Ra-226", - dataIndex: "ra", - render: (_, row) => ( - { - firstCellRefs.current[row.key] = instance; - }} - value={row.ra} - min={0} - onChange={(value) => updateRow(row.key, "ra", value)} - /> - ) - }, - { - title: "Th-232", - dataIndex: "th", - render: (_, row) => ( - updateRow(row.key, "th", value)} /> - ) - }, - { - title: "K-40", - dataIndex: "k", - render: (_, row) => ( - updateRow(row.key, "k", value)} /> - ) - }, - { - title: "", - key: "action", - width: 88, - render: (_, row) => ( - - ) - } - ]} - /> -
-
- - -
-
- - - - className="calibration-table" - pagination={false} - size="small" - rowKey="key" - dataSource={[ - { key: "ra", name: "Ra", ...defaultCalibration.ra }, - { key: "th", name: "Th", ...defaultCalibration.th }, - { key: "k", name: "K", ...defaultCalibration.k } - ]} - columns={[ - { title: "核素", dataIndex: "name", align: "center" }, - { title: "a", dataIndex: "factor", align: "center" }, - { - title: "U", - dataIndex: "expanded_uncertainty_percent", - align: "center", - render: (value: number) => `${value}%` - }, - { title: "k", dataIndex: "coverage_factor", align: "center" } - ]} - /> -
-
- IRa 标准值 - setLimits((current) => ({ ...current, ira_limit: value ?? 0 }))} - /> -
-
- Ir 标准值 - setLimits((current) => ({ ...current, ir_limit: value ?? 0 }))} - /> -
-
-
-
- - {error ? : null} - -
- {result ? ( -
- -
- - -
- 判定 - - {conclusionText[result.conclusion]} - -
-
- - pagination={false} - rowKey="name" - size="small" - dataSource={[ - { name: "Ra-226", ...result.ra }, - { name: "Th-232", ...result.th }, - { name: "K-40", ...result.k } - ]} - columns={[ - { title: "核素", dataIndex: "name" }, - { title: "均值", dataIndex: "mean_measured", render: (value: number) => formatNumber(value) }, - { title: "校准活度", dataIndex: "mean_calibrated", render: (value: number) => formatNumber(value) }, - { title: "A 类", dataIndex: "type_a_uncertainty", render: (value: number) => formatNumber(value) }, - { title: "B 类相对", dataIndex: "type_b_relative", render: (value) => formatNumber(value * 100, 3) + "%" }, - { title: "合成", dataIndex: "combined_uncertainty", render: (value: number) => formatNumber(value) } - ]} - /> -
- - -
-
- 综合合格概率 - {formatPercent(result.mcm.overall_pass_probability)} - IRa 与 Ir 同时合格 -
-
- 综合不合格概率 - {formatPercent(result.mcm.overall_fail_probability)} - 任一指数超标 -
-
- 仿真判定 - = 0.95 ? "success" : "warning"}> - {result.mcm.overall_pass_probability >= 0.95 ? "合格" : "不合格风险"} - -
-
- - pagination={false} - rowKey="name" - size="small" - dataSource={[ - { name: "IRa", ...result.mcm.ira }, - { name: "Ir", ...result.mcm.ir } - ]} - columns={[ - { title: "指数", dataIndex: "name" }, - { title: "平均值", dataIndex: "mean", render: (value: number) => formatNumber(value) }, - { title: "标准偏差", dataIndex: "std_dev", render: (value: number) => formatNumber(value) }, - { title: "P2.5", dataIndex: "p2_5", render: (value: number) => formatNumber(value) }, - { title: "P97.5", dataIndex: "p97_5", render: (value: number) => formatNumber(value) }, - { title: "标准值", dataIndex: "standard_value", render: (value: number) => formatNumber(value, 2) }, - { - title: "合格概率", - dataIndex: "pass_probability", - render: (value: number) => ( - = 0.95 ? "success" : "warning"}>{formatPercent(value)} - ) - } - ]} - /> -
-
- ) : ( -
请计算后查看结果
- )} -
-
-
-
- ); -} - -function ResultTile(props: { title: string; value: number; uncertainty: number }) { - return ( -
- {props.title} - {formatNumber(props.value)} - 相对不确定度 {formatNumber(props.uncertainty, 2)}% +
+ + 建筑材料放射性判定分析
); } +function App() { + const [activeTab, setActiveTab] = useState("calc"); + const [reloadSignal, setReloadSignal] = useState(0); + // 复算:带入历史 input 并强制重挂 CalculatorPanel(key 递增)。 + const [loadedInput, setLoadedInput] = useState(undefined); + const [calcKey, setCalcKey] = useState(0); + + const recompute = (input: SampleInput) => { + setLoadedInput(input); + setCalcKey((k) => k + 1); + setActiveTab("calc"); + }; + + return ( + + +
+ }} + items={[ + { + key: "calc", + label: "计算", + children: ( + setReloadSignal((s) => s + 1)} + /> + ) + }, + { + key: "history", + label: "历史记录", + children: ( + + ) + } + ]} + /> +
+
+
+ ); +} + export { App }; diff --git a/ui/src/CalculatorPanel.tsx b/ui/src/CalculatorPanel.tsx new file mode 100644 index 0000000..6050e43 --- /dev/null +++ b/ui/src/CalculatorPanel.tsx @@ -0,0 +1,416 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Alert, App, Button, Card, DatePicker, Input, InputNumber, Select, Table, Tag } from "antd"; +import dayjs, { type Dayjs } from "dayjs"; +import { ReportModal } from "./ReportView"; +import { + conclusionText, + defaultCalibration, + formatNumber, + formatPercent, + materialOptions, + materialTiers, + validityText, + verdictDisplay, + type CalculationResult, + type LimitTier, + type MaterialType, + type SampleInput +} from "./types"; + +type MeasurementRow = { + key: number; + ra: number | null; + th: number | null; + k: number | null; +}; + +type CalibrationRow = { key: string; name: string } & (typeof defaultCalibration)["ra"]; + +type FocusableInput = { focus: () => void }; + +type Props = { + initialInput?: SampleInput; + onSaved: () => void; +}; + +function rowsFromInput(input?: SampleInput): MeasurementRow[] { + if (!input) return [{ key: 1, ra: 100, th: 110, k: 560 }]; + const n = input.ra.measured_values.length; + return Array.from({ length: n }, (_, i) => ({ + key: i + 1, + ra: input.ra.measured_values[i] ?? null, + th: input.th.measured_values[i] ?? null, + k: input.k.measured_values[i] ?? null + })); +} + +function CalculatorPanel({ initialInput, onSaved }: Props) { + const { message } = App.useApp(); + const [rows, setRows] = useState(() => rowsFromInput(initialInput)); + const [materialType, setMaterialType] = useState( + initialInput?.material_type ?? "BuildingMainBody" + ); + const [sampleId, setSampleId] = useState(initialInput?.sample_id ?? ""); + const [calcDate, setCalcDate] = useState( + initialInput?.calculation_date ? dayjs(initialInput.calculation_date) : dayjs() + ); + const [result, setResult] = useState(null); + const [lastInput, setLastInput] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + const firstCellRefs = useRef>({}); + + const dataSource = useMemo(() => rows, [rows]); + + const updateRow = (key: number, field: keyof Omit, value: number | null) => { + setRows((current) => current.map((row) => (row.key === key ? { ...row, [field]: value } : row))); + }; + + const addRow = () => { + const key = Date.now(); + setRows((current) => [...current, { key, ra: null, th: null, k: null }]); + window.setTimeout(() => firstCellRefs.current[key]?.focus(), 0); + }; + + const removeRow = (key: number) => { + setRows((current) => current.filter((row) => row.key !== key)); + }; + + const buildInput = (): SampleInput => { + const toValues = (field: keyof Omit) => + rows.map((row) => row[field]).filter((value): value is number => typeof value === "number"); + + return { + ra: { measured_values: toValues("ra"), calibration: defaultCalibration.ra }, + th: { measured_values: toValues("th"), calibration: defaultCalibration.th }, + k: { measured_values: toValues("k"), calibration: defaultCalibration.k }, + material_type: materialType, + sample_id: sampleId.trim() ? sampleId.trim() : null, + calculation_date: calcDate ? calcDate.format("YYYY-MM-DD") : null + }; + }; + + const calculate = async () => { + setLoading(true); + setError(null); + const input = buildInput(); + try { + const response = await invoke("calculate", { input }); + setResult(response); + setLastInput(input); + } catch (err) { + setResult(null); + setLastInput(null); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + // 复算:带入历史记录后自动计算一次。 + useEffect(() => { + if (initialInput) void calculate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const save = async () => { + if (!result || !lastInput) return; + setSaving(true); + try { + await invoke("save_record", { + args: { input: lastInput, result, created_at: new Date().toISOString() } + }); + message.success("已保存到历史"); + onSaved(); + } catch (err) { + message.error(`保存失败:${String(err)}`); + } finally { + setSaving(false); + } + }; + + const tiers = materialTiers[materialType]; + + return ( +
+
+ +
+ + pagination={false} + dataSource={dataSource} + rowKey="key" + size="small" + columns={[ + { title: "序号", key: "index", width: 56, align: "center", render: (_, _row, index) => index + 1 }, + { + title: "Ra-226", + dataIndex: "ra", + render: (_, row) => ( + { + firstCellRefs.current[row.key] = instance; + }} + value={row.ra} + min={0} + onChange={(value) => updateRow(row.key, "ra", value)} + /> + ) + }, + { + title: "Th-232", + dataIndex: "th", + render: (_, row) => ( + updateRow(row.key, "th", value)} /> + ) + }, + { + title: "K-40", + dataIndex: "k", + render: (_, row) => ( + updateRow(row.key, "k", value)} /> + ) + }, + { + title: "", + key: "action", + width: 88, + render: (_, row) => ( + + ) + } + ]} + /> +
+
+ + + + +
+
+ + +
+
+ 样品编号 + setSampleId(event.target.value)} /> +
+
+ 计算日期 + +
+
+ 材料类型 + + value={materialType} + options={materialOptions} + onChange={setMaterialType} + style={{ width: "100%" }} + /> +
+
+ +
+ + className="calibration-table" + pagination={false} + size="small" + rowKey="key" + dataSource={[ + { key: "ra", name: "Ra", ...defaultCalibration.ra }, + { key: "th", name: "Th", ...defaultCalibration.th }, + { key: "k", name: "K", ...defaultCalibration.k } + ]} + columns={[ + { title: "核素", dataIndex: "name", align: "center" }, + { title: "a", dataIndex: "factor", align: "center" }, + { + title: "U", + dataIndex: "expanded_uncertainty_percent", + align: "center", + render: (value: number) => `${value}%` + }, + { title: "k", dataIndex: "coverage_factor", align: "center" } + ]} + /> + + + className="limit-table" + pagination={false} + size="small" + rowKey="label" + dataSource={tiers} + columns={[ + { title: "级别", dataIndex: "label", align: "center" }, + { title: "IRa 限", dataIndex: "ira", align: "center", render: (value: number | null) => value ?? "—" }, + { title: "Ir 限", dataIndex: "ir", align: "center", render: (value: number | null) => value ?? "—" } + ]} + /> +
+
+
+ + {error ? : null} + +
+ {result && lastInput ? ( + <> + + {sampleId.trim() || "未编号"} · {calcDate ? calcDate.format("YYYY-MM-DD") : "无日期"} ·{" "} + {result.measurement_count} 次测量 + + } + > +
+
+ 有效性 + + {validityText[result.analysis.validity].text} + + 总比活度 {formatNumber(result.analysis.total_calibrated_activity, 1)} Bq/kg(阈值 37) +
+
+ 最终判定 + + {verdictDisplay(result.analysis.verdict).text} + + {result.analysis.verdict === "NeedMoreMeasurements" ? 真值区间跨越极限值 : null} +
+
+ IRa 真值区间 + {formatNumber(result.ira.value)} + + [{formatNumber(result.ira.p2_5)}, {formatNumber(result.ira.p97_5)}] · k=2{" "} + {formatNumber(result.ira.relative_expanded_uncertainty_percent, 2)}% + +
+
+ Ir 真值区间 + {formatNumber(result.ir.value)} + + [{formatNumber(result.ir.p2_5)}, {formatNumber(result.ir.p97_5)}] · k=2{" "} + {formatNumber(result.ir.relative_expanded_uncertainty_percent, 2)}% + +
+
+
+ +
+ +
+ + +
+ 不确定度判定 + + {conclusionText[result.conclusion]} + +
+
+ + pagination={false} + rowKey="name" + size="small" + dataSource={[ + { name: "Ra-226", ...result.ra }, + { name: "Th-232", ...result.th }, + { name: "K-40", ...result.k } + ]} + columns={[ + { title: "核素", dataIndex: "name" }, + { title: "均值", dataIndex: "mean_measured", render: (value: number) => formatNumber(value) }, + { title: "校准活度", dataIndex: "mean_calibrated", render: (value: number) => formatNumber(value) }, + { title: "A 类", dataIndex: "type_a_uncertainty", render: (value: number) => formatNumber(value) }, + { title: "B 类相对", dataIndex: "type_b_relative", render: (value) => formatNumber(value * 100, 3) + "%" }, + { title: "合成", dataIndex: "combined_uncertainty", render: (value: number) => formatNumber(value) } + ]} + /> +
+ + +
+
+ 综合合格概率 + {formatPercent(result.mcm.overall_pass_probability)} + IRa 与 Ir 同时合格 +
+
+ 不符合概率 + {formatPercent(result.mcm.overall_fail_probability)} + 95% 置信概率下 +
+
+ 仿真判定 + + {result.mcm.overall_fail_probability < 0.05 ? "合格(不符合概率<5%)" : "不合格风险"} + +
+
+ + pagination={false} + rowKey="name" + size="small" + dataSource={[ + { name: "IRa", ...result.mcm.ira }, + { name: "Ir", ...result.mcm.ir } + ]} + columns={[ + { title: "指数", dataIndex: "name" }, + { title: "平均值", dataIndex: "mean", render: (value: number) => formatNumber(value) }, + { title: "标准偏差", dataIndex: "std_dev", render: (value: number) => formatNumber(value) }, + { title: "P2.5", dataIndex: "p2_5", render: (value: number) => formatNumber(value) }, + { title: "P97.5", dataIndex: "p97_5", render: (value: number) => formatNumber(value) }, + { title: "标准值", dataIndex: "standard_value", render: (value: number) => formatNumber(value, 2) }, + { + title: "合格概率", + dataIndex: "pass_probability", + render: (value: number) => = 0.95 ? "success" : "warning"}>{formatPercent(value)} + } + ]} + /> +
+
+ + ) : ( +
请计算后查看结果
+ )} +
+ + setReportOpen(false)} + detail={result && lastInput ? { input: lastInput, result } : null} + /> +
+ ); +} + +function ResultTile(props: { title: string; value: number; uncertainty: number }) { + return ( +
+ {props.title} + {formatNumber(props.value)} + 相对不确定度 {formatNumber(props.uncertainty, 2)}% +
+ ); +} + +export { CalculatorPanel }; diff --git a/ui/src/HistoryTab.tsx b/ui/src/HistoryTab.tsx new file mode 100644 index 0000000..7dc0008 --- /dev/null +++ b/ui/src/HistoryTab.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { App, Button, DatePicker, Input, Popconfirm, Select, Space, Table, Tag } from "antd"; +import dayjs, { type Dayjs } from "dayjs"; +import { ReportModal } from "./ReportView"; +import { + formatNumber, + materialOptions, + materialText, + verdictDisplay, + verdictKindOptions, + type MaterialType, + type RecordDetail, + type RecordFilter, + type RecordSummary, + type SampleInput +} from "./types"; + +const { RangePicker } = DatePicker; + +type Props = { + active: boolean; + reloadSignal: number; + onRecompute: (input: SampleInput) => void; +}; + +function HistoryTab({ active, reloadSignal, onRecompute }: Props) { + const { message } = App.useApp(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [sampleId, setSampleId] = useState(""); + const [materialType, setMaterialType] = useState(null); + const [verdictKind, setVerdictKind] = useState(null); + const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null); + const [report, setReport] = useState(null); + const [reportOpen, setReportOpen] = useState(false); + + const fetchList = useCallback(async () => { + setLoading(true); + try { + const filter: RecordFilter = { + sample_id: sampleId.trim() || null, + material_type: materialType, + date_from: range?.[0] ? range[0]!.format("YYYY-MM-DD") : null, + date_to: range?.[1] ? range[1]!.format("YYYY-MM-DD") : null, + verdict_kind: verdictKind + }; + const result = await invoke("list_records", { filter }); + setRows(result); + } catch (err) { + message.error(`加载历史失败:${String(err)}`); + } finally { + setLoading(false); + } + }, [sampleId, materialType, verdictKind, range]); + + useEffect(() => { + if (active) void fetchList(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, reloadSignal]); + + const openReport = async (id: number) => { + try { + const detail = await invoke("get_record", { id }); + setReport(detail); + setReportOpen(true); + } catch (err) { + message.error(`读取记录失败:${String(err)}`); + } + }; + + const recompute = async (id: number) => { + try { + const detail = await invoke("get_record", { id }); + onRecompute(detail.input); + } catch (err) { + message.error(`复算失败:${String(err)}`); + } + }; + + const exportExcel = async (id: number) => { + try { + const path = await invoke("export_excel", { id }); + if (path) message.success(`已导出 Excel:${path}`); + else message.info("已取消导出"); + } catch (err) { + message.error(`导出失败:${String(err)}`); + } + }; + + const remove = async (id: number) => { + try { + await invoke("delete_record", { id }); + message.success("已删除"); + void fetchList(); + } catch (err) { + message.error(`删除失败:${String(err)}`); + } + }; + + return ( +
+ + setSampleId(e.target.value)} + /> + + allowClear + placeholder="材料类型" + style={{ width: 180 }} + options={materialOptions} + value={materialType ?? undefined} + onChange={(v) => setMaterialType(v ?? null)} + /> +