dpp_rules/batteries/recycled_content.rs
1//! Battery recycled content validation — EU Regulation 2023/1542, Art. 8.
2//!
3//! Art. 8(2) and 8(3) set minimum recycled content shares for four metals, in
4//! the regulation text itself. Both paragraphs require the **Annex VIII**
5//! technical documentation to demonstrate the share — Annex VIII is the only
6//! annex Art. 8 cross-references.
7//!
8//! Phase 1 (from 18 Aug 2031) covers **EV batteries, SLI batteries, and
9//! industrial batteries with a capacity > 2 kWh** (excluding those with
10//! exclusively external storage). **LMT batteries** join only in Phase 2 (from
11//! **18 Aug 2036**), at the higher targets. Portable batteries are out of scope.
12//!
13//! The targets are **finalized law** — they are in the regulation text itself,
14//! not in a pending delegated act. However, neither phase is yet in force, so
15//! the battery plugin reports an overall status of `NotAssessed` and surfaces
16//! any shortfall as an advisory warning rather than a violation. These constants
17//! are the single source of truth it will check against once enforcement begins.
18//!
19//! ## The measurement basis differs by metal
20//!
21//! Art. 8(2)/(3) measure cobalt, lithium and nickel as the share recovered from
22//! waste that is present **in active materials**; lead is measured as the share
23//! present **in the battery**. The percentages below therefore do not share a
24//! denominator, and a caller must not sum or average them across metals.
25//!
26//! ## Two duties, not one
27//!
28//! Art. 8(1) requires documentation of the *actual* shares, with no minimum,
29//! from **18 Aug 2028** (industrial > 2 kWh, EV, SLI) and **18 Aug 2033** (LMT)
30//! — or 24 months after the Art. 8(1) methodology delegated act enters into
31//! force, whichever is later. Annex XIII point 1(e) makes that documentation
32//! publicly accessible passport content. See [`art8_declaration_duty_for`].
33//!
34//! Art. 8(2) and 8(3) then impose *minimum* shares from 18 Aug 2031 and
35//! 18 Aug 2036 — fixed dates, unconditional. See [`art8_phase_for`].
36//!
37//! The two ladders are independent: a battery can owe the declaration without
38//! yet owing a minimum.
39//!
40//! ## Which phase binds is a function of the placing-on-market date
41//!
42//! [`art8_category_for`] maps a declared battery type and capacity onto an
43//! [`Art8Category`]; [`art8_phase_for`] then picks the governing phase from the
44//! date the battery was placed on the EU market. Assessment date is never an
45//! input — Art. 8 attaches its duties at placing on the market, so deriving the
46//! phase from "today" would report batteries lawfully placed before a phase
47//! began as short of it from the moment that phase starts.
48//!
49//! The battery plugin calls both, and reports a missing or unparseable
50//! `placedOnMarketDate` as its own finding rather than assuming one.
51//!
52//! ## Phase 1 — EV + SLI + industrial > 2 kWh, from **18 Aug 2031** (Art. 8(2))
53//! | Material | Minimum % | Basis |
54//! |----------|-----------|-------|
55//! | Cobalt | 16 % | active materials |
56//! | Lead | 85 % | the battery |
57//! | Lithium | 6 % | active materials |
58//! | Nickel | 6 % | active materials |
59//!
60//! ## Phase 2 — Phase 1 categories + **LMT**, from **18 Aug 2036** (Art. 8(3))
61//! | Material | Minimum % | Basis |
62//! |----------|-----------|-------|
63//! | Cobalt | 26 % | active materials |
64//! | Lead | 85 % | the battery |
65//! | Lithium | 12 % | active materials |
66//! | Nickel | 15 % | active materials |
67
68use alloc::vec::Vec;
69
70use crate::common::date::CalendarDate;
71
72// ✅ COMPLIANCE-PIN: EU 2023/1542, Art. 8(2) and 8(3) (OJ L 191, 28.7.2023, p. 33)
73// Verified verbatim against the Official Journal text on 2026-07-25. Percentages,
74// dates and category scope were read directly from Art. 8(2) and 8(3); the prior
75// 🟠 residual (verbatim OJ confirmation) is now closed.
76// Phase-1 date: 18 Aug 2031. Phase-2 date: 18 Aug 2036.
77// Category scope: Phase 1 = industrial > 2 kWh (excl. exclusively-external-storage)
78// + EV + SLI. Phase 2 adds LMT. SLI is **in** Phase-1 scope.
79// Cross-reference is **Annex VIII** (technical documentation), named explicitly by
80// both paragraphs. A prior pin here cited "Annex X"; that annex is "LIST OF RAW
81// MATERIALS AND RISK CATEGORIES" (due diligence, Arts. 48–53) and is unrelated to
82// recycled content. Corrected 2026-07-25.
83
84// ── Phase 1 constants — industrial > 2 kWh + EV + SLI, from 18 Aug 2031 ──────
85
86/// Minimum cobalt recycled content — Art. 8(2), from 18 Aug 2031.
87pub const COBALT_RECYCLED_PCT_2031: f64 = 16.0;
88/// Minimum lead recycled content — Art. 8(2), from 18 Aug 2031.
89pub const LEAD_RECYCLED_PCT_2031: f64 = 85.0;
90/// Minimum lithium recycled content — Art. 8(2), from 18 Aug 2031.
91pub const LITHIUM_RECYCLED_PCT_2031: f64 = 6.0;
92/// Minimum nickel recycled content — Art. 8(2), from 18 Aug 2031.
93pub const NICKEL_RECYCLED_PCT_2031: f64 = 6.0;
94
95// ── Phase 2 constants — Phase 1 categories + LMT, from 18 Aug 2036 ───────────
96
97/// Minimum cobalt recycled content — Art. 8(3), from 18 Aug 2036.
98pub const COBALT_RECYCLED_PCT_2036: f64 = 26.0;
99/// Minimum lead recycled content — Art. 8(3), from 18 Aug 2036.
100pub const LEAD_RECYCLED_PCT_2036: f64 = 85.0;
101/// Minimum lithium recycled content — Art. 8(3), from 18 Aug 2036.
102pub const LITHIUM_RECYCLED_PCT_2036: f64 = 12.0;
103/// Minimum nickel recycled content — Art. 8(3), from 18 Aug 2036.
104pub const NICKEL_RECYCLED_PCT_2036: f64 = 15.0;
105
106// ── Which phase binds ─────────────────────────────────────────────────────────
107
108/// First day on which the Art. 8(2) minimum shares bind a battery placed on the
109/// EU market — 18 August 2031.
110pub const ART8_PHASE1_FROM: CalendarDate = CalendarDate::new(2031, 8, 18);
111
112/// First day on which the Art. 8(3) minimum shares bind a battery placed on the
113/// EU market — 18 August 2036.
114pub const ART8_PHASE2_FROM: CalendarDate = CalendarDate::new(2036, 8, 18);
115
116/// The battery categories Art. 8(2) and 8(3) treat differently.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Art8Category {
119 /// Industrial batteries > 2 kWh (excluding those with exclusively external
120 /// storage), electric-vehicle batteries, and SLI batteries. Named by both
121 /// Art. 8(2) and Art. 8(3).
122 IndustrialEvSli,
123 /// LMT batteries. Named only by Art. 8(3) — outside Phase 1 entirely.
124 Lmt,
125 /// Portable batteries, and anything else Art. 8 does not reach.
126 NotCovered,
127}
128
129/// Which Art. 8 minimum-share phase binds a battery.
130///
131/// Four outcomes, deliberately not collapsed into `Option`: "Art. 8 does not
132/// reach this battery" and "Art. 8 does not reach this battery *yet*" are
133/// different answers, and reporting either as a shortfall misstates an
134/// operator's legal position.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum Art8Phase {
137 /// The minimum shares never bind this category.
138 NotCovered,
139 /// In scope, but placed on the market before the phase began.
140 NotYetBinding,
141 /// Art. 8(2) minimums apply — the `*_2031` constants.
142 Phase1,
143 /// Art. 8(3) minimums apply — the `*_2036` constants.
144 Phase2,
145}
146
147/// Select the governing Art. 8 phase from the date the battery was **placed on
148/// the EU market** — not the date of assessment.
149///
150/// Art. 8(2) and 8(3) attach their obligations to batteries placed on the market
151/// from their respective dates. A battery lawfully placed on the market in 2030
152/// does not acquire the 2031 minimums by being assessed in 2033. Passing
153/// "today" here instead of the placing-on-market date produces retroactive
154/// non-compliance findings from 18 Aug 2031 onwards.
155#[must_use]
156pub fn art8_phase_for(category: Art8Category, placed_on_market: CalendarDate) -> Art8Phase {
157 match category {
158 Art8Category::NotCovered => Art8Phase::NotCovered,
159 // Art. 8(2) does not name LMT; only Art. 8(3) does.
160 Art8Category::Lmt => {
161 if placed_on_market >= ART8_PHASE2_FROM {
162 Art8Phase::Phase2
163 } else {
164 Art8Phase::NotYetBinding
165 }
166 }
167 Art8Category::IndustrialEvSli => {
168 if placed_on_market >= ART8_PHASE2_FROM {
169 Art8Phase::Phase2
170 } else if placed_on_market >= ART8_PHASE1_FROM {
171 Art8Phase::Phase1
172 } else {
173 Art8Phase::NotYetBinding
174 }
175 }
176 }
177}
178
179/// Map a declared battery type and energy capacity onto the Art. 8 category.
180///
181/// Matching is case-insensitive. Art. 8(2)/(3) reach industrial batteries only
182/// above 2 kWh, so an industrial battery at or below that threshold — or with an
183/// undeclared capacity — is [`Art8Category::NotCovered`].
184///
185/// An unrecognised or absent `battery_type` maps to
186/// [`Art8Category::IndustrialEvSli`]. That is the conservative direction: a
187/// mislabelled in-scope battery is still assessed rather than silently skipped.
188#[must_use]
189pub fn art8_category_for(battery_type: &str, capacity_kwh: Option<f64>) -> Art8Category {
190 let t = battery_type.trim();
191 let eq = |s: &str| t.eq_ignore_ascii_case(s);
192 if eq("portable") {
193 Art8Category::NotCovered
194 } else if eq("lmt") {
195 Art8Category::Lmt
196 } else if eq("industrial") {
197 if capacity_kwh.is_some_and(|k| k.is_finite() && k > 2.0) {
198 Art8Category::IndustrialEvSli
199 } else {
200 Art8Category::NotCovered
201 }
202 } else {
203 // ev, sli / starting-lighting-ignition, and anything unrecognised.
204 Art8Category::IndustrialEvSli
205 }
206}
207
208// ── Input type ────────────────────────────────────────────────────────────────
209
210/// Declared recycled content percentages for the four regulated metals.
211///
212/// `None` means the metal is absent or undeclared — it is skipped in target
213/// checks. Only declared values can fail a target check.
214#[derive(Debug, Clone, Copy)]
215pub struct RecycledContentInput {
216 pub cobalt_pct: Option<f64>,
217 pub lithium_pct: Option<f64>,
218 pub nickel_pct: Option<f64>,
219 pub lead_pct: Option<f64>,
220}
221
222/// A recycled-content shortfall for a single material.
223#[derive(Debug, Clone, Copy)]
224pub struct RecycledContentShortfall {
225 pub material: &'static str,
226 pub declared_pct: f64,
227 pub required_pct: f64,
228}
229
230// ── Phase-check functions ─────────────────────────────────────────────────────
231
232/// Check declared recycled content against Art. 8(2) Phase 1 targets (from 2031).
233///
234/// Returns every material whose declared percentage falls below the Phase 1
235/// minimum. An empty `Vec` means all declared metals pass. Undeclared metals
236/// are not checked — battery-type scoping (Phase 1: EV / SLI / industrial
237/// > 2 kWh; LMT only from Phase 2) is the caller's responsibility.
238#[must_use]
239pub fn art8_shortfalls_2031(input: &RecycledContentInput) -> Vec<RecycledContentShortfall> {
240 check_targets(
241 input,
242 COBALT_RECYCLED_PCT_2031,
243 LEAD_RECYCLED_PCT_2031,
244 LITHIUM_RECYCLED_PCT_2031,
245 NICKEL_RECYCLED_PCT_2031,
246 )
247}
248
249/// Check declared recycled content against Art. 8(3) Phase 2 targets (from 2036).
250#[must_use]
251pub fn art8_shortfalls_2036(input: &RecycledContentInput) -> Vec<RecycledContentShortfall> {
252 check_targets(
253 input,
254 COBALT_RECYCLED_PCT_2036,
255 LEAD_RECYCLED_PCT_2036,
256 LITHIUM_RECYCLED_PCT_2036,
257 NICKEL_RECYCLED_PCT_2036,
258 )
259}
260
261fn check_targets(
262 input: &RecycledContentInput,
263 cobalt_req: f64,
264 lead_req: f64,
265 lithium_req: f64,
266 nickel_req: f64,
267) -> Vec<RecycledContentShortfall> {
268 let mut out = Vec::new();
269 if let Some(pct) = input.cobalt_pct {
270 // Non-finite (NaN/Inf) cannot demonstrate compliance — treat as shortfall.
271 if !pct.is_finite() || pct < cobalt_req {
272 out.push(RecycledContentShortfall {
273 material: "cobalt",
274 declared_pct: pct,
275 required_pct: cobalt_req,
276 });
277 }
278 }
279 if let Some(pct) = input.lead_pct
280 && (!pct.is_finite() || pct < lead_req)
281 {
282 out.push(RecycledContentShortfall {
283 material: "lead",
284 declared_pct: pct,
285 required_pct: lead_req,
286 });
287 }
288 if let Some(pct) = input.lithium_pct
289 && (!pct.is_finite() || pct < lithium_req)
290 {
291 out.push(RecycledContentShortfall {
292 material: "lithium",
293 declared_pct: pct,
294 required_pct: lithium_req,
295 });
296 }
297 if let Some(pct) = input.nickel_pct
298 && (!pct.is_finite() || pct < nickel_req)
299 {
300 out.push(RecycledContentShortfall {
301 material: "nickel",
302 declared_pct: pct,
303 required_pct: nickel_req,
304 });
305 }
306 out
307}
308
309// ── Chemistry → regulated-metal applicability ──────────────────────────────────
310
311/// The Art. 8 regulated metals (cobalt, lithium, nickel, lead) that are
312/// *meaningfully present* for a given battery chemistry.
313///
314/// Used to scope recycled-content checks so a chemistry that does not contain a
315/// metal is never flagged for that metal's "shortfall" — e.g. an LFP cell
316/// (LiFePO₄, no cobalt or nickel) must not produce a cobalt shortfall just
317/// because the field defaulted to `0.0`.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub struct RegulatedMetals {
320 pub cobalt: bool,
321 pub lithium: bool,
322 pub nickel: bool,
323 pub lead: bool,
324}
325
326/// Map a battery chemistry code (e.g. `"LFP"`, `"NMC"`, `"lead-acid"`) to the
327/// Art. 8 regulated metals it contains.
328///
329/// Matching is case-insensitive. Unknown chemistries return **all `true`**
330/// (conservative: every declared value is checked, since we cannot rule a metal
331/// out). The caller still skips any metal whose declared percentage is absent.
332#[must_use]
333pub fn chemistry_regulated_metals(chemistry: &str) -> RegulatedMetals {
334 let c = chemistry.trim();
335 let eq = |s: &str| c.eq_ignore_ascii_case(s);
336 if eq("LFP") {
337 RegulatedMetals {
338 cobalt: false,
339 lithium: true,
340 nickel: false,
341 lead: false,
342 }
343 } else if eq("NMC") || eq("NCA") {
344 RegulatedMetals {
345 cobalt: true,
346 lithium: true,
347 nickel: true,
348 lead: false,
349 }
350 } else if eq("LCO") {
351 RegulatedMetals {
352 cobalt: true,
353 lithium: true,
354 nickel: false,
355 lead: false,
356 }
357 } else if eq("NiMH") || eq("NiCd") {
358 RegulatedMetals {
359 cobalt: false,
360 lithium: false,
361 nickel: true,
362 lead: false,
363 }
364 } else if eq("lead-acid") {
365 RegulatedMetals {
366 cobalt: false,
367 lithium: false,
368 nickel: false,
369 lead: true,
370 }
371 } else if eq("solid-state") {
372 RegulatedMetals {
373 cobalt: false,
374 lithium: true,
375 nickel: false,
376 lead: false,
377 }
378 } else {
379 // Unknown chemistry — cannot exclude any metal; check whatever is declared.
380 RegulatedMetals {
381 cobalt: true,
382 lithium: true,
383 nickel: true,
384 lead: true,
385 }
386 }
387}
388
389/// Metals whose recycled content is declared **> 0** but which the chemistry
390/// does **not** contain — a data-integrity contradiction (e.g. cobalt recycled
391/// content on an LFP cell, which has no cobalt).
392///
393/// A declared `0.0` is *not* a conflict (it states "no recycled content", which
394/// is trivially true for an absent metal). Unknown chemistries contain every
395/// metal per [`chemistry_regulated_metals`], so they never conflict.
396#[must_use]
397pub fn recycled_content_chemistry_conflicts(
398 chemistry: &str,
399 cobalt_pct: Option<f64>,
400 lithium_pct: Option<f64>,
401 nickel_pct: Option<f64>,
402 lead_pct: Option<f64>,
403) -> Vec<&'static str> {
404 let reg = chemistry_regulated_metals(chemistry);
405 let positive = |v: Option<f64>| matches!(v, Some(x) if x.is_finite() && x > 0.0);
406 let mut out = Vec::new();
407 if positive(cobalt_pct) && !reg.cobalt {
408 out.push("cobalt");
409 }
410 if positive(lithium_pct) && !reg.lithium {
411 out.push("lithium");
412 }
413 if positive(nickel_pct) && !reg.nickel {
414 out.push("nickel");
415 }
416 if positive(lead_pct) && !reg.lead {
417 out.push("lead");
418 }
419 out
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn all_metals(co: f64, pb: f64, li: f64, ni: f64) -> RecycledContentInput {
427 RecycledContentInput {
428 cobalt_pct: Some(co),
429 lead_pct: Some(pb),
430 lithium_pct: Some(li),
431 nickel_pct: Some(ni),
432 }
433 }
434
435 // ── Art. 8 phase selection — golden vectors ──────────────────────────────
436
437 #[test]
438 fn battery_placed_before_phase1_stays_unbound_however_late_it_is_assessed() {
439 // A battery lawfully placed on the EU market on 1 Jun 2030, assessed in
440 // 2033. Art. 8(2) attaches at placing on the market, so the 2031 minimums
441 // never reach it. Deriving the phase from the assessment date instead
442 // would report this battery as non-compliant from 18 Aug 2031 onwards.
443 let placed = CalendarDate::new(2030, 6, 1);
444 assert_eq!(
445 art8_phase_for(Art8Category::IndustrialEvSli, placed),
446 Art8Phase::NotYetBinding
447 );
448 }
449
450 #[test]
451 fn phase1_boundary_is_inclusive_of_18_august_2031() {
452 assert_eq!(
453 art8_phase_for(
454 Art8Category::IndustrialEvSli,
455 CalendarDate::new(2031, 8, 17)
456 ),
457 Art8Phase::NotYetBinding
458 );
459 assert_eq!(
460 art8_phase_for(
461 Art8Category::IndustrialEvSli,
462 CalendarDate::new(2031, 8, 18)
463 ),
464 Art8Phase::Phase1
465 );
466 }
467
468 #[test]
469 fn phase2_boundary_is_inclusive_of_18_august_2036() {
470 assert_eq!(
471 art8_phase_for(
472 Art8Category::IndustrialEvSli,
473 CalendarDate::new(2036, 8, 17)
474 ),
475 Art8Phase::Phase1
476 );
477 assert_eq!(
478 art8_phase_for(
479 Art8Category::IndustrialEvSli,
480 CalendarDate::new(2036, 8, 18)
481 ),
482 Art8Phase::Phase2
483 );
484 }
485
486 #[test]
487 fn lmt_is_outside_phase1_entirely() {
488 // Art. 8(2) does not name LMT batteries; Art. 8(3) does. An LMT battery
489 // placed on the market the very day Phase 1 begins is still unbound.
490 assert_eq!(
491 art8_phase_for(Art8Category::Lmt, CalendarDate::new(2031, 8, 18)),
492 Art8Phase::NotYetBinding
493 );
494 assert_eq!(
495 art8_phase_for(Art8Category::Lmt, CalendarDate::new(2036, 8, 18)),
496 Art8Phase::Phase2
497 );
498 }
499
500 #[test]
501 fn out_of_scope_categories_are_never_bound() {
502 // Portable batteries, at any date, including well past Phase 2.
503 assert_eq!(
504 art8_phase_for(Art8Category::NotCovered, CalendarDate::new(2040, 1, 1)),
505 Art8Phase::NotCovered
506 );
507 }
508
509 #[test]
510 fn not_covered_and_not_yet_binding_are_distinguishable() {
511 // The whole point of a four-outcome enum: an operator told "not covered"
512 // and one told "not yet" have different obligations, and neither has a
513 // shortfall.
514 let portable = art8_phase_for(Art8Category::NotCovered, CalendarDate::new(2033, 1, 1));
515 let early_ev = art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2030, 1, 1));
516 assert_ne!(portable, early_ev);
517 }
518
519 #[test]
520 fn selected_phase_agrees_with_the_target_constants() {
521 // 16 % cobalt clears Phase 1 and fails Phase 2. The phase selector and
522 // the constants it selects between must not disagree about which set
523 // is in force on a given placing-on-market date.
524 let input = all_metals(16.0, 85.0, 6.0, 6.0);
525
526 assert_eq!(
527 art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2032, 1, 1)),
528 Art8Phase::Phase1
529 );
530 assert!(art8_shortfalls_2031(&input).is_empty());
531
532 assert_eq!(
533 art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2037, 1, 1)),
534 Art8Phase::Phase2
535 );
536 assert!(
537 art8_shortfalls_2036(&input)
538 .iter()
539 .any(|s| s.material == "cobalt")
540 );
541 }
542
543 // ── Category mapping ─────────────────────────────────────────────────────
544
545 #[test]
546 fn portable_is_not_covered_and_lmt_is_its_own_category() {
547 assert_eq!(
548 art8_category_for("portable", None),
549 Art8Category::NotCovered
550 );
551 assert_eq!(
552 art8_category_for("PORTABLE", None),
553 Art8Category::NotCovered
554 );
555 assert_eq!(art8_category_for("lmt", None), Art8Category::Lmt);
556 assert_eq!(art8_category_for(" LMT ", None), Art8Category::Lmt);
557 }
558
559 #[test]
560 fn industrial_is_covered_only_above_two_kwh() {
561 assert_eq!(
562 art8_category_for("industrial", Some(2.5)),
563 Art8Category::IndustrialEvSli
564 );
565 // Art. 8 says "greater than 2 kWh" — exactly 2 kWh is out.
566 assert_eq!(
567 art8_category_for("industrial", Some(2.0)),
568 Art8Category::NotCovered
569 );
570 assert_eq!(
571 art8_category_for("industrial", None),
572 Art8Category::NotCovered
573 );
574 // A non-finite capacity cannot demonstrate the threshold is met.
575 assert_eq!(
576 art8_category_for("industrial", Some(f64::NAN)),
577 Art8Category::NotCovered
578 );
579 }
580
581 #[test]
582 fn ev_sli_and_unknown_types_are_assessed() {
583 for t in ["ev", "sli", "starting-lighting-ignition", "", "mystery"] {
584 assert_eq!(
585 art8_category_for(t, None),
586 Art8Category::IndustrialEvSli,
587 "type {t:?} should be assessed"
588 );
589 }
590 }
591
592 // ── Art. 8(1) declaration duty ───────────────────────────────────────────
593
594 #[test]
595 fn declaration_is_certainly_not_due_before_its_floor() {
596 // The one thing that *is* knowable while the delegated act is
597 // unadopted: the real date is never earlier than the floor, so anything
598 // placed on the market before it definitely owes nothing yet.
599 assert_eq!(
600 art8_declaration_duty_for(
601 Art8Category::IndustrialEvSli,
602 CalendarDate::new(2028, 8, 17)
603 ),
604 Art8DeclarationDuty::NotYetDue {
605 not_before: CalendarDate::new(2028, 8, 18)
606 }
607 );
608 }
609
610 #[test]
611 fn declaration_is_undetermined_on_and_after_the_floor() {
612 // Art. 8(1) applies from the floor "or 24 months after the date of entry
613 // into force of the delegated act, whichever is the latest". The act is
614 // unadopted, so on/after the floor the answer is genuinely unknown —
615 // reporting it as "due" would assert a date the regulation does not set.
616 let got =
617 art8_declaration_duty_for(Art8Category::IndustrialEvSli, CalendarDate::new(2029, 1, 1));
618 let Art8DeclarationDuty::Undetermined {
619 not_before,
620 empowerment,
621 } = got
622 else {
623 panic!("expected Undetermined, got {got:?}");
624 };
625 assert_eq!(not_before, CalendarDate::new(2028, 8, 18));
626 assert!(empowerment.contains("Art. 8(1)"));
627 }
628
629 #[test]
630 fn lmt_declaration_floor_is_five_years_later() {
631 // Art. 8(1) second subparagraph: LMT from 18 Aug 2033, not 2028.
632 assert_eq!(
633 art8_declaration_duty_for(Art8Category::Lmt, CalendarDate::new(2030, 1, 1)),
634 Art8DeclarationDuty::NotYetDue {
635 not_before: CalendarDate::new(2033, 8, 18)
636 }
637 );
638 assert!(matches!(
639 art8_declaration_duty_for(Art8Category::Lmt, CalendarDate::new(2034, 1, 1)),
640 Art8DeclarationDuty::Undetermined { .. }
641 ));
642 }
643
644 #[test]
645 fn out_of_scope_categories_owe_no_declaration() {
646 assert_eq!(
647 art8_declaration_duty_for(Art8Category::NotCovered, CalendarDate::new(2040, 1, 1)),
648 Art8DeclarationDuty::NotCovered
649 );
650 }
651
652 #[test]
653 fn the_declaration_and_minimum_ladders_are_independent() {
654 // A battery placed on the market in 2029 is past the declaration floor
655 // but years short of the Art. 8(2) minimums. Conflating the two would
656 // report a shortfall against a duty that does not yet exist.
657 let placed = CalendarDate::new(2029, 1, 1);
658 assert!(matches!(
659 art8_declaration_duty_for(Art8Category::IndustrialEvSli, placed),
660 Art8DeclarationDuty::Undetermined { .. }
661 ));
662 assert_eq!(
663 art8_phase_for(Art8Category::IndustrialEvSli, placed),
664 Art8Phase::NotYetBinding
665 );
666 }
667
668 // ── Target checks ────────────────────────────────────────────────────────
669
670 #[test]
671 fn exactly_at_2031_targets_passes() {
672 let input = all_metals(16.0, 85.0, 6.0, 6.0);
673 assert!(art8_shortfalls_2031(&input).is_empty());
674 }
675
676 #[test]
677 fn above_2031_targets_passes() {
678 let input = all_metals(20.0, 90.0, 10.0, 10.0);
679 assert!(art8_shortfalls_2031(&input).is_empty());
680 }
681
682 #[test]
683 fn below_2031_cobalt_flagged() {
684 let input = all_metals(15.0, 85.0, 6.0, 6.0); // cobalt 15 < 16
685 let shortfalls = art8_shortfalls_2031(&input);
686 assert_eq!(shortfalls.len(), 1);
687 assert_eq!(shortfalls[0].material, "cobalt");
688 assert_eq!(shortfalls[0].required_pct, 16.0);
689 }
690
691 #[test]
692 fn multiple_shortfalls_all_returned() {
693 let input = all_metals(10.0, 80.0, 3.0, 4.0); // all below
694 assert_eq!(art8_shortfalls_2031(&input).len(), 4);
695 }
696
697 #[test]
698 fn undeclared_metals_not_flagged() {
699 let input = RecycledContentInput {
700 cobalt_pct: Some(20.0),
701 lead_pct: None,
702 lithium_pct: None,
703 nickel_pct: None,
704 };
705 assert!(art8_shortfalls_2031(&input).is_empty());
706 }
707
708 #[test]
709 fn phase2_stricter_than_phase1() {
710 // 16% cobalt passes 2031 but fails 2036 (target 26%)
711 let input = all_metals(16.0, 85.0, 6.0, 6.0);
712 assert!(art8_shortfalls_2031(&input).is_empty());
713 let shortfalls = art8_shortfalls_2036(&input);
714 assert!(shortfalls.iter().any(|s| s.material == "cobalt"));
715 }
716
717 #[test]
718 fn nan_cobalt_treated_as_shortfall() {
719 let input = RecycledContentInput {
720 cobalt_pct: Some(f64::NAN),
721 lead_pct: None,
722 lithium_pct: None,
723 nickel_pct: None,
724 };
725 let shortfalls = art8_shortfalls_2031(&input);
726 assert_eq!(shortfalls.len(), 1);
727 assert_eq!(shortfalls[0].material, "cobalt");
728 }
729
730 #[test]
731 fn infinity_cobalt_treated_as_shortfall() {
732 let input = RecycledContentInput {
733 cobalt_pct: Some(f64::INFINITY),
734 lead_pct: None,
735 lithium_pct: None,
736 nickel_pct: None,
737 };
738 let shortfalls = art8_shortfalls_2031(&input);
739 assert_eq!(shortfalls.len(), 1);
740 assert_eq!(shortfalls[0].material, "cobalt");
741 }
742
743 #[test]
744 fn lfp_regulates_lithium_only() {
745 let m = chemistry_regulated_metals("LFP");
746 assert!(m.lithium);
747 assert!(!m.cobalt && !m.nickel && !m.lead);
748 // case-insensitive
749 assert_eq!(chemistry_regulated_metals("lfp"), m);
750 }
751
752 #[test]
753 fn nmc_and_nca_regulate_cobalt_lithium_nickel() {
754 for chem in ["NMC", "NCA"] {
755 let m = chemistry_regulated_metals(chem);
756 assert!(m.cobalt && m.lithium && m.nickel);
757 assert!(!m.lead);
758 }
759 }
760
761 #[test]
762 fn lead_acid_regulates_lead_only() {
763 let m = chemistry_regulated_metals("lead-acid");
764 assert!(m.lead);
765 assert!(!m.cobalt && !m.lithium && !m.nickel);
766 }
767
768 #[test]
769 fn unknown_chemistry_checks_all_metals() {
770 let m = chemistry_regulated_metals("mystery-cell");
771 assert!(m.cobalt && m.lithium && m.nickel && m.lead);
772 }
773
774 #[test]
775 fn positive_cobalt_on_lfp_is_a_conflict() {
776 let c = recycled_content_chemistry_conflicts("LFP", Some(5.0), Some(12.0), None, None);
777 assert_eq!(c.len(), 1);
778 assert_eq!(c[0], "cobalt");
779 }
780
781 #[test]
782 fn zero_cobalt_on_lfp_is_not_a_conflict() {
783 // 0.0 declares "no recycled cobalt" — trivially true for an absent metal.
784 let c = recycled_content_chemistry_conflicts("LFP", Some(0.0), Some(12.0), Some(0.0), None);
785 assert!(c.is_empty(), "got: {c:?}");
786 }
787
788 #[test]
789 fn nmc_cobalt_and_nickel_declared_no_conflict() {
790 let c = recycled_content_chemistry_conflicts("NMC", Some(16.0), Some(6.0), Some(8.0), None);
791 assert!(c.is_empty(), "got: {c:?}");
792 }
793
794 #[test]
795 fn lead_declared_on_lfp_is_a_conflict() {
796 let c = recycled_content_chemistry_conflicts("LFP", None, Some(12.0), None, Some(80.0));
797 assert_eq!(c.len(), 1);
798 assert_eq!(c[0], "lead");
799 }
800
801 #[test]
802 fn unknown_chemistry_never_conflicts() {
803 let c = recycled_content_chemistry_conflicts(
804 "mystery",
805 Some(5.0),
806 Some(5.0),
807 Some(5.0),
808 Some(5.0),
809 );
810 assert!(c.is_empty());
811 }
812}
813
814// ── Art. 8(1) — the declaration duty ─────────────────────────────────────────
815
816/// Floor date for the Art. 8(1) documentation duty for industrial batteries
817/// > 2 kWh, EV and SLI — 18 August 2028.
818pub const ART8_DECLARATION_FLOOR_2028: CalendarDate = CalendarDate::new(2028, 8, 18);
819
820/// Floor date for the Art. 8(1) documentation duty for LMT batteries —
821/// 18 August 2033.
822pub const ART8_DECLARATION_FLOOR_2033: CalendarDate = CalendarDate::new(2033, 8, 18);
823
824/// The empowerment whose entry into force can push the Art. 8(1) duty later.
825pub const ART8_DECLARATION_EMPOWERMENT: &str = "EU 2023/1542 Art. 8(1), third subparagraph — recycled content calculation, verification and documentation format";
826
827/// Whether the Art. 8(1) documentation duty has begun for a battery.
828///
829/// Unlike the Art. 8(2)/(3) minimums, whose dates are stated outright, Art. 8(1)
830/// applies from *"18 August 2028 or 24 months after the date of entry into force
831/// of the delegated act …, whichever is the latest"*. That act has not been
832/// adopted, so the real start date is unknown — but it is never **earlier** than
833/// the floor, which is enough to answer the question for anything placed on the
834/// market before it.
835#[derive(Debug, Clone, Copy, PartialEq, Eq)]
836pub enum Art8DeclarationDuty {
837 /// Art. 8(1) does not reach this category.
838 NotCovered,
839 /// Certainly not yet owed: placed on the market before the floor date, and
840 /// the real date can only be that floor or later.
841 NotYetDue { not_before: CalendarDate },
842 /// Cannot be determined. Placed on the market on or after the floor, but
843 /// whether the duty had begun depends on when the delegated act entered
844 /// into force — and it has not been adopted.
845 Undetermined {
846 not_before: CalendarDate,
847 empowerment: &'static str,
848 },
849}
850
851/// Whether a battery owes the Art. 8(1) recycled-content declaration.
852///
853/// Keyed on the date the battery was **placed on the EU market**, like every
854/// other Art. 8 duty — not on the date of assessment.
855#[must_use]
856pub fn art8_declaration_duty_for(
857 category: Art8Category,
858 placed_on_market: CalendarDate,
859) -> Art8DeclarationDuty {
860 let floor = match category {
861 Art8Category::NotCovered => return Art8DeclarationDuty::NotCovered,
862 Art8Category::IndustrialEvSli => ART8_DECLARATION_FLOOR_2028,
863 Art8Category::Lmt => ART8_DECLARATION_FLOOR_2033,
864 };
865 if placed_on_market < floor {
866 Art8DeclarationDuty::NotYetDue { not_before: floor }
867 } else {
868 Art8DeclarationDuty::Undetermined {
869 not_before: floor,
870 empowerment: ART8_DECLARATION_EMPOWERMENT,
871 }
872 }
873}