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:
parent
c1080c1b03
commit
0c06d51c4a
|
|
@ -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 Runtime(Win11 已内置) | 见 <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/ 计算引擎(库 crate:ceramic-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 主体材料)。
|
||||||
|
- 仿真使用固定随机种子,结果可复现。
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.0(GB 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)]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
216
ui/src/App.tsx
216
ui/src/App.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue