use alloc::vec::Vec;
use crate::common::date::CalendarDate;
pub const COBALT_RECYCLED_PCT_2031: f64 = 16.0;
pub const LEAD_RECYCLED_PCT_2031: f64 = 85.0;
pub const LITHIUM_RECYCLED_PCT_2031: f64 = 6.0;
pub const NICKEL_RECYCLED_PCT_2031: f64 = 6.0;
pub const COBALT_RECYCLED_PCT_2036: f64 = 26.0;
pub const LEAD_RECYCLED_PCT_2036: f64 = 85.0;
pub const LITHIUM_RECYCLED_PCT_2036: f64 = 12.0;
pub const NICKEL_RECYCLED_PCT_2036: f64 = 15.0;
pub const ART8_PHASE1_FROM: CalendarDate = CalendarDate::new(2031, 8, 18);
pub const ART8_PHASE2_FROM: CalendarDate = CalendarDate::new(2036, 8, 18);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Art8Category {
IndustrialEvSli,
Lmt,
NotCovered,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Art8Phase {
NotCovered,
NotYetBinding,
Phase1,
Phase2,
}
#[must_use]
pub fn art8_phase_for(category: Art8Category, placed_on_market: CalendarDate) -> Art8Phase {
match category {
Art8Category::NotCovered => Art8Phase::NotCovered,
Art8Category::Lmt => {
if placed_on_market >= ART8_PHASE2_FROM {
Art8Phase::Phase2
} else {
Art8Phase::NotYetBinding
}
}
Art8Category::IndustrialEvSli => {
if placed_on_market >= ART8_PHASE2_FROM {
Art8Phase::Phase2
} else if placed_on_market >= ART8_PHASE1_FROM {
Art8Phase::Phase1
} else {
Art8Phase::NotYetBinding
}
}
}
}
#[must_use]
pub fn art8_category_for(battery_type: &str, capacity_kwh: Option<f64>) -> Art8Category {
let t = battery_type.trim();
let eq = |s: &str| t.eq_ignore_ascii_case(s);
if eq("portable") {
Art8Category::NotCovered
} else if eq("lmt") {
Art8Category::Lmt
} else if eq("industrial") {
if capacity_kwh.is_some_and(|k| k.is_finite() && k > 2.0) {
Art8Category::IndustrialEvSli
} else {
Art8Category::NotCovered
}
} else {
Art8Category::IndustrialEvSli
}
}
#[derive(Debug, Clone, Copy)]
pub struct RecycledContentInput {
pub cobalt_pct: Option<f64>,
pub lithium_pct: Option<f64>,
pub nickel_pct: Option<f64>,
pub lead_pct: Option<f64>,
}
#[derive(Debug, Clone, Copy)]
pub struct RecycledContentShortfall {
pub material: &'static str,
pub declared_pct: f64,
pub required_pct: f64,
}
#[must_use]
pub fn art8_shortfalls_2031(input: &RecycledContentInput) -> Vec<RecycledContentShortfall> {
check_targets(
input,
COBALT_RECYCLED_PCT_2031,
LEAD_RECYCLED_PCT_2031,
LITHIUM_RECYCLED_PCT_2031,
NICKEL_RECYCLED_PCT_2031,
)
}
#[must_use]
pub fn art8_shortfalls_2036(input: &RecycledContentInput) -> Vec<RecycledContentShortfall> {
check_targets(
input,
COBALT_RECYCLED_PCT_2036,
LEAD_RECYCLED_PCT_2036,
LITHIUM_RECYCLED_PCT_2036,
NICKEL_RECYCLED_PCT_2036,
)
}
fn check_targets(
input: &RecycledContentInput,
cobalt_req: f64,
lead_req: f64,
lithium_req: f64,
nickel_req: f64,
) -> Vec<RecycledContentShortfall> {
let mut out = Vec::new();
if let Some(pct) = input.cobalt_pct {
if !pct.is_finite() || pct < cobalt_req {
out.push(RecycledContentShortfall {
material: "cobalt",
declared_pct: pct,
required_pct: cobalt_req,
});
}
}
if let Some(pct) = input.lead_pct
&& (!pct.is_finite() || pct < lead_req)
{
out.push(RecycledContentShortfall {
material: "lead",
declared_pct: pct,
required_pct: lead_req,
});
}
if let Some(pct) = input.lithium_pct
&& (!pct.is_finite() || pct < lithium_req)
{
out.push(RecycledContentShortfall {
material: "lithium",
declared_pct: pct,
required_pct: lithium_req,
});
}
if let Some(pct) = input.nickel_pct
&& (!pct.is_finite() || pct < nickel_req)
{
out.push(RecycledContentShortfall {
material: "nickel",
declared_pct: pct,
required_pct: nickel_req,
});
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RegulatedMetals {
pub cobalt: bool,
pub lithium: bool,
pub nickel: bool,
pub lead: bool,
}
#[must_use]
pub fn chemistry_regulated_metals(chemistry: &str) -> RegulatedMetals {
let c = chemistry.trim();
let eq = |s: &str| c.eq_ignore_ascii_case(s);
if eq("LFP") {
RegulatedMetals {
cobalt: false,
lithium: true,
nickel: false,
lead: false,
}
} else if eq("NMC") || eq("NCA") {
RegulatedMetals {
cobalt: true,
lithium: true,
nickel: true,
lead: false,
}
} else if eq("LCO") {
RegulatedMetals {
cobalt: true,
lithium: true,
nickel: false,
lead: false,
}
} else if eq("NiMH") || eq("NiCd") {
RegulatedMetals {
cobalt: false,
lithium: false,
nickel: true,
lead: false,
}
} else if eq("lead-acid") {
RegulatedMetals {
cobalt: false,
lithium: false,
nickel: false,
lead: true,
}
} else if eq("solid-state") {
RegulatedMetals {
cobalt: false,
lithium: true,
nickel: false,
lead: false,
}
} else {
RegulatedMetals {
cobalt: true,
lithium: true,
nickel: true,
lead: true,
}
}
}
#[must_use]
pub fn recycled_content_chemistry_conflicts(
chemistry: &str,
cobalt_pct: Option<f64>,
lithium_pct: Option<f64>,
nickel_pct: Option<f64>,
lead_pct: Option<f64>,
) -> Vec<&'static str> {
let reg = chemistry_regulated_metals(chemistry);
let positive = |v: Option<f64>| matches!(v, Some(x) if x.is_finite() && x > 0.0);
let mut out = Vec::new();
if positive(cobalt_pct) && !reg.cobalt {
out.push("cobalt");
}
if positive(lithium_pct) && !reg.lithium {
out.push("lithium");
}
if positive(nickel_pct) && !reg.nickel {
out.push("nickel");
}
if positive(lead_pct) && !reg.lead {
out.push("lead");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn all_metals(co: f64, pb: f64, li: f64, ni: f64) -> RecycledContentInput {
RecycledContentInput {
cobalt_pct: Some(co),
lead_pct: Some(pb),
lithium_pct: Some(li),
nickel_pct: Some(ni),
}
}
#[test]
fn battery_placed_before_phase1_stays_unbound_however_late_it_is_assessed() {
let placed = CalendarDate::new(2030, 6, 1);
assert_eq!(
art8_phase_for(Art8Category::IndustrialEvSli, placed),
Art8Phase::NotYetBinding
);
}
#[test]
fn phase1_boundary_is_inclusive_of_18_august_2031() {
assert_eq!(
art8_phase_for(
Art8Category::IndustrialEvSli,
CalendarDate::new(2031, 8, 17)
),
Art8Phase::NotYetBinding
);
assert_eq!(
art8_phase_for(
Art8Category::IndustrialEvSli,
CalendarDate::new(2031, 8, 18)
),
Art8Phase::Phase1
);
}
#[test]
fn phase2_boundary_is_inclusive_of_18_august_2036() {
assert_eq!(
art8_phase_for(
Art8Category::IndustrialEvSli,
CalendarDate::new(2036, 8, 17)
),
Art8Phase::Phase1
);
assert_eq!(
art8_phase_for(
Art8Category::IndustrialEvSli,
CalendarDate::new(2036, 8, 18)
),
Art8Phase::Phase2
);
}
#[test]
fn lmt_is_outside_phase1_entirely() {
assert_eq!(
art8_phase_for(Art8Category::Lmt, CalendarDate::new(2031, 8, 18)),
Art8Phase::NotYetBinding
);
assert_eq!(
art8_phase_for(Art8Category::Lmt, CalendarDate::new(2036, 8, 18)),
Art8Phase::Phase2
);
}
#[test]
fn out_of_scope_categories_are_never_bound() {
assert_eq!(
art8_phase_for(Art8Category::NotCovered, CalendarDate::new(2040, 1, 1)),
Art8Phase::NotCovered
);
}
#[test]
fn not_covered_and_not_yet_binding_are_distinguishable() {
let portable = art8_phase_for(Art8Category::NotCovered, CalendarDate::new(2033, 1, 1));
let early_ev = art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2030, 1, 1));
assert_ne!(portable, early_ev);
}
#[test]
fn selected_phase_agrees_with_the_target_constants() {
let input = all_metals(16.0, 85.0, 6.0, 6.0);
assert_eq!(
art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2032, 1, 1)),
Art8Phase::Phase1
);
assert!(art8_shortfalls_2031(&input).is_empty());
assert_eq!(
art8_phase_for(Art8Category::IndustrialEvSli, CalendarDate::new(2037, 1, 1)),
Art8Phase::Phase2
);
assert!(
art8_shortfalls_2036(&input)
.iter()
.any(|s| s.material == "cobalt")
);
}
#[test]
fn portable_is_not_covered_and_lmt_is_its_own_category() {
assert_eq!(
art8_category_for("portable", None),
Art8Category::NotCovered
);
assert_eq!(
art8_category_for("PORTABLE", None),
Art8Category::NotCovered
);
assert_eq!(art8_category_for("lmt", None), Art8Category::Lmt);
assert_eq!(art8_category_for(" LMT ", None), Art8Category::Lmt);
}
#[test]
fn industrial_is_covered_only_above_two_kwh() {
assert_eq!(
art8_category_for("industrial", Some(2.5)),
Art8Category::IndustrialEvSli
);
assert_eq!(
art8_category_for("industrial", Some(2.0)),
Art8Category::NotCovered
);
assert_eq!(
art8_category_for("industrial", None),
Art8Category::NotCovered
);
assert_eq!(
art8_category_for("industrial", Some(f64::NAN)),
Art8Category::NotCovered
);
}
#[test]
fn ev_sli_and_unknown_types_are_assessed() {
for t in ["ev", "sli", "starting-lighting-ignition", "", "mystery"] {
assert_eq!(
art8_category_for(t, None),
Art8Category::IndustrialEvSli,
"type {t:?} should be assessed"
);
}
}
#[test]
fn declaration_is_certainly_not_due_before_its_floor() {
assert_eq!(
art8_declaration_duty_for(
Art8Category::IndustrialEvSli,
CalendarDate::new(2028, 8, 17)
),
Art8DeclarationDuty::NotYetDue {
not_before: CalendarDate::new(2028, 8, 18)
}
);
}
#[test]
fn declaration_is_undetermined_on_and_after_the_floor() {
let got =
art8_declaration_duty_for(Art8Category::IndustrialEvSli, CalendarDate::new(2029, 1, 1));
let Art8DeclarationDuty::Undetermined {
not_before,
empowerment,
} = got
else {
panic!("expected Undetermined, got {got:?}");
};
assert_eq!(not_before, CalendarDate::new(2028, 8, 18));
assert!(empowerment.contains("Art. 8(1)"));
}
#[test]
fn lmt_declaration_floor_is_five_years_later() {
assert_eq!(
art8_declaration_duty_for(Art8Category::Lmt, CalendarDate::new(2030, 1, 1)),
Art8DeclarationDuty::NotYetDue {
not_before: CalendarDate::new(2033, 8, 18)
}
);
assert!(matches!(
art8_declaration_duty_for(Art8Category::Lmt, CalendarDate::new(2034, 1, 1)),
Art8DeclarationDuty::Undetermined { .. }
));
}
#[test]
fn out_of_scope_categories_owe_no_declaration() {
assert_eq!(
art8_declaration_duty_for(Art8Category::NotCovered, CalendarDate::new(2040, 1, 1)),
Art8DeclarationDuty::NotCovered
);
}
#[test]
fn the_declaration_and_minimum_ladders_are_independent() {
let placed = CalendarDate::new(2029, 1, 1);
assert!(matches!(
art8_declaration_duty_for(Art8Category::IndustrialEvSli, placed),
Art8DeclarationDuty::Undetermined { .. }
));
assert_eq!(
art8_phase_for(Art8Category::IndustrialEvSli, placed),
Art8Phase::NotYetBinding
);
}
#[test]
fn exactly_at_2031_targets_passes() {
let input = all_metals(16.0, 85.0, 6.0, 6.0);
assert!(art8_shortfalls_2031(&input).is_empty());
}
#[test]
fn above_2031_targets_passes() {
let input = all_metals(20.0, 90.0, 10.0, 10.0);
assert!(art8_shortfalls_2031(&input).is_empty());
}
#[test]
fn below_2031_cobalt_flagged() {
let input = all_metals(15.0, 85.0, 6.0, 6.0); let shortfalls = art8_shortfalls_2031(&input);
assert_eq!(shortfalls.len(), 1);
assert_eq!(shortfalls[0].material, "cobalt");
assert_eq!(shortfalls[0].required_pct, 16.0);
}
#[test]
fn multiple_shortfalls_all_returned() {
let input = all_metals(10.0, 80.0, 3.0, 4.0); assert_eq!(art8_shortfalls_2031(&input).len(), 4);
}
#[test]
fn undeclared_metals_not_flagged() {
let input = RecycledContentInput {
cobalt_pct: Some(20.0),
lead_pct: None,
lithium_pct: None,
nickel_pct: None,
};
assert!(art8_shortfalls_2031(&input).is_empty());
}
#[test]
fn phase2_stricter_than_phase1() {
let input = all_metals(16.0, 85.0, 6.0, 6.0);
assert!(art8_shortfalls_2031(&input).is_empty());
let shortfalls = art8_shortfalls_2036(&input);
assert!(shortfalls.iter().any(|s| s.material == "cobalt"));
}
#[test]
fn nan_cobalt_treated_as_shortfall() {
let input = RecycledContentInput {
cobalt_pct: Some(f64::NAN),
lead_pct: None,
lithium_pct: None,
nickel_pct: None,
};
let shortfalls = art8_shortfalls_2031(&input);
assert_eq!(shortfalls.len(), 1);
assert_eq!(shortfalls[0].material, "cobalt");
}
#[test]
fn infinity_cobalt_treated_as_shortfall() {
let input = RecycledContentInput {
cobalt_pct: Some(f64::INFINITY),
lead_pct: None,
lithium_pct: None,
nickel_pct: None,
};
let shortfalls = art8_shortfalls_2031(&input);
assert_eq!(shortfalls.len(), 1);
assert_eq!(shortfalls[0].material, "cobalt");
}
#[test]
fn lfp_regulates_lithium_only() {
let m = chemistry_regulated_metals("LFP");
assert!(m.lithium);
assert!(!m.cobalt && !m.nickel && !m.lead);
assert_eq!(chemistry_regulated_metals("lfp"), m);
}
#[test]
fn nmc_and_nca_regulate_cobalt_lithium_nickel() {
for chem in ["NMC", "NCA"] {
let m = chemistry_regulated_metals(chem);
assert!(m.cobalt && m.lithium && m.nickel);
assert!(!m.lead);
}
}
#[test]
fn lead_acid_regulates_lead_only() {
let m = chemistry_regulated_metals("lead-acid");
assert!(m.lead);
assert!(!m.cobalt && !m.lithium && !m.nickel);
}
#[test]
fn unknown_chemistry_checks_all_metals() {
let m = chemistry_regulated_metals("mystery-cell");
assert!(m.cobalt && m.lithium && m.nickel && m.lead);
}
#[test]
fn positive_cobalt_on_lfp_is_a_conflict() {
let c = recycled_content_chemistry_conflicts("LFP", Some(5.0), Some(12.0), None, None);
assert_eq!(c.len(), 1);
assert_eq!(c[0], "cobalt");
}
#[test]
fn zero_cobalt_on_lfp_is_not_a_conflict() {
let c = recycled_content_chemistry_conflicts("LFP", Some(0.0), Some(12.0), Some(0.0), None);
assert!(c.is_empty(), "got: {c:?}");
}
#[test]
fn nmc_cobalt_and_nickel_declared_no_conflict() {
let c = recycled_content_chemistry_conflicts("NMC", Some(16.0), Some(6.0), Some(8.0), None);
assert!(c.is_empty(), "got: {c:?}");
}
#[test]
fn lead_declared_on_lfp_is_a_conflict() {
let c = recycled_content_chemistry_conflicts("LFP", None, Some(12.0), None, Some(80.0));
assert_eq!(c.len(), 1);
assert_eq!(c[0], "lead");
}
#[test]
fn unknown_chemistry_never_conflicts() {
let c = recycled_content_chemistry_conflicts(
"mystery",
Some(5.0),
Some(5.0),
Some(5.0),
Some(5.0),
);
assert!(c.is_empty());
}
}
pub const ART8_DECLARATION_FLOOR_2028: CalendarDate = CalendarDate::new(2028, 8, 18);
pub const ART8_DECLARATION_FLOOR_2033: CalendarDate = CalendarDate::new(2033, 8, 18);
pub const ART8_DECLARATION_EMPOWERMENT: &str = "EU 2023/1542 Art. 8(1), third subparagraph — recycled content calculation, verification and documentation format";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Art8DeclarationDuty {
NotCovered,
NotYetDue { not_before: CalendarDate },
Undetermined {
not_before: CalendarDate,
empowerment: &'static str,
},
}
#[must_use]
pub fn art8_declaration_duty_for(
category: Art8Category,
placed_on_market: CalendarDate,
) -> Art8DeclarationDuty {
let floor = match category {
Art8Category::NotCovered => return Art8DeclarationDuty::NotCovered,
Art8Category::IndustrialEvSli => ART8_DECLARATION_FLOOR_2028,
Art8Category::Lmt => ART8_DECLARATION_FLOOR_2033,
};
if placed_on_market < floor {
Art8DeclarationDuty::NotYetDue { not_before: floor }
} else {
Art8DeclarationDuty::Undetermined {
not_before: floor,
empowerment: ART8_DECLARATION_EMPOWERMENT,
}
}
}