Add Monte Carlo (MCM) acceptance evaluation

Add a 10000-iteration Monte Carlo simulation on top of the existing
GUM uncertainty path. For IRa and Ir it reports the simulated mean,
standard deviation, P2.5/P97.5 (95% coverage interval), and pass/fail
probability against configurable standard values (default IRa/Ir <= 1.0).

- src/mcm.rs: dependency-free SplitMix64 PRNG + Box-Muller normals,
  deterministic seed for reproducible results
- domain: AcceptanceLimits, McmResult, McmIndexStats; wire into
  SampleInput/CalculationResult
- UI: compact calibration table, standard-value inputs, side-by-side
  GUM/MCM result cards, and a placeholder before calculation
- run.md: run and packaging instructions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-08 15:13:05 +08:00
parent c1080c1b03
commit 0c06d51c4a
8 changed files with 611 additions and 75 deletions

105
run.md Normal file
View File

@ -0,0 +1,105 @@
# 运行与打包说明
建筑材料放射性判定系统Rust + Tauri + React + TypeScript
## 1. 环境要求
| 工具 | 版本 | 说明 |
| --- | --- | --- |
| Node.js | ≥ 18 | 前端构建Vite |
| npm | 随 Node 安装 | 依赖管理 |
| Rust | ≥ 1.78(已验证 1.93 | 计算引擎与 Tauri 后端,`rustup` 安装 |
| Tauri 前置依赖 | Windows: WebView2 RuntimeWin11 已内置) | 见 <https://tauri.app/start/prerequisites/> |
首次准备:
```powershell
npm install
```
> Rust 依赖在首次 `cargo`/`tauri` 命令时自动下载编译。
## 2. 开发运行
### 2.1 仅前端(浏览器预览,不含 Tauri 原生命令)
```powershell
npm run dev
```
打开 <http://localhost:1420>。注意:此模式下 `invoke("calculate")` 不可用,仅用于调样式。
### 2.2 完整桌面应用(推荐)
```powershell
npm run tauri:dev
```
该命令会自动启动 Vite 开发服务器并编译 Rust 后端,弹出桌面窗口,支持热重载。
## 3. 测试
计算引擎(含蒙特卡洛仿真)的单元/集成测试:
```powershell
cargo test -p ceramic-radioactivity
```
前端类型检查与生产构建:
```powershell
npm run build
```
## 4. 打包(生成 Windows 安装包)
1. 在 `src-tauri/tauri.conf.json` 中将 `bundle.active` 设为 `true`
```json
"bundle": {
"active": true,
"targets": ["nsis", "msi"],
"icon": ["icons/icon.ico"]
}
```
- `nsis` 生成 `.exe` 安装包,`msi` 生成 `.msi`(需安装 WiX Toolset可只保留 `nsis`)。
- `icon` 需指向有效图标;可用 `npx tauri icon path\to\logo.png` 生成整套图标。
2. 执行构建:
```powershell
npm run tauri:build
```
3. 产物输出目录:
```
src-tauri/target/release/ # 可执行文件 建筑材料放射性判定.exe
src-tauri/target/release/bundle/nsis/ # NSIS 安装包
src-tauri/target/release/bundle/msi/ # MSI 安装包(如启用)
```
> 首次 release 构建较慢(需编译全部 Rust 依赖),后续增量构建会显著加快。
## 5. 项目结构
```
src/ 计算引擎(库 crateceramic-radioactivity
├─ domain.rs 输入/输出/校准/判定/MCM 数据结构
├─ calculator.rs GUM 法不确定度计算与三态判定
└─ mcm.rs 蒙特卡洛法仿真10000 次,含 PRNG
tests/ 计算引擎测试
src-tauri/ Tauri 桌面外壳command: calculate
ui/ React + Ant Design 前端
run.md 本说明
```
## 6. 功能说明(计算与判定)
- **GUM 法**A 类(标准差 / 极差法、B 类不确定度合成,输出 IRa、Ir 及相对不确定度。
- `ur ≤ 20%``OK`;否则次数 < 6 `请增加试验次数至 6 次`否则 `校准仪器后重新测量`
- **蒙特卡洛法MCM**对各核素校准比活度按正态分布抽样10000 次仿真传播至 IRa、Ir
输出**平均值、标准偏差、P2.5、P97.5**95% 包含区间),并与**标准值**比较给出**合格 / 不合格概率**。
- 标准值IRa、Ir 限值)可在界面“校准参数 / 判定标准值”中修改,默认均为 `1.0`GB 6566 主体材料)。
- 仿真使用固定随机种子,结果可复现。

