400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { Alert, Button, Card, ConfigProvider, 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 AcceptanceLimits = {
|
||
ira_limit: number;
|
||
ir_limit: number;
|
||
};
|
||
|
||
type SampleInput = {
|
||
ra: NuclideMeasurements;
|
||
th: NuclideMeasurements;
|
||
k: NuclideMeasurements;
|
||
limits: AcceptanceLimits;
|
||
};
|
||
|
||
type McmIndexStats = {
|
||
mean: number;
|
||
std_dev: number;
|
||
p2_5: number;
|
||
p97_5: number;
|
||
standard_value: number;
|
||
pass_probability: number;
|
||
fail_probability: number;
|
||
};
|
||
|
||
type McmResult = {
|
||
iterations: number;
|
||
ira: McmIndexStats;
|
||
ir: McmIndexStats;
|
||
overall_pass_probability: number;
|
||
overall_fail_probability: number;
|
||
};
|
||
|
||
type NuclideResult = {
|
||
mean_measured: number;
|
||
mean_calibrated: number;
|
||
type_a_uncertainty: number;
|
||
type_b_relative: number;
|
||
type_b_uncertainty: number;
|
||
sensitivity_coefficient: number;
|
||
combined_uncertainty: number;
|
||
};
|
||
|
||
type IndexResult = {
|
||
value: number;
|
||
standard_uncertainty: number;
|
||
relative_uncertainty_percent: number;
|
||
};
|
||
|
||
type CalculationResult = {
|
||
measurement_count: number;
|
||
ra: NuclideResult;
|
||
th: NuclideResult;
|
||
k: NuclideResult;
|
||
ira: IndexResult;
|
||
ir: IndexResult;
|
||
conclusion: Conclusion;
|
||
mcm: McmResult;
|
||
};
|
||
|
||
type MeasurementRow = {
|
||
key: number;
|
||
ra: number | null;
|
||
th: number | null;
|
||
k: number | null;
|
||
};
|
||
|
||
type ResultRow = { name: string } & NuclideResult;
|
||
|
||
type McmRow = { name: string } & McmIndexStats;
|
||
|
||
type CalibrationRow = { key: string; name: string } & CalibrationParams;
|
||
|
||
type FocusableInput = {
|
||
focus: () => void;
|
||
};
|
||
|
||
const defaultCalibration = {
|
||
ra: { factor: 0.916, expanded_uncertainty_percent: 6.3, coverage_factor: 2 },
|
||
th: { factor: 0.884, expanded_uncertainty_percent: 6.9, coverage_factor: 2 },
|
||
k: { factor: 0.961, expanded_uncertainty_percent: 6.7, coverage_factor: 2 }
|
||
};
|
||
|
||
const defaultLimits: AcceptanceLimits = { ira_limit: 1.0, ir_limit: 1.0 };
|
||
|
||
const initialRows: MeasurementRow[] = [
|
||
{ key: 1, ra: 100, th: 110, k: 560 },
|
||
{ key: 2, ra: 102, th: 111, k: 565 },
|
||
{ key: 3, ra: 98, th: 109, k: 555 },
|
||
{ key: 4, ra: 101, th: 110, k: 562 },
|
||
{ key: 5, ra: 99, th: 112, k: 558 },
|
||
{ key: 6, ra: 100, th: 108, k: 561 }
|
||
];
|
||
|
||
const conclusionText: Record<Conclusion, string> = {
|
||
Ok: "OK",
|
||
IncreaseMeasurementsToSix: "请增加试验次数至 6 次",
|
||
RecalibrateInstrument: "校准仪器后重新测量"
|
||
};
|
||
|
||
function formatNumber(value: number, digits = 4) {
|
||
if (!Number.isFinite(value)) return "-";
|
||
return value.toFixed(digits);
|
||
}
|
||
|
||
function formatPercent(value: number, digits = 2) {
|
||
if (!Number.isFinite(value)) return "-";
|
||
return (value * 100).toFixed(digits) + "%";
|
||
}
|
||
|
||
function App() {
|
||
const [rows, setRows] = useState<MeasurementRow[]>(initialRows);
|
||
const [limits, setLimits] = useState<AcceptanceLimits>(defaultLimits);
|
||
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 },
|
||
limits
|
||
};
|
||
};
|
||
|
||
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">
|
||
<Table<CalibrationRow>
|
||
className="calibration-table"
|
||
pagination={false}
|
||
size="small"
|
||
rowKey="key"
|
||
dataSource={[
|
||
{ key: "ra", name: "Ra", ...defaultCalibration.ra },
|
||
{ key: "th", name: "Th", ...defaultCalibration.th },
|
||
{ key: "k", name: "K", ...defaultCalibration.k }
|
||
]}
|
||
columns={[
|
||
{ title: "核素", dataIndex: "name", align: "center" },
|
||
{ title: "a", dataIndex: "factor", align: "center" },
|
||
{
|
||
title: "U",
|
||
dataIndex: "expanded_uncertainty_percent",
|
||
align: "center",
|
||
render: (value: number) => `${value}%`
|
||
},
|
||
{ title: "k", dataIndex: "coverage_factor", align: "center" }
|
||
]}
|
||
/>
|
||
<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>
|
||
</div>
|
||
|
||
{error ? <Alert type="error" message={error} showIcon /> : null}
|
||
|
||
<div className="results-area">
|
||
{result ? (
|
||
<div className="results-row">
|
||
<Card title="计算结果(GUM 法)" 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>
|
||
|
||
<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 className="results-placeholder">请计算后查看结果</div>
|
||
)}
|
||
</div>
|
||
</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 };
|