dpp_rules/lint/
battery.rs1use alloc::{format, string::String, vec::Vec};
7
8use super::{LintFinding, LintSeverity};
9
10#[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 pub manufacturing_date_unix: Option<i64>,
21 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#[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#[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#[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#[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#[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#[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 #[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); 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 #[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); 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 #[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]; 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 #[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); let finding = manufacturing_date_in_future(&input).unwrap();
284 assert_eq!(finding.code, "battery.manufacturing_date_in_future");
285 }
286
287 #[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 #[test]
306 fn clean_input_produces_no_findings() {
307 assert!(lint_battery(&base_input()).is_empty());
308 }
309}