View File

@ -2,6 +2,7 @@ use crate::domain::{
CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements, CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements,
NuclideResult, SampleInput, NuclideResult, SampleInput,
}; };
use crate::mcm::run_monte_carlo;
const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0; const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0;
@ -25,6 +26,8 @@ pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, Calcula
Conclusion::RecalibrateInstrument Conclusion::RecalibrateInstrument
}; };
let mcm = run_monte_carlo(&ra, &th, &k, &input.limits);
Ok(CalculationResult { Ok(CalculationResult {
measurement_count: n, measurement_count: n,
ra, ra,
@ -33,6 +36,7 @@ pub fn calculate_sample(input: SampleInput) -> Result<CalculationResult, Calcula
ira, ira,
ir, ir,
conclusion, conclusion,
mcm,
}) })
} }

View File

@ -8,6 +8,25 @@ pub struct SampleInput {
pub ra: NuclideMeasurements, pub ra: NuclideMeasurements,
pub th: NuclideMeasurements, pub th: NuclideMeasurements,
pub k: NuclideMeasurements, pub k: NuclideMeasurements,
/// 合格判定标准值(限值)。前端可省略,默认 IRa ≤ 1.0、Ir ≤ 1.0GB 6566 主体材料)。
#[serde(default)]
pub limits: AcceptanceLimits,
}
/// 内照射指数 IRa 与外照射指数 Ir 的合格判定标准值(限值)。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct AcceptanceLimits {
pub ira_limit: f64,
pub ir_limit: f64,
}
impl Default for AcceptanceLimits {
fn default() -> Self {
Self {
ira_limit: 1.0,
ir_limit: 1.0,
}
}
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -32,6 +51,42 @@ pub struct CalculationResult {
pub ira: IndexResult, pub ira: IndexResult,
pub ir: IndexResult, pub ir: IndexResult,
pub conclusion: Conclusion, pub conclusion: Conclusion,
/// 蒙特卡洛法MCM仿真结果。
pub mcm: McmResult,
}
/// 蒙特卡洛法MCM整体仿真结果。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct McmResult {
/// 仿真次数(默认 10000
pub iterations: usize,
/// 内照射指数 IRa 的仿真统计。
pub ira: McmIndexStats,
/// 外照射指数 Ir 的仿真统计。
pub ir: McmIndexStats,
/// 综合合格概率:每次仿真中 IRa 与 Ir 同时不超过各自标准值的比例。
pub overall_pass_probability: f64,
/// 综合不合格概率 = 1 - overall_pass_probability。
pub overall_fail_probability: f64,
}
/// 单个指数的 MCM 仿真统计量。
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct McmIndexStats {
/// 仿真平均值。
pub mean: f64,
/// 仿真标准偏差。
pub std_dev: f64,
/// 2.5 百分位95% 包含区间下限)。
pub p2_5: f64,
/// 97.5 百分位95% 包含区间上限)。
pub p97_5: f64,
/// 用于比较的标准值(限值)。
pub standard_value: f64,
/// 合格概率:仿真值不超过标准值的比例。
pub pass_probability: f64,
/// 不合格概率 = 1 - pass_probability。
pub fail_probability: f64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]

View File

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

146
src/mcm.rs Normal file
View File

