Skip to main content

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}