diff --git a/run.md b/run.md new file mode 100644 index 0000000..608aa1f --- /dev/null +++ b/run.md @@ -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 已内置) | 见 | + +首次准备: + +```powershell +npm install +``` + +> Rust 依赖在首次 `cargo`/`tauri` 命令时自动下载编译。 + +## 2. 开发运行 + +### 2.1 仅前端(浏览器预览,不含 Tauri 原生命令) + +```powershell +npm run dev +``` + +打开 。注意:此模式下 `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 主体材料)。 + - 仿真使用固定随机种子,结果可复现。 diff --git a/src/calculator.rs b/src/calculator.rs index 453bdb3..85578ab 100644 --- a/src/calculator.rs +++ b/src/calculator.rs @@ -2,6 +2,7 @@ use crate::domain::{ CalculationError, CalculationResult, Conclusion, IndexResult, NuclideMeasurements, NuclideResult, SampleInput, }; +use crate::mcm::run_monte_carlo; const ACCEPTANCE_LIMIT_PERCENT: f64 = 20.0; @@ -25,6 +26,8 @@ pub fn calculate_sample(input: SampleInput) -> Result Result Self { + Self { + ira_limit: 1.0, + ir_limit: 1.0, + } + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -32,6 +51,42 @@ pub struct CalculationResult { pub ira: IndexResult, pub ir: IndexResult, 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)] diff --git a/src/lib.rs b/src/lib.rs index eceaebe..c41623d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,9 @@ mod calculator; mod domain; +mod mcm; pub use calculator::calculate_sample; pub use domain::{ - CalculationError, CalculationResult, CalibrationParams, Conclusion, IndexResult, - NuclideMeasurements, NuclideResult, SampleInput, + AcceptanceLimits, CalculationError, CalculationResult, CalibrationParams, Conclusion, + IndexResult, McmIndexStats, McmResult, NuclideMeasurements, NuclideResult, SampleInput, }; diff --git a/src/mcm.rs b/src/mcm.rs new file mode 100644 index 0000000..619fa3b --- /dev/null +++ b/src/mcm.rs @@ -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::() / n as f64; + let variance = samples + .iter() + .map(|value| (value - mean).powi(2)) + .sum::() + / (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() + } +} diff --git a/tests/calculator_tests.rs b/tests/calculator_tests.rs index a096fcf..1189afe 100644 --- a/tests/calculator_tests.rs +++ b/tests/calculator_tests.rs @@ -1,5 +1,6 @@ use ceramic_radioactivity::{ - calculate_sample, CalibrationParams, Conclusion, NuclideMeasurements, SampleInput, + calculate_sample, AcceptanceLimits, CalibrationParams, Conclusion, NuclideMeasurements, + SampleInput, }; fn default_input() -> SampleInput { @@ -28,6 +29,10 @@ fn default_input() -> SampleInput { 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")); } +#[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) { assert!( (actual - expected).abs() <= tolerance, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c112574..89cb4ee 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,16 +1,6 @@ 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 { Alert, Button, Card, ConfigProvider, InputNumber, Table, Tag } from "antd"; import zhCN from "antd/locale/zh_CN"; type Conclusion = "Ok" | "IncreaseMeasurementsToSix" | "RecalibrateInstrument"; @@ -26,10 +16,34 @@ type NuclideMeasurements = { 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 = { @@ -56,6 +70,7 @@ type CalculationResult = { ira: IndexResult; ir: IndexResult; conclusion: Conclusion; + mcm: McmResult; }; type MeasurementRow = { @@ -67,6 +82,10 @@ type MeasurementRow = { type ResultRow = { name: string } & NuclideResult; +type McmRow = { name: string } & McmIndexStats; + +type CalibrationRow = { key: string; name: string } & CalibrationParams; + type FocusableInput = { focus: () => void; }; @@ -77,6 +96,8 @@ const defaultCalibration = { 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 }, @@ -97,8 +118,14 @@ function formatNumber(value: number, digits = 4) { 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(initialRows); + const [limits, setLimits] = useState(defaultLimits); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -129,7 +156,8 @@ function App() { 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 } + k: { measured_values: toValues("k"), calibration: defaultCalibration.k }, + limits }; }; @@ -221,53 +249,137 @@ function App() { - -
- {Object.entries(defaultCalibration).map(([key, calibration]) => ( - - {calibration.factor} - {calibration.expanded_uncertainty_percent}% - {calibration.coverage_factor} - - ))} -
+ + + 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" } + ]} + /> +
+
+ IRa 标准值 + setLimits((current) => ({ ...current, ira_limit: value ?? 0 }))} + /> +
+
+ Ir 标准值 + setLimits((current) => ({ ...current, ir_limit: value ?? 0 }))} + /> +
+
{error ? : null} - {result ? ( - -
- - -
- 判定 - - {conclusionText[result.conclusion]} - +
+ {result ? ( +
+ +
+ + +
+ 判定 + + {conclusionText[result.conclusion]} + +
+ + 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) } + ]} + /> +
+ + +
+
+ 综合合格概率 + {formatPercent(result.mcm.overall_pass_probability)} + IRa 与 Ir 同时合格 +
+
+ 综合不合格概率 + {formatPercent(result.mcm.overall_fail_probability)} + 任一指数超标 +
+
+ 仿真判定 + = 0.95 ? "success" : "warning"}> + {result.mcm.overall_pass_probability >= 0.95 ? "合格" : "不合格风险"} + +
+
+ + 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) => ( + = 0.95 ? "success" : "warning"}>{formatPercent(value)} + ) + } + ]} + /> +
- - 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) } - ]} - /> - - ) : null} + ) : ( +
请计算后查看结果
+ )} +
diff --git a/ui/src/styles.css b/ui/src/styles.css index 82401b4..2762519 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -12,20 +12,47 @@ body { .app-shell { min-height: 100vh; padding: 12px; + display: flex; + flex-direction: column; } .workspace { + width: 100%; max-width: 1280px; margin: 0 auto; + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; } .content-grid { display: grid; grid-template-columns: minmax(0, 1fr) 240px; - grid-auto-rows: 450px; + grid-auto-rows: 440px; align-items: stretch; gap: 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 { @@ -70,13 +97,36 @@ body { } .compact-panel .ant-card-body { - height: calc(100% - 38px); - overflow: hidden; - padding: 8px 10px 6px; + height: auto; + overflow: visible; + padding: 10px; } -.calibration-block { - margin-bottom: 8px; +.calibration-table { + 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 { @@ -91,6 +141,17 @@ body { 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 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -99,8 +160,8 @@ body { } .result-tile { - min-height: 68px; - padding: 8px 10px; + min-height: 58px; + padding: 7px 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: #ffffff; @@ -116,7 +177,7 @@ body { } .result-tile strong { - font-size: 20px; + font-size: 18px; line-height: 1.2; } @@ -145,23 +206,14 @@ body { 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 { + .result-grid, + .results-row { grid-template-columns: 1fr; }