@ -0,0 +1,146 @@
//! 蒙特卡洛法MCM不确定度传播与合格概率评估。
//!
//! 按 JJF 1059.2 的思路,对内照射指数 IRa 与外照射指数 Ir 进行 10000 次仿真:
//! 每次仿真从各核素校准比活度的分布中抽样,经测量模型传播后得到 IRa、Ir 的样本,
//! 进而给出仿真平均值、标准偏差、95% 包含区间P2.5、P97.5),并与标准值比较给出合格概率。
//!
//! 每个核素的校准比活度按正态分布 `N(mean_calibrated, combined_uncertainty)` 抽样,
//! 其中均值与标准(合成)不确定度直接复用 GUM 法已算出的 `NuclideResult`
//! 因此 MCM 的均值/标准偏差与 GUM 结果保持一致,而包含区间与合格概率由仿真分布直接得到。
use crate::domain::{AcceptanceLimits, McmIndexStats, McmResult, NuclideResult};
/// 默认仿真次数。
pub const DEFAULT_ITERATIONS: usize = 10_000;
/// 固定随机种子,保证同一输入得到可复现的仿真结果(便于出具一致的报告与测试)。
const SEED: u64 = 0x853C_49E6_748F_EA9B;
/// 对给定的核素结果与标准值执行 MCM 仿真。
pub fn run_monte_carlo(
ra: &NuclideResult,
th: &NuclideResult,
k: &NuclideResult,
limits: &AcceptanceLimits,
) -> McmResult {
let iterations = DEFAULT_ITERATIONS;
let mut rng = SplitMix64::new(SEED);
let mut ira_samples = Vec::with_capacity(iterations);
let mut ir_samples = Vec::with_capacity(iterations);
let mut overall_pass = 0usize;
for _ in 0..iterations {
let c_ra = sample_normal(&mut rng, ra.mean_calibrated, ra.combined_uncertainty);
let c_th = sample_normal(&mut rng, th.mean_calibrated, th.combined_uncertainty);
let c_k = sample_normal(&mut rng, k.mean_calibrated, k.combined_uncertainty);
let ira = c_ra / 200.0;
let ir = c_ra / 370.0 + c_th / 260.0 + c_k / 4200.0;
if ira <= limits.ira_limit && ir <= limits.ir_limit {
overall_pass += 1;
}
ira_samples.push(ira);
ir_samples.push(ir);
}
let ira_stats = summarize(&mut ira_samples, limits.ira_limit);
let ir_stats = summarize(&mut ir_samples, limits.ir_limit);
let overall_pass_probability = overall_pass as f64 / iterations as f64;
McmResult {
iterations,
ira: ira_stats,
ir: ir_stats,
overall_pass_probability,
overall_fail_probability: 1.0 - overall_pass_probability,
}
}
/// 由样本计算统计量。会就地排序 `samples` 以取百分位。
fn summarize(samples: &mut [f64], standard_value: f64) -> McmIndexStats {
let n = samples.len();
let mean = samples.iter().sum::<f64>() / n as f64;
let variance = samples
.iter()
.map(|value| (value - mean).powi(2))
.sum::<f64>()
/ (n as f64 - 1.0);
let std_dev = variance.sqrt();
let pass = samples.iter().filter(|&&value| value <= standard_value).count();
let pass_probability = pass as f64 / n as f64;
samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p2_5 = percentile(samples, 0.025);
let p97_5 = percentile(samples, 0.975);
McmIndexStats {
mean,
std_dev,
p2_5,
p97_5,
standard_value,
pass_probability,
fail_probability: 1.0 - pass_probability,
}
}
/// 已排序样本的线性插值百分位p 取 0..=1
fn percentile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return f64::NAN;
}
if n == 1 {
return sorted[0];
}
let rank = p * (n as f64 - 1.0);
let lower = rank.floor() as usize;
let upper = rank.ceil() as usize;
let weight = rank - lower as f64;
sorted[lower] * (1.0 - weight) + sorted[upper] * weight
}
/// 由标准正态抽样得到 `N(mean, std_dev)` 样本std_dev 为 0 时退化为常数)。
fn sample_normal(rng: &mut SplitMix64, mean: f64, std_dev: f64) -> f64 {
if std_dev == 0.0 {
return mean;
}
mean + std_dev * rng.next_standard_normal()
}
/// SplitMix64 伪随机数发生器:无外部依赖、确定性、统计性质良好。
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// 返回 [0, 1) 区间内的均匀分布样本。
fn next_uniform(&mut self) -> f64 {
// 取高 53 位映射到 [0, 1),与 f64 尾数精度一致。
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Box-Muller 变换生成标准正态样本。
fn next_standard_normal(&mut self) -> f64 {
// 避免 ln(0):将 u1 下限钳到一个极小正数。
let u1 = self.next_uniform().max(f64::MIN_POSITIVE);
let u2 = self.next_uniform();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
}

View File

@ -1,5 +1,6 @@
use ceramic_radioactivity::{ use ceramic_radioactivity::{
calculate_sample, CalibrationParams, Conclusion, NuclideMeasurements, SampleInput, calculate_sample, AcceptanceLimits, CalibrationParams, Conclusion, NuclideMeasurements,
SampleInput,
}; };
fn default_input() -> SampleInput { fn default_input() -> SampleInput {
@ -28,6 +29,10 @@ fn default_input() -> SampleInput {
coverage_factor: 2.0, coverage_factor: 2.0,
}, },
}, },
limits: AcceptanceLimits {
ira_limit: 1.0,
ir_limit: 1.0,
},
} }
} }
@ -80,6 +85,62 @@ fn rejects_mismatched_measurement_counts() {
assert!(err.to_string().contains("same measurement count")); assert!(err.to_string().contains("same measurement count"));
} }
#[test]
fn monte_carlo_matches_analytical_mean_and_uncertainty() {
let result = calculate_sample(default_input()).expect("valid sample should calculate");
assert_eq!(result.mcm.iterations, 10_000);
// MCM 仿真均值/标准偏差应与 GUM 解析结果一致(仿真随机误差在 ~1% 量级)。
assert_close(result.mcm.ira.mean, result.ira.value, 5e-4);
assert_close(result.mcm.ir.mean, result.ir.value, 5e-4);
assert_close(
result.mcm.ira.std_dev,
result.ira.standard_uncertainty,
result.ira.standard_uncertainty * 0.05,
);
assert_close(
result.mcm.ir.std_dev,
result.ir.standard_uncertainty,
result.ir.standard_uncertainty * 0.05,
);
// 95% 包含区间应包住均值。
assert!(result.mcm.ira.p2_5 < result.mcm.ira.mean);
assert!(result.mcm.ira.mean < result.mcm.ira.p97_5);
// 默认样本的 IRa≈0.46、Ir≈0.75 都远低于标准值 1.0,合格概率应为 1。
assert_close(result.mcm.ira.pass_probability, 1.0, 1e-12);
assert_close(result.mcm.ir.pass_probability, 1.0, 1e-12);
assert_close(result.mcm.overall_pass_probability, 1.0, 1e-12);
assert_close(result.mcm.overall_fail_probability, 0.0, 1e-12);
}
#[test]
fn monte_carlo_is_deterministic_for_same_input() {
let first = calculate_sample(default_input()).expect("valid sample should calculate");
let second = calculate_sample(default_input()).expect("valid sample should calculate");
assert_eq!(first.mcm, second.mcm);
}
#[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");
assert_close(result.mcm.ira.pass_probability, 0.5, 0.03);
assert_close(
result.mcm.ira.fail_probability,
1.0 - result.mcm.ira.pass_probability,
1e-12,
);
}
fn assert_close(actual: f64, expected: f64, tolerance: f64) { fn assert_close(actual: f64, expected: f64, tolerance: f64) {
assert!( assert!(
(actual - expected).abs() <= tolerance, (actual - expected).abs() <= tolerance,

View File

@ -1,16 +1,6 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { import { Alert, Button, Card, ConfigProvider, InputNumber, Table, Tag } from "antd";
Alert,
Button,
Card,
ConfigProvider,
Descriptions,
Form,
InputNumber,
Table,
Tag
} from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument"; type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument";
@ -26,10 +16,34 @@ type NuclideMeasurements = {
calibration: CalibrationParams; calibration: CalibrationParams;
}; };
type AcceptanceLimits = {
ira_limit: number;
ir_limit: number;
};
type SampleInput = { type SampleInput = {
ra: NuclideMeasurements; ra: NuclideMeasurements;
th: NuclideMeasurements; th: NuclideMeasurements;
k: 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 = { type NuclideResult = {
@ -56,6 +70,7 @@ type CalculationResult = {
ira: IndexResult; ira: IndexResult;
ir: IndexResult; ir: IndexResult;
conclusion: Conclusion; conclusion: Conclusion;
mcm: McmResult;
}; };
type MeasurementRow = { type MeasurementRow = {
@ -67,6 +82,10 @@ type MeasurementRow = {
type ResultRow = { name: string } & NuclideResult; type ResultRow = { name: string } & NuclideResult;
type McmRow = { name: string } & McmIndexStats;
type CalibrationRow = { key: string; name: string } & CalibrationParams;
type FocusableInput = { type FocusableInput = {
focus: () => void; focus: () => void;
}; };
@ -77,6 +96,8 @@ const defaultCalibration = {
k: { factor: 0.961, expanded_uncertainty_percent: 6.7, 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[] = [ const initialRows: MeasurementRow[] = [
{ key: 1, ra: 100, th: 110, k: 560 }, { key: 1, ra: 100, th: 110, k: 560 },
{ key: 2, ra: 102, th: 111, k: 565 }, { key: 2, ra: 102, th: 111, k: 565 },
@ -97,8 +118,14 @@ function formatNumber(value: number, digits = 4) {
return value.toFixed(digits); return value.toFixed(digits);
} }
function formatPercent(value: number, digits = 2) {
if (!Number.isFinite(value)) return "-";
return (value * 100).toFixed(digits) + "%";
}
function App() { function App() {
const [rows, setRows] = useState<MeasurementRow[]>(initialRows); const [rows, setRows] = useState<MeasurementRow[]>(initialRows);
const [limits, setLimits] = useState<AcceptanceLimits>(defaultLimits);
const [result, setResult] = useState<CalculationResult | null>(null); const [result, setResult] = useState<CalculationResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -129,7 +156,8 @@ function App() {
return { return {
ra: { measured_values: toValues("ra"), calibration: defaultCalibration.ra }, ra: { measured_values: toValues("ra"), calibration: defaultCalibration.ra },
th: { measured_values: toValues("th"), calibration: defaultCalibration.th }, th: { measured_values: toValues("th"), calibration: defaultCalibration.th },
k: { measured_values: toValues("k"), calibration: defaultCalibration.k } k: { measured_values: toValues("k"), calibration: defaultCalibration.k },
limits
}; };
}; };
@ -221,53 +249,137 @@ function App() {
</div> </div>
</Card> </Card>
<Card title="校准参数" className="panel compact-panel"> <Card title="校准参数 / 判定标准值" className="panel compact-panel">
<Form layout="vertical"> <Table<CalibrationRow>
{Object.entries(defaultCalibration).map(([key, calibration]) => ( className="calibration-table"
<Descriptions key={key} bordered size="small" column={1} className="calibration-block"> pagination={false}
<Descriptions.Item label={key.toUpperCase()}>{calibration.factor}</Descriptions.Item> size="small"
<Descriptions.Item label="U">{calibration.expanded_uncertainty_percent}%</Descriptions.Item> rowKey="key"
<Descriptions.Item label="k">{calibration.coverage_factor}</Descriptions.Item> dataSource={[
</Descriptions> { key: "ra", name: "Ra", ...defaultCalibration.ra },
))} { key: "th", name: "Th", ...defaultCalibration.th },
</Form> { 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" }
]}
/>
<div className="limits-form">
<div className="limit-field">
<span className="limit-label">IRa </span>
<InputNumber
value={limits.ira_limit}
min={0}
step={0.1}
onChange={(value) => setLimits((current) => ({ ...current, ira_limit: value ?? 0 }))}
/>
</div>
<div className="limit-field">
<span className="limit-label">Ir </span>
<InputNumber
value={limits.ir_limit}
min={0}
step={0.1}
onChange={(value) => setLimits((current) => ({ ...current, ir_limit: value ?? 0 }))}
/>
</div>
</div>
</Card> </Card>
</div> </div>
{error ? <Alert type="error" message={error} showIcon /> : null} {error ? <Alert type="error" message={error} showIcon /> : null}
{result ? ( <div className="results-area">
<Card title="计算结果" className="panel"> {result ? (
<div className="result-grid"> <div className="results-row">
<ResultTile title="IRa" value={result.ira.value} uncertainty={result.ira.relative_uncertainty_percent} /> <Card title="计算结果GUM 法)" className="panel">
<ResultTile title="Ir" value={result.ir.value} uncertainty={result.ir.relative_uncertainty_percent} /> <div className="result-grid">
<div className="result-tile conclusion-tile"> <ResultTile title="IRa" value={result.ira.value} uncertainty={result.ira.relative_uncertainty_percent} />
<span></span> <ResultTile title="Ir" value={result.ir.value} uncertainty={result.ir.relative_uncertainty_percent} />
<Tag color={result.conclusion === "Ok" ? "success" : "warning"}> <div className="result-tile conclusion-tile">
{conclusionText[result.conclusion]} <span></span>
</Tag> <Tag color={result.conclusion === "Ok" ? "success" : "warning"}>
{conclusionText[result.conclusion]}
</Tag>
</div>
</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>
<Card title={`蒙特卡洛仿真MCM${result.mcm.iterations} 次)`} className="panel">
<div className="result-grid">
<div className="result-tile">
<span></span>
<strong>{formatPercent(result.mcm.overall_pass_probability)}</strong>
<small>IRa Ir </small>
</div>
<div className="result-tile">
<span></span>
<strong>{formatPercent(result.mcm.overall_fail_probability)}</strong>
<small></small>
</div>
<div className="result-tile conclusion-tile">
<span>仿</span>
<Tag color={result.mcm.overall_pass_probability >= 0.95 ? "success" : "warning"}>
{result.mcm.overall_pass_probability >= 0.95 ? "合格" : "不合格风险"}
</Tag>
</div>
</div>
<Table<McmRow>
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) => (
<Tag color={value >= 0.95 ? "success" : "warning"}>{formatPercent(value)}</Tag>
)
}
]}
/>
</Card>
</div> </div>
<Table<ResultRow> ) : (
pagination={false} <div className="results-placeholder"></div>
rowKey="name" )}
size="small" </div>
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> </section>
</main> </main>
</ConfigProvider> </ConfigProvider>

View File

@ -12,20 +12,47 @@ body {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
padding: 12px; padding: 12px;
display: flex;
flex-direction: column;
} }
.workspace { .workspace {
width: 100%;
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
} }
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 240px; grid-template-columns: minmax(0, 1fr) 240px;
grid-auto-rows: 450px; grid-auto-rows: 440px;
align-items: stretch; align-items: stretch;
gap: 10px; gap: 10px;
margin-bottom: 10px; margin-bottom: 10px;
flex: 0 0 auto;
}
.results-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.results-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 26px;
font-weight: 600;
letter-spacing: 2px;
user-select: none;
} }
.panel { .panel {
@ -70,13 +97,36 @@ body {
} }
.compact-panel .ant-card-body { .compact-panel .ant-card-body {
height: calc(100% - 38px); height: auto;
overflow: hidden; overflow: visible;
padding: 8px 10px 6px; padding: 10px;
} }
.calibration-block { .calibration-table {
margin-bottom: 8px; margin-bottom: 12px;
}
.limits-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.limit-field {
display: flex;
align-items: center;
gap: 8px;
}
.limit-label {
flex-shrink: 0;
white-space: nowrap;
color: #5f6368;
font-size: 13px;
}
.limit-field .ant-input-number {
flex: 1;
} }
.ant-input-number { .ant-input-number {
@ -91,6 +141,17 @@ body {
margin-top: 8px; margin-top: 8px;
} }
.results-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: stretch;
}
.results-row > .panel {
height: 100%;
}
.result-grid { .result-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@ -99,8 +160,8 @@ body {
} }
.result-tile { .result-tile {
min-height: 68px; min-height: 58px;
padding: 8px 10px; padding: 7px 10px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
@ -116,7 +177,7 @@ body {
} }
.result-tile strong { .result-tile strong {
font-size: 20px; font-size: 18px;
line-height: 1.2; line-height: 1.2;
} }
@ -145,23 +206,14 @@ body {
padding: 0 7px; 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) { @media (max-width: 900px) {
.app-shell { .app-shell {
padding: 10px; padding: 10px;
} }
.content-grid, .content-grid,
.result-grid { .result-grid,
.results-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }