Initial commit
This commit is contained in:
commit
5cae20cd58
|
|
@ -0,0 +1,9 @@
|
||||||
|
target/
|
||||||
|
target-*/
|
||||||
|
node_modules/
|
||||||
|
ui/dist/
|
||||||
|
dist/
|
||||||
|
src-tauri/gen/
|
||||||
|
*.log
|
||||||
|
.claude/settings.local.json
|
||||||
|
temp/
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ceramic-radioactivity"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "ceramic-radioactivity"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "ceramic_radioactivity"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 设置代理环境变量
|
||||||
|
$env:HTTP_PROXY = "http://127.0.0.1:7897"
|
||||||
|
$env:HTTPS_PROXY = "http://127.0.0.1:7897"
|
||||||
|
|
||||||
|
# 调用 claude 命令,传递所有参数
|
||||||
|
& "claude" @args
|
||||||
|
|
||||||
|
# 检查上一条命令的退出代码
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "按任意键继续..." -ForegroundColor Yellow
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 设置代理环境变量
|
||||||
|
$env:HTTP_PROXY = "http://127.0.0.1:7897"
|
||||||
|
$env:HTTPS_PROXY = "http://127.0.0.1:7897"
|
||||||
|
|
||||||
|
# 调用 codex 命令,传递所有参数
|
||||||
|
& "codex" @args
|
||||||
|
|
||||||
|
# 检查上一条命令的退出代码
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "按任意键继续..." -ForegroundColor Yellow
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Rust Tauri MVP Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the first usable Rust + Tauri MVP for manual measurement entry and formula-based judgment.
|
||||||
|
|
||||||
|
**Architecture:** The Rust calculator is a pure library with explicit domain types and tests. Tauri commands and React UI are added after calculator behavior is verified.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, Cargo tests, Tauri, React, TypeScript, Ant Design, SQLite later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Calculator Crate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `Cargo.toml`
|
||||||
|
- Create: `src/lib.rs`
|
||||||
|
- Create: `src/domain.rs`
|
||||||
|
- Create: `src/calculator.rs`
|
||||||
|
- Create: `tests/calculator_tests.rs`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write failing Rust tests**
|
||||||
|
|
||||||
|
Write tests that call the desired public API before implementation.
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cargo test`
|
||||||
|
|
||||||
|
Expected: fail because the calculator modules and functions are not implemented yet.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement domain and calculator**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- `NuclideMeasurements`
|
||||||
|
- `CalibrationParams`
|
||||||
|
- `SampleInput`
|
||||||
|
- `CalculationResult`
|
||||||
|
- `Conclusion`
|
||||||
|
- `calculate_sample`
|
||||||
|
|
||||||
|
- [x] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `cargo test`
|
||||||
|
|
||||||
|
Expected: all calculator tests pass.
|
||||||
|
|
||||||
|
### Task 2: Tauri Shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create/modify Tauri scaffold files under the repository root.
|
||||||
|
|
||||||
|
- [x] **Step 1: Add Tauri + React scaffold**
|
||||||
|
|
||||||
|
Use a Vite React TypeScript frontend and Tauri Rust backend.
|
||||||
|
|
||||||
|
- [x] **Step 2: Add calculate command**
|
||||||
|
|
||||||
|
Expose a command that accepts `SampleInput` JSON and returns `CalculationResult` JSON.
|
||||||
|
|
||||||
|
- [x] **Step 3: Build check**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
Expected: frontend builds.
|
||||||
|
|
||||||
|
Run: `cargo check`
|
||||||
|
|
||||||
|
Expected: Rust side compiles.
|
||||||
|
|
||||||
|
### Task 3: MVP UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify React app files after scaffold exists.
|
||||||
|
|
||||||
|
- [x] **Step 1: Build input form**
|
||||||
|
|
||||||
|
Add manual entry fields for Ra, Th, and K repeated values.
|
||||||
|
|
||||||
|
- [x] **Step 2: Call Rust calculator**
|
||||||
|
|
||||||
|
Invoke the Tauri command and display returned values.
|
||||||
|
|
||||||
|
- [x] **Step 3: Show conclusion**
|
||||||
|
|
||||||
|
Display `OK`, `请增加试验次数至 6 次`, or `校准仪器后重新测量`.
|
||||||
|
|
||||||
|
- [x] **Step 4: Build verification**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
|
||||||
|
Expected: build exits with code 0.
|
||||||
|
|
||||||
|
### Task 4: Next Stage Backlog
|
||||||
|
|
||||||
|
After the MVP is verified:
|
||||||
|
|
||||||
|
- Add SQLite history.
|
||||||
|
- Add Excel import/export.
|
||||||
|
- Add PDF or HTML report export.
|
||||||
|
- Add Windows packaging.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- The plan implements the approved MVP only.
|
||||||
|
- The formula engine is isolated and testable before GUI work.
|
||||||
|
- No first-stage requirement depends on network services or external databases.
|
||||||
|
- History and report features are explicitly deferred to keep the first version small.
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Rust Tauri MVP Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a Windows desktop MVP for the building-material radioactivity judgment system. The first version covers manual entry of repeated Ra-226, Th-232, and K-40 measurements, Rust-side uncertainty calculation, and display of IRa, Ir, relative uncertainty, and the acceptance conclusion.
|
||||||
|
|
||||||
|
## Source Documents
|
||||||
|
|
||||||
|
- `temp/项目报价单.md`: original Rust + Tauri quotation scope.
|
||||||
|
- `temp/项目报价单_B方案.md`: C# alternative quotation.
|
||||||
|
- `temp/项目报价单_C方案.md`: Python alternative quotation.
|
||||||
|
- `temp/不确定度公式.pdf`: formula reference for calculation and judgment.
|
||||||
|
|
||||||
|
## Technology Choice
|
||||||
|
|
||||||
|
Use Rust + Tauri + React + TypeScript. Rust owns the formula engine and later storage/reporting commands. React owns data entry and presentation. Ant Design is the preferred UI library because this app is form-heavy and table-heavy.
|
||||||
|
|
||||||
|
## MVP Scope
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
- Manual entry of repeated measured values for Ra-226, Th-232, and K-40.
|
||||||
|
- Configurable calibration parameters with defaults from the PDF table 3:
|
||||||
|
- Ra: `a = 0.916`, `U = 6.3%`, `k = 2`
|
||||||
|
- Th: `a = 0.884`, `U = 6.9%`, `k = 2`
|
||||||
|
- K: `a = 0.961`, `U = 6.7%`, `k = 2`
|
||||||
|
- A-type uncertainty:
|
||||||
|
- `n >= 6`: sample standard deviation divided by `sqrt(n)`.
|
||||||
|
- `2 <= n < 6`: range method using table 2 coefficients.
|
||||||
|
- B-type uncertainty:
|
||||||
|
- `urB = U / k`
|
||||||
|
- `uB = a * urB`
|
||||||
|
- Calibrated activity values:
|
||||||
|
- `C = measured * calibration_factor`
|
||||||
|
- Indices:
|
||||||
|
- `IRa = C_Ra / 200`
|
||||||
|
- `Ir = C_Ra / 370 + C_Th / 260 + C_K / 4200`
|
||||||
|
- Combined nuclide uncertainty and index uncertainty.
|
||||||
|
- Relative uncertainty and three-state conclusion:
|
||||||
|
- both relative uncertainties <= 20%: `OK`
|
||||||
|
- otherwise, if sample count is below 6: `请增加试验次数至 6 次`
|
||||||
|
- otherwise: `校准仪器后重新测量`
|
||||||
|
|
||||||
|
Excluded from first pass:
|
||||||
|
|
||||||
|
- SQLite history.
|
||||||
|
- Excel import/export.
|
||||||
|
- PDF report export.
|
||||||
|
- Installer packaging.
|
||||||
|
|
||||||
|
These are second-stage modules after the formula path is verified.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The repository root starts as a Rust library crate named `ceramic-radioactivity`. It exposes calculation data structures and a pure `calculate_sample` function. Tauri integration will call this function from a command, and the React UI will pass plain JSON data to the command.
|
||||||
|
|
||||||
|
Planned modules:
|
||||||
|
|
||||||
|
- `src/lib.rs`: public exports.
|
||||||
|
- `src/calculator.rs`: formula implementation.
|
||||||
|
- `src/domain.rs`: input, output, calibration parameters, and conclusion types.
|
||||||
|
- `tests/calculator_tests.rs`: formula behavior tests.
|
||||||
|
|
||||||
|
The front end will be added after Rust tests pass:
|
||||||
|
|
||||||
|
- `src-tauri`: Tauri application shell.
|
||||||
|
- `ui`: React + TypeScript + Ant Design UI.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The calculator returns validation errors for:
|
||||||
|
|
||||||
|
- fewer than 2 measurements for any nuclide;
|
||||||
|
- unequal measurement counts across nuclides;
|
||||||
|
- non-finite numeric values;
|
||||||
|
- missing range coefficient for the `2 <= n < 6` range method.
|
||||||
|
|
||||||
|
The UI displays validation failures without saving or exporting anything.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The Rust calculator is tested first. Tests cover:
|
||||||
|
|
||||||
|
- mean and calibrated activity calculation;
|
||||||
|
- A-type uncertainty for both standard-deviation and range methods;
|
||||||
|
- default B-type uncertainty;
|
||||||
|
- index calculation;
|
||||||
|
- conclusion switching between `OK`, `请增加试验次数至 6 次`, and `校准仪器后重新测量`.
|
||||||
|
|
||||||
|
The first implementation target is a deterministic unit test based on hand-computed small input values, then a sample-shaped test using the PDF table 1 data.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "ceramic-radioactivity-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --config ui/vite.config.ts",
|
||||||
|
"build": "vite build --config ui/vite.config.ts",
|
||||||
|
"preview": "vite preview --config ui/vite.config.ts",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.5.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"antd": "^5.25.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^6.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "ceramic-radioactivity-tauri"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ceramic-radioactivity = { path = ".." }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -0,0 +1,13 @@
|
||||||
|
use ceramic_radioactivity::{calculate_sample, CalculationResult, SampleInput};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn calculate(input: SampleInput) -> Result<CalculationResult, String> {
|
||||||
|
calculate_sample(input).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.invoke_handler(tauri::generate_handler![calculate])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("failed to run Tauri application");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "建筑材料放射性判定",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.tcjs.ceramic-radioactivity",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"frontendDist": "../ui/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "建筑材料放射性判定",
|
||||||
|
"width": 1180,
|
||||||
|
"height": 820,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 640
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
use crate::domain::{
|
||||||
|
CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements,
|
||||||
|
NuclideResult, SampleInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0;
|
||||||
|
|
||||||
|
pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, CalculationError> {
|
||||||
|
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 ira = calculate_ira(&ra);
|
||||||
|
let ir = calculate_ir(&ra, &th, &k);
|
||||||
|
let conclusion = if ira.relative_uncertainty_percent <= ACCEPTANCE_LIMIT_PERCENT
|
||||||
|
&& ir.relative_uncertainty_percent <= ACCEPTANCE_LIMIT_PERCENT
|
||||||
|
{
|
||||||
|
Conclusion::Ok
|
||||||
|
} else if n < 6 {
|
||||||
|
Conclusion::IncreaseMeasurementsToSix
|
||||||
|
} else {
|
||||||
|
Conclusion::RecalibrateInstrument
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CalculationResult {
|
||||||
|
measurement_count: n,
|
||||||
|
ra,
|
||||||
|
th,
|
||||||
|
k,
|
||||||
|
ira,
|
||||||
|
ir,
|
||||||
|
conclusion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_input(input: &SampleInput) -> Result<(), CalculationError> {
|
||||||
|
let counts = [
|
||||||
|
("Ra", input.ra.measured_values.len()),
|
||||||
|
("Th", input.th.measured_values.len()),
|
||||||
|
("K", input.k.measured_values.len()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (nuclide, count) in counts {
|
||||||
|
if count < 2 {
|
||||||
|
return Err(CalculationError::TooFewMeasurements { nuclide, count });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.ra.measured_values.len() != input.th.measured_values.len()
|
||||||
|
|| input.ra.measured_values.len() != input.k.measured_values.len()
|
||||||
|
{
|
||||||
|
return Err(CalculationError::MismatchedMeasurementCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_nuclide("Ra", &input.ra)?;
|
||||||
|
validate_nuclide("Th", &input.th)?;
|
||||||
|
validate_nuclide("K", &input.k)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nuclide(
|
||||||
|
nuclide: &'static str,
|
||||||
|
measurements: &NuclideMeasurements,
|
||||||
|
) -> Result<(), CalculationError> {
|
||||||
|
let calibration = measurements.calibration;
|
||||||
|
if !calibration.factor.is_finite()
|
||||||
|
|| !calibration.expanded_uncertainty_percent.is_finite()
|
||||||
|
|| !calibration.coverage_factor.is_finite()
|
||||||
|
|| calibration.coverage_factor == 0.0
|
||||||
|
{
|
||||||
|
return Err(CalculationError::InvalidCalibration { nuclide });
|
||||||
|
}
|
||||||
|
|
||||||
|
for value in &measurements.measured_values {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(CalculationError::NonFiniteValue {
|
||||||
|
field: "measured value",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_nuclide(
|
||||||
|
nuclide: &'static str,
|
||||||
|
measurements: &NuclideMeasurements,
|
||||||
|
) -> Result<NuclideResult, CalculationError> {
|
||||||
|
let n = measurements.measured_values.len();
|
||||||
|
let mean_measured = mean(&measurements.measured_values);
|
||||||
|
let mean_calibrated = mean_measured * measurements.calibration.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 sensitivity_coefficient = mean_measured;
|
||||||
|
let combined_uncertainty = (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,
|
||||||
|
type_a_uncertainty,
|
||||||
|
type_b_relative,
|
||||||
|
type_b_uncertainty,
|
||||||
|
sensitivity_coefficient,
|
||||||
|
combined_uncertainty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_ir(ra: &NuclideResult, th: &NuclideResult, k: &NuclideResult) -> IndexResult {
|
||||||
|
let value = ra.mean_calibrated / 370.0 + th.mean_calibrated / 260.0 + k.mean_calibrated / 4200.0;
|
||||||
|
let standard_uncertainty = ((ra.combined_uncertainty / 370.0).powi(2)
|
||||||
|
+ (th.combined_uncertainty / 260.0).powi(2)
|
||||||
|
+ (k.combined_uncertainty / 4200.0).powi(2))
|
||||||
|
.sqrt();
|
||||||
|
IndexResult {
|
||||||
|
value,
|
||||||
|
standard_uncertainty,
|
||||||
|
relative_uncertainty_percent: relative_percent(standard_uncertainty, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_a_uncertainty(values: &[f64]) -> Result<f64, CalculationError> {
|
||||||
|
let n = values.len();
|
||||||
|
if n >= 6 {
|
||||||
|
Ok(sample_standard_deviation(values) / (n as f64).sqrt())
|
||||||
|
} else {
|
||||||
|
let coefficient =
|
||||||
|
range_coefficient(n).ok_or(CalculationError::UnsupportedRangeMethodCount { count: n })?;
|
||||||
|
let (min, max) = min_max(values);
|
||||||
|
Ok((max - min) / (coefficient * (n as f64).sqrt()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_standard_deviation(values: &[f64]) -> f64 {
|
||||||
|
let avg = mean(values);
|
||||||
|
let sum_squared: f64 = values.iter().map(|value| (value - avg).powi(2)).sum();
|
||||||
|
(sum_squared / (values.len() as f64 - 1.0)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mean(values: &[f64]) -> f64 {
|
||||||
|
values.iter().sum::<f64>() / values.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn min_max(values: &[f64]) -> (f64, f64) {
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), value| {
|
||||||
|
(min.min(*value), max.max(*value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range_coefficient(n: usize) -> Option<f64> {
|
||||||
|
match n {
|
||||||
|
2 => Some(1.13),
|
||||||
|
3 => Some(1.69),
|
||||||
|
4 => Some(2.06),
|
||||||
|
5 => Some(2.33),
|
||||||
|
6 => Some(2.53),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_percent(uncertainty: f64, value: f64) -> f64 {
|
||||||
|
if value == 0.0 {
|
||||||
|
f64::INFINITY
|
||||||
|
} else {
|
||||||
|
uncertainty / value.abs() * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SampleInput {
|
||||||
|
pub ra: NuclideMeasurements,
|
||||||
|
pub th: NuclideMeasurements,
|
||||||
|
pub k: NuclideMeasurements,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NuclideMeasurements {
|
||||||
|
pub measured_values: Vec<f64>,
|
||||||
|
pub calibration: CalibrationParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CalibrationParams {
|
||||||
|
pub factor: f64,
|
||||||
|
pub expanded_uncertainty_percent: f64,
|
||||||
|
pub coverage_factor: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CalculationResult {
|
||||||
|
pub measurement_count: usize,
|
||||||
|
pub ra: NuclideResult,
|
||||||
|
pub th: NuclideResult,
|
||||||
|
pub k: NuclideResult,
|
||||||
|
pub ira: IndexResult,
|
||||||
|
pub ir: IndexResult,
|
||||||
|
pub conclusion: Conclusion,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NuclideResult {
|
||||||
|
pub mean_measured: f64,
|
||||||
|
pub mean_calibrated: f64,
|
||||||
|
pub type_a_uncertainty: f64,
|
||||||
|
pub type_b_relative: f64,
|
||||||
|
pub type_b_uncertainty: f64,
|
||||||
|
pub sensitivity_coefficient: f64,
|
||||||
|
pub combined_uncertainty: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct IndexResult {
|
||||||
|
pub value: f64,
|
||||||
|
pub standard_uncertainty: f64,
|
||||||
|
pub relative_uncertainty_percent: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Conclusion {
|
||||||
|
Ok,
|
||||||
|
IncreaseMeasurementsToSix,
|
||||||
|
RecalibrateInstrument,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum CalculationError {
|
||||||
|
TooFewMeasurements { nuclide: &'static str, count: usize },
|
||||||
|
MismatchedMeasurementCounts,
|
||||||
|
NonFiniteValue { field: &'static str },
|
||||||
|
UnsupportedRangeMethodCount { count: usize },
|
||||||
|
InvalidCalibration { nuclide: &'static str },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CalculationError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::TooFewMeasurements { nuclide, count } => {
|
||||||
|
write!(f, "{nuclide} requires at least 2 measurements, got {count}")
|
||||||
|
}
|
||||||
|
Self::MismatchedMeasurementCounts => {
|
||||||
|
write!(f, "Ra, Th, and K must have the same measurement count")
|
||||||
|
}
|
||||||
|
Self::NonFiniteValue { field } => write!(f, "{field} must be a finite number"),
|
||||||
|
Self::UnsupportedRangeMethodCount { count } => {
|
||||||
|
write!(f, "range method coefficient is not defined for n = {count}")
|
||||||
|
}
|
||||||
|
Self::InvalidCalibration { nuclide } => {
|
||||||
|
write!(f, "{nuclide} calibration parameters are invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for CalculationError {}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
mod calculator;
|
||||||
|
mod domain;
|
||||||
|
|
||||||
|
pub use calculator::calculate_sample;
|
||||||
|
pub use domain::{
|
||||||
|
CalculationError, CalculationResult, CalibrationParams, Conclusion, IndexResult,
|
||||||
|
NuclideMeasurements, NuclideResult, SampleInput,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
use ceramic_radioactivity::{
|
||||||
|
calculate_sample, CalibrationParams, Conclusion, NuclideMeasurements, SampleInput,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn calculates_indices_and_ok_conclusion_for_six_measurements() {
|
||||||
|
let result = calculate_sample(default_input()).expect("valid sample should calculate");
|
||||||
|
|
||||||
|
assert_close(result.ra.mean_measured, 100.0, 1e-9);
|
||||||
|
assert_close(result.ra.mean_calibrated, 91.6, 1e-9);
|
||||||
|
assert_close(result.th.mean_calibrated, 97.24, 1e-9);
|
||||||
|
assert_close(result.k.mean_calibrated, 538.320_166_666_666_6, 1e-9);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_for_more_measurements_when_uncertainty_is_high_and_n_is_below_six() {
|
||||||
|
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_eq!(result.measurement_count, 3);
|
||||||
|
assert_eq!(result.conclusion, Conclusion::IncreaseMeasurementsToSix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asks_for_recalibration_when_uncertainty_is_high_after_six_measurements() {
|
||||||
|
let mut input = default_input();
|
||||||
|
input.ra.measured_values = vec![10.0, 200.0, 400.0, 10.0, 200.0, 400.0];
|
||||||
|
input.th.measured_values = vec![10.0, 200.0, 400.0, 10.0, 200.0, 400.0];
|
||||||
|
input.k.measured_values = vec![10.0, 200.0, 400.0, 10.0, 200.0, 400.0];
|
||||||
|
|
||||||
|
let result = calculate_sample(input).expect("valid sample should calculate");
|
||||||
|
|
||||||
|
assert_eq!(result.measurement_count, 6);
|
||||||
|
assert_eq!(result.conclusion, Conclusion::RecalibrateInstrument);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_mismatched_measurement_counts() {
|
||||||
|
let mut input = default_input();
|
||||||
|
input.k.measured_values.pop();
|
||||||
|
|
||||||
|
let err = calculate_sample(input).expect_err("mismatched counts should fail");
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("same measurement count"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_close(actual: f64, expected: f64, tolerance: f64) {
|
||||||
|
assert!(
|
||||||
|
(actual - expected).abs() <= tolerance,
|
||||||
|
"actual {actual} expected {expected} tolerance {tolerance}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>建筑材料放射性判定</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfigProvider,
|
||||||
|
Descriptions,
|
||||||
|
Form,
|
||||||
|
InputNumber,
|
||||||
|
Table,
|
||||||
|
Tag
|
||||||
|
} from "antd";
|
||||||
|
import zhCN from "antd/locale/zh_CN";
|
||||||
|
|
||||||
|
type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument";
|
||||||
|
|
||||||
|
type CalibrationParams = {
|
||||||
|
factor: number;
|
||||||
|
expanded_uncertainty_percent: number;
|
||||||
|
coverage_factor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NuclideMeasurements = {
|
||||||
|
measured_values: number[];
|
||||||
|
calibration: CalibrationParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SampleInput = {
|
||||||
|
ra: NuclideMeasurements;
|
||||||
|
th: NuclideMeasurements;
|
||||||
|
k: NuclideMeasurements;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeasurementRow = {
|
||||||
|
key: number;
|
||||||
|
ra: number | null;
|
||||||
|
th: number | null;
|
||||||
|
k: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultRow = { name: string } & NuclideResult;
|
||||||
|
|
||||||
|
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 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<Conclusion, string> = {
|
||||||
|
Ok: "OK",
|
||||||
|
IncreaseMeasurementsToSix: "请增加试验次数至 6 次",
|
||||||
|
RecalibrateInstrument: "校准仪器后重新测量"
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatNumber(value: number, digits = 4) {
|
||||||
|
if (!Number.isFinite(value)) return "-";
|
||||||
|
return value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [rows, setRows] = useState<MeasurementRow[]>(initialRows);
|
||||||
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const firstCellRefs = useRef<Record<number, FocusableInput | null>>({});
|
||||||
|
|
||||||
|
const dataSource = useMemo(() => rows, [rows]);
|
||||||
|
|
||||||
|
const updateRow = (key: number, field: keyof Omit<MeasurementRow, "key">, 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<MeasurementRow, "key">) =>
|
||||||
|
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 }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await invoke<CalculationResult>("calculate", { input: buildInput() });
|
||||||
|
setResult(response);
|
||||||
|
} catch (err) {
|
||||||
|
setResult(null);
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<main className="app-shell">
|
||||||
|
<section className="workspace">
|
||||||
|
<div className="content-grid">
|
||||||
|
<Card
|
||||||
|
title="重复测量值"
|
||||||
|
className="panel measurements-panel"
|
||||||
|
>
|
||||||
|
<div className="measurement-table">
|
||||||
|
<Table<MeasurementRow>
|
||||||
|
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) => (
|
||||||
|
<InputNumber
|
||||||
|
ref={(instance) => {
|
||||||
|
firstCellRefs.current[row.key] = instance;
|
||||||
|
}}
|
||||||
|
value={row.ra}
|
||||||
|
min={0}
|
||||||
|
onChange={(value) => updateRow(row.key, "ra", value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Th-232",
|
||||||
|
dataIndex: "th",
|
||||||
|
render: (_, row) => (
|
||||||
|
<InputNumber value={row.th} min={0} onChange={(value) => updateRow(row.key, "th", value)} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "K-40",
|
||||||
|
dataIndex: "k",
|
||||||
|
render: (_, row) => (
|
||||||
|
<InputNumber value={row.k} min={0} onChange={(value) => updateRow(row.key, "k", value)} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
key: "action",
|
||||||
|
width: 88,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button danger size="small" disabled={rows.length <= 2} onClick={() => removeRow(row.key)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="measurement-actions">
|
||||||
|
<Button size="small" block onClick={addRow}>
|
||||||
|
添加次数
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" size="small" block loading={loading} onClick={calculate}>
|
||||||
|
计算
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="校准参数" className="panel compact-panel">
|
||||||
|
<Form layout="vertical">
|
||||||
|
{Object.entries(defaultCalibration).map(([key, calibration]) => (
|
||||||
|
<Descriptions key={key} bordered size="small" column={1} className="calibration-block">
|
||||||
|
<Descriptions.Item label={key.toUpperCase()}>{calibration.factor}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="U">{calibration.expanded_uncertainty_percent}%</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="k">{calibration.coverage_factor}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <Alert type="error" message={error} showIcon /> : null}
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<Card title="计算结果" className="panel">
|
||||||
|
<div className="result-grid">
|
||||||
|
<ResultTile title="IRa" value={result.ira.value} uncertainty={result.ira.relative_uncertainty_percent} />
|
||||||
|
<ResultTile title="Ir" value={result.ir.value} uncertainty={result.ir.relative_uncertainty_percent} />
|
||||||
|
<div className="result-tile conclusion-tile">
|
||||||
|
<span>判定</span>
|
||||||
|
<Tag color={result.conclusion === "Ok" ? "success" : "warning"}>
|
||||||
|
{conclusionText[result.conclusion]}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table<ResultRow>
|
||||||
|
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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultTile(props: { title: string; value: number; uncertainty: number }) {
|
||||||
|
return (
|
||||||
|
<div className="result-tile">
|
||||||
|
<span>{props.title}</span>
|
||||||
|
<strong>{formatNumber(props.value)}</strong>
|
||||||
|
<small>相对不确定度 {formatNumber(props.uncertainty, 2)}%</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { App };
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #202124;
|
||||||
|
background: #f4f6f8;
|
||||||
|
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 240px;
|
||||||
|
grid-auto-rows: 500px;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid > .panel {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel .ant-card-head {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel .ant-card-head-title {
|
||||||
|
padding: 9px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel .ant-card-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measurements-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measurements-panel .ant-card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measurement-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-panel .ant-card-body {
|
||||||
|
height: calc(100% - 38px);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calibration-block {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measurement-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile {
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile span {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile strong {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tile small {
|
||||||
|
color: #5f6368;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion-tile .ant-tag {
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-small .ant-table-thead > tr > th,
|
||||||
|
.ant-table-small .ant-table-tbody > tr > td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-number {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-number .ant-input-number-input {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-descriptions.ant-descriptions-small .ant-descriptions-item-label,
|
||||||
|
.ant-descriptions.ant-descriptions-small .ant-descriptions-item-content {
|
||||||
|
padding: 5px 8px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-descriptions.ant-descriptions-small .ant-descriptions-item-label {
|
||||||
|
width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid,
|
||||||
|
.result-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-auto-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid > .panel {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measurements-panel {
|
||||||
|
height: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: "ui",
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue