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 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#[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#[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#[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#[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 #[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); 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 #[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); 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 #[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]; 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 #[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); let finding = manufacturing_date_in_future(&input).unwrap();
286 assert_eq!(finding.code, "battery.manufacturing_date_in_future");
287 }
288
289 #[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 #[test]
308 fn clean_input_produces_no_findings() {
309 assert!(lint_battery(&base_input()).is_empty());
310 }
311}