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 (within_tolerance, total) =
96        crate::common::numeric::sums_to(pcts.iter().copied(), 100.0, MATERIAL_SUM_TOLERANCE_PCT);
97    if !total.is_finite() || within_tolerance {
98        return None;
99    }
100    Some(LintFinding {
101        code: "battery.material_composition_sum",
102        field,
103        severity: LintSeverity::Warning,
104        message: format!(
105            "{field} weightPct entries sum to {total:.1}%, expected ~100% — intended?"
106        ),
107    })
108}
109
110/// Sum-consistency check: `cathodeMaterial`, `anodeMaterial`, and
111/// `electrolyteMaterial` are each declared as a `weightPct` breakdown of that
112/// component — none of the three currently have a hard schema or cross-field
113/// gate requiring them to sum to 100%. Fires independently per list, so 0–3
114/// findings can result from one call.
115#[must_use]
116pub fn material_composition_sums(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
117    [
118        ("cathodeMaterial", input.cathode_material_pct),
119        ("anodeMaterial", input.anode_material_pct),
120        ("electrolyteMaterial", input.electrolyte_material_pct),
121    ]
122    .into_iter()
123    .filter_map(|(field, pcts)| material_sum_finding(field, pcts))
124    .collect()
125}
126
127/// Cross-field ordering: a declared manufacturing date after "now" cannot be
128/// correct — the battery hasn't been made yet.
129#[must_use]
130pub fn manufacturing_date_in_future(input: &BatteryLintInput<'_>) -> Option<LintFinding> {
131    let mfg = input.manufacturing_date_unix?;
132    if mfg <= input.as_of_unix {
133        return None;
134    }
135    Some(LintFinding {
136        code: "battery.manufacturing_date_in_future",
137        field: "manufacturingDate",
138        severity: LintSeverity::Warning,
139        message: String::from("manufacturingDate is in the future — intended?"),
140    })
141}
142
143const OPERATING_TEMP_MIN_PLAUSIBLE_C: f64 = -60.0;
144const OPERATING_TEMP_MAX_PLAUSIBLE_C: f64 = 150.0;
145
146/// Range plausibility: no commercial battery chemistry operates outside
147/// roughly -60°C to 150°C. A declared bound beyond that is far more likely a
148/// unit slip (e.g. Fahrenheit entered as Celsius) than a real spec. Distinct
149/// from [`crate::batteries::chemistry::validate_operating_temp_range`], which
150/// checks `min < max` — this checks each bound against a plausible envelope.
151#[must_use]
152pub fn operating_temp_absurd_range(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
153    let mut out = Vec::new();
154    if let Some(min) = input.operating_temp_min_c
155        && min.is_finite()
156        && min < OPERATING_TEMP_MIN_PLAUSIBLE_C
157    {
158        out.push(LintFinding {
159            code: "battery.operating_temp_range_implausible",
160            field: "operatingTempMinC",
161            severity: LintSeverity::Notice,
162            message: format!(
163                "operatingTempMinC ({min}°C) is outside the plausible range for any known battery \
164                 chemistry — intended?"
165            ),
166        });
167    }
168    if let Some(max) = input.operating_temp_max_c
169        && max.is_finite()
170        && max > OPERATING_TEMP_MAX_PLAUSIBLE_C
171    {
172        out.push(LintFinding {
173            code: "battery.operating_temp_range_implausible",
174            field: "operatingTempMaxC",
175            severity: LintSeverity::Notice,
176            message: format!(
177                "operatingTempMaxC ({max}°C) is outside the plausible range for any known battery \
178                 chemistry — intended?"
179            ),
180        });
181    }
182    out
183}
184
185/// Run every battery plausibility lint and collect the findings.
186#[must_use]
187pub fn lint_battery(input: &BatteryLintInput<'_>) -> Vec<LintFinding> {
188    let mut out = Vec::new();
189    out.extend(energy_capacity_mismatch(input));
190    out.extend(rated_capacity_kwh_wh_mismatch(input));
191    out.extend(material_composition_sums(input));
192    out.extend(manufacturing_date_in_future(input));
193    out.extend(operating_temp_absurd_range(input));
194    out
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn base_input() -> BatteryLintInput<'static> {
202        BatteryLintInput {
203            nominal_voltage_v: 3.7,
204            nominal_capacity_ah: 10.0,
205            rated_energy_wh: Some(37.0),
206            rated_capacity_kwh: Some(0.037),
207            operating_temp_min_c: Some(-20.0),
208            operating_temp_max_c: Some(60.0),
209            manufacturing_date_unix: Some(1_000),
210            as_of_unix: 2_000,
211            cathode_material_pct: &[],
212            anode_material_pct: &[],
213            electrolyte_material_pct: &[],
214        }
215    }
216
217    // ── energy_capacity_mismatch ────────────────────────────────────────────
218
219    #[test]
220    fn energy_capacity_within_tolerance_passes() {
221        assert!(energy_capacity_mismatch(&base_input()).is_none());
222    }
223
224    #[test]
225    fn energy_capacity_far_off_triggers() {
226        let mut input = base_input();
227        input.rated_energy_wh = Some(200.0); // 37.0 expected, way off
228        let finding = energy_capacity_mismatch(&input).unwrap();
229        assert_eq!(finding.code, "battery.energy_capacity_mismatch");
230        assert_eq!(finding.severity, LintSeverity::Notice);
231    }
232
233    // ── rated_capacity_kwh_wh_mismatch ──────────────────────────────────────
234
235    #[test]
236    fn capacity_unit_consistent_passes() {
237        assert!(rated_capacity_kwh_wh_mismatch(&base_input()).is_none());
238    }
239
240    #[test]
241    fn capacity_unit_mismatch_triggers() {
242        let mut input = base_input();
243        input.rated_capacity_kwh = Some(1.0); // 1000 Wh expected, declared 37 Wh
244        let finding = rated_capacity_kwh_wh_mismatch(&input).unwrap();
245        assert_eq!(finding.code, "battery.rated_capacity_kwh_wh_mismatch");
246        assert_eq!(finding.severity, LintSeverity::Warning);
247    }
248
249    // ── material_composition_sums ───────────────────────────────────────────
250
251    #[test]
252    fn material_sums_near_100_pass() {
253        let mut input = base_input();
254        input.cathode_material_pct = &[60.0, 40.0];
255        assert!(material_composition_sums(&input).is_empty());
256    }
257
258    #[test]
259    fn material_sum_off_triggers() {
260        let mut input = base_input();
261        input.cathode_material_pct = &[60.0, 20.0]; // sums to 80
262        let findings = material_composition_sums(&input);
263        assert_eq!(findings.len(), 1);
264        assert_eq!(findings[0].field, "cathodeMaterial");
265    }
266
267    #[test]
268    fn empty_material_lists_never_trigger() {
269        assert!(material_composition_sums(&base_input()).is_empty());
270    }
271
272    // ── manufacturing_date_in_future ────────────────────────────────────────
273
274    #[test]
275    fn manufacturing_date_in_past_passes() {
276        assert!(manufacturing_date_in_future(&base_input()).is_none());
277    }
278
279    #[test]
280    fn manufacturing_date_in_future_triggers() {
281        let mut input = base_input();
282        input.manufacturing_date_unix = Some(3_000); // as_of is 2_000
283        let finding = manufacturing_date_in_future(&input).unwrap();
284        assert_eq!(finding.code, "battery.manufacturing_date_in_future");
285    }
286
287    // ── operating_temp_absurd_range ─────────────────────────────────────────
288
289    #[test]
290    fn plausible_temp_range_passes() {
291        assert!(operating_temp_absurd_range(&base_input()).is_empty());
292    }
293
294    #[test]
295    fn absurd_temp_range_triggers_both_bounds() {
296        let mut input = base_input();
297        input.operating_temp_min_c = Some(-200.0);
298        input.operating_temp_max_c = Some(500.0);
299        let findings = operating_temp_absurd_range(&input);
300        assert_eq!(findings.len(), 2);
301    }
302
303    // ── lint_battery aggregator ─────────────────────────────────────────────
304
305    #[test]
306    fn clean_input_produces_no_findings() {
307        assert!(lint_battery(&base_input()).is_empty());
308    }
309}