Initial commit

This commit is contained in:
caoqianming 2026-05-15 13:50:27 +08:00
commit 5cae20cd58
25 changed files with 8841 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
target/
target-*/
node_modules/
ui/dist/
dist/
src-tauri/gen/
*.log
.claude/settings.local.json
temp/

75
Cargo.lock generated Normal file
View File

@ -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"

11
Cargo.toml Normal file
View File

@ -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"] }

12
cl.ps1 Normal file
View File

@ -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")
}

12
col.ps1 Normal file
View File

@ -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")
}

View File

@ -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.

View File

@ -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.

2957
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -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"
}
}

4576
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
src-tauri/Cargo.toml Normal file
View File

@ -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 = [] }

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

13
src-tauri/src/main.rs Normal file
View File

@ -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");
}

31
src-tauri/tauri.conf.json Normal file
View File

@ -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": []
}
}

191
src/calculator.rs Normal file
View File

@ -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
}
}

91
src/domain.rs Normal file
View File

@ -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 {}

8
src/lib.rs Normal file
View File

@ -0,0 +1,8 @@
mod calculator;
mod domain;
pub use calculator::calculate_sample;
pub use domain::{
CalculationError, CalculationResult, CalibrationParams, Conclusion, IndexResult,
NuclideMeasurements, NuclideResult, SampleInput,
};

88
tests/calculator_tests.rs Normal file
View File

@ -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}"
);
}

12
ui/index.html Normal file
View File

@ -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>

287
ui/src/App.tsx Normal file
View File

@ -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 };

10
ui/src/main.tsx Normal file
View File

@ -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>
);

179
ui/src/styles.css Normal file
View File

@ -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;
}
}

21
ui/tsconfig.json Normal file
View File

@ -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": []
}

16
ui/vite.config.ts Normal file
View File

@ -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
}
});