Skip to main content

dpp_rules/lint/
battery.rs

1//! Battery plausibility lints — physically-grounded consistency checks that
2//! EU Battery Regulation 2023/1542 does not itself require, but that flag
3//! likely data-entry mistakes: energy/capacity arithmetic, unit-conversion
4//! consistency, material-composition sums, and date/range plausibility.
5
6use alloc::{format, string::String, vec::Vec};
7
8use super::{LintFinding, LintSeverity};
9
10/// Borrowing view over the battery fields these lints inspect.
11#[derive(Debug, Clone, Copy)]
12pub struct BatteryLintInput<'a> {
13    pub nominal_voltage_v: f64,
14    pub nominal_capacity_ah: f64,
15    pub rated_energy_wh: Option<f64>,
16    pub rated_capacity_kwh: Option<f64>,
17    pub operating_temp_min_c: Option<f64>,
18    pub operating_temp_max_c: Option<f64>,
19    /// Unix seconds. `None` when the passport carries no manufacturing date.
20    pub manufacturing_date_unix: Option<i64>,
21    /// Unix seconds "now" — this crate has no clock, so the caller supplies it.
22    pub as_of_unix: i64,
23    pub cathode_material_pct: &'a [f64],
24    pub anode_material_pct: &'a [f64],
25    pub electrolyte_material_pct: &'a [f64],
26}
27
28const ENERGY_CAPACITY_TOLERANCE_PCT: f64 = 20.0;
29
30/// Physics check: energy (Wh) = voltage (V) × capacity (Ah). A declared
31/// `ratedEnergyWh` more than `ENERGY_CAPACITY_TOLERANCE_PCT` away from the
32/// V×Ah product is more likely a data-entry error than a real spec — though a
33/// generous tolerance is kept since manufacturers sometimes rate energy at
34/// average (not nominal) discharge voltage, hence `Notice` not `Warning`.
35#[must_use]
36pub fn energy_capacity_mismatch(input: &BatteryLintInput<'_>) -> Option<LintFinding> {
37    let declared = input.rated_energy_wh?;
38    let computed = input.nominal_voltage_v * input.nominal_capacity_ah;
39    if !declared.is_finite() || !computed.is_finite() || computed <= 0.0 {
40        return None;
41    }
42    let deviation_pct = ((declared - computed).abs() / computed) * 100.0;
43    if deviation_pct <= ENERGY_CAPACITY_TOLERANCE_PCT {
44        return None;
45    }
46    Some(LintFinding {
47        code: "battery.energy_capacity_mismatch",
48        field: "ratedEnergyWh",
49        severity: LintSeverity::Notice,
50        message: format!(
51            "ratedEnergyWh ({declared:.1} Wh) differs from nominalVoltageV × nominalCapacityAh \
52             ({computed:.1} Wh) by {deviation_pct:.0}% — intended?"
53        ),
54    })
55}
56
57const CAPACITY_UNIT_TOLERANCE_PCT: f64 = 5.0;
58
59/// Unit-conversion check: `ratedCapacityKwh × 1000` and `ratedEnergyWh`
60/// describe the same physical quantity in different units and should match
61/// closely — unlike the voltage×capacity estimate above, there is no
62/// legitimate reason for these two to diverge by more than rounding.
63#[must_use]
64pub fn rated_capacity_kwh_wh_mismatch(input: &BatteryLintInput<'_>) -> Option<LintFinding> {
65    let kwh = input.rated_capacity_kwh?;
66    let wh = input.rated_energy_wh?;
67    if !kwh.is_finite() || !wh.is_finite() {
68        return None;
69    }
70    let computed_wh = kwh * 1000.0;
71    if computed_wh <= 0.0 {
72        return None;
73    }
74    let deviation_pct = ((wh - computed_wh).abs() / computed_wh) * 100.0;
75    if deviation_pct <= CAPACITY_UNIT_TOLERANCE_PCT {
76        return None;
77    }
78    Some(LintFinding {
79        code: "battery.rated_capacity_kwh_wh_mismatch",
80        field: "ratedCapacityKwh",
81        severity: LintSeverity::Warning,
82        message: format!(
83            "ratedCapacityKwh ({kwh:.3} kWh = {computed_wh:.1} Wh) does not match ratedEnergyWh \
84             ({wh:.1} Wh) — intended?"
85        ),
86    })
87}
88
89const MATERIAL_SUM_TOLERANCE_PCT: f64 = 2.0;
90
91fn material_sum_finding(field: &'static str, pcts: &[f64]) -> Option<LintFinding> {
92    if pcts.is_empty() {
93        return None;
94    }
95    let total: f64 = pcts.iter().copied().sum();
96    if !total.is_finite() {
97        return None;
98    }
99    if (total - 100.0).abs() <= MATERIAL_SUM_TOLERANCE_PCT {
100        return None;
101    }
102    Some(LintFinding {
103        code: "battery.material_composition_sum",
104        field,
105        severity: LintSeverity::Warning,
106        message: format!(
107            "{field} weightPct entries sum to {total:.1}%, expected ~100% — intended?"
108        ),
109    })
110}
111
112/// Sum-consistency check: `cathodeMaterial`, `anodeMaterial`, and
113/// `electrolyteMaterial` are each declared as a `weightPct` breakdown of that
114/// component — none of the three currently have a hard schema or cross-field
115/// gate requiring them to sum to 100%. Fires independently per list, so 0–3
116/// findings can result from one call.
117#[must_use]
118pub fn material_composition_sums(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
119    [
120        ("cathodeMaterial", input.cathode_material_pct),
121        ("anodeMaterial", input.anode_material_pct),
122        ("electrolyteMaterial", input.electrolyte_material_pct),
123    ]
124    .into_iter()
125    .filter_map(|(field, pcts)| material_sum_finding(field, pcts))
126    .collect()
127}
128
129/// Cross-field ordering: a declared manufacturing date after "now" cannot be
130/// correct — the battery hasn't been made yet.
131#[must_use]
132pub fn manufacturing_date_in_future(input: &BatteryLintInput<'_>) -> Option<LintFinding> {
133    let mfg = input.manufacturing_date_unix?;
134    if mfg <= input.as_of_unix {
135        return None;
136    }
137    Some(LintFinding {
138        code: "battery.manufacturing_date_in_future",
139        field: "manufacturingDate",
140        severity: LintSeverity::Warning,
141        message: String::from("manufacturingDate is in the future — intended?"),
142    })
143}
144
145const OPERATING_TEMP_MIN_PLAUSIBLE_C: f64 = -60.0;
146const OPERATING_TEMP_MAX_PLAUSIBLE_C: f64 = 150.0;
147
148/// Range plausibility: no commercial battery chemistry operates outside
149/// roughly -60°C to 150°C. A declared bound beyond that is far more likely a
150/// unit slip (e.g. Fahrenheit entered as Celsius) than a real spec. Distinct
151/// from [`crate::batteries::chemistry::validate_operating_temp_range`], which
152/// checks `min < max` — this checks each bound against a plausible envelope.
153#[must_use]
154pub fn operating_temp_absurd_range(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
155    let mut out = Vec::new();
156    if let Some(min) = input.operating_temp_min_c
157        && min.is_finite()
158        && min < OPERATING_TEMP_MIN_PLAUSIBLE_C
159    {
160        out.push(LintFinding {
161            code: "battery.operating_temp_range_implausible",
162            field: "operatingTempMinC",
163            severity: LintSeverity::Notice,
164            message: format!(
165                "operatingTempMinC ({min}°C) is outside the plausible range for any known battery \
166                 chemistry — intended?"
167            ),
168        });
169    }
170    if let Some(max) = input.operating_temp_max_c
171        && max.is_finite()
172        && max > OPERATING_TEMP_MAX_PLAUSIBLE_C
173    {
174        out.push(LintFinding {
175            code: "battery.operating_temp_range_implausible",
176            field: "operatingTempMaxC",
177            severity: LintSeverity::Notice,
178            message: format!(
179                "operatingTempMaxC ({max}°C) is outside the plausible range for any known battery \
180                 chemistry — intended?"
181            ),
182        });
183    }
184    out
185}
186
187/// Run every battery plausibility lint and collect the findings.
188#[must_use]
189pub fn lint_battery(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
190    let mut out = Vec::new();
191    out.extend(energy_capacity_mismatch(input));
192    out.extend(rated_capacity_kwh_wh_mismatch(input));
193    out.extend(material_composition_sums(input));
194    out.extend(manufacturing_date_in_future(input));
195    out.extend(operating_temp_absurd_range(input));
196    out
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn base_input() -> BatteryLintInput<'static> {
204        BatteryLintInput {
205            nominal_voltage_v: 3.7,
206            nominal_capacity_ah: 10.0,
207            rated_energy_wh: Some(37.0),
208            rated_capacity_kwh: Some(0.037),
209            operating_temp_min_c: Some(-20.0),
210            operating_temp_max_c: Some(60.0),
211            manufacturing_date_unix: Some(1_000),
212            as_of_unix: 2_000,
213            cathode_material_pct: &[],
214            anode_material_pct: &[],
215            electrolyte_material_pct: &[],
216        }
217    }
218
219    // ── energy_capacity_mismatch ────────────────────────────────────────────
220
221    #[test]
222    fn energy_capacity_within_tolerance_passes() {
223        assert!(energy_capacity_mismatch(&base_input()).is_none());
224    }
225
226    #[test]
227    fn energy_capacity_far_off_triggers() {
228        let mut input = base_input();
229        input.rated_energy_wh = Some(200.0); // 37.0 expected, way off
230        let finding = energy_capacity_mismatch(&input).unwrap();
231        assert_eq!(finding.code, "battery.energy_capacity_mismatch");
232        assert_eq!(finding.severity, LintSeverity::Notice);
233    }
234
235    // ── rated_capacity_kwh_wh_mismatch ──────────────────────────────────────
236
237    #[test]
238    fn capacity_unit_consistent_passes() {
239        assert!(rated_capacity_kwh_wh_mismatch(&base_input()).is_none());
240    }
241
242    #[test]
243    fn capacity_unit_mismatch_triggers() {
244        let mut input = base_input();
245        input.rated_capacity_kwh = Some(1.0); // 1000 Wh expected, declared 37 Wh
246        let finding = rated_capacity_kwh_wh_mismatch(&input).unwrap();
247        assert_eq!(finding.code, "battery.rated_capacity_kwh_wh_mismatch");
248        assert_eq!(finding.severity, LintSeverity::Warning);
249    }
250
251    // ── material_composition_sums ───────────────────────────────────────────
252
253    #[test]
254    fn material_sums_near_100_pass() {
255        let mut input = base_input();
256        input.cathode_material_pct = &[60.0, 40.0];
257        assert!(material_composition_sums(&input).is_empty());
258    }
259
260    #[test]
261    fn material_sum_off_triggers() {
262        let mut input = base_input();
263        input.cathode_material_pct = &[60.0, 20.0]; // sums to 80
264        let findings = material_composition_sums(&input);
265        assert_eq!(findings.len(), 1);
266        assert_eq!(findings[0].field, "cathodeMaterial");
267    }
268
269    #[test]
270    fn empty_material_lists_never_trigger() {
271        assert!(material_composition_sums(&base_input()).is_empty());
272    }
273
274    // ── manufacturing_date_in_future ────────────────────────────────────────
275
276    #[test]
277    fn manufacturing_date_in_past_passes() {
278        assert!(manufacturing_date_in_future(&base_input()).is_none());
279    }
280
281    #[test]
282    fn manufacturing_date_in_future_triggers() {
283        let mut input = base_input();
284        input.manufacturing_date_unix = Some(3_000); // as_of is 2_000
285        let finding = manufacturing_date_in_future(&input).unwrap();
286        assert_eq!(finding.code, "battery.manufacturing_date_in_future");
287    }
288
289    // ── operating_temp_absurd_range ─────────────────────────────────────────
290
291    #[test]
292    fn plausible_temp_range_passes() {
293        assert!(operating_temp_absurd_range(&base_input()).is_empty());
294    }
295
296    #[test]
297    fn absurd_temp_range_triggers_both_bounds() {
298        let mut input = base_input();
299        input.operating_temp_min_c = Some(-200.0);
300        input.operating_temp_max_c = Some(500.0);
301        let findings = operating_temp_absurd_range(&input);
302        assert_eq!(findings.len(), 2);
303    }
304
305    // ── lint_battery aggregator ─────────────────────────────────────────────
306
307    #[test]
308    fn clean_input_produces_no_findings() {
309        assert!(lint_battery(&base_input()).is_empty());
310    }
311}