use alloc::vec::Vec;
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;
#[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 annex_x_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 annex_x_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 exactly_at_2031_targets_passes() {
let input = all_metals(16.0, 85.0, 6.0, 6.0);
assert!(annex_x_shortfalls_2031(&input).is_empty());
}
#[test]
fn above_2031_targets_passes() {
let input = all_metals(20.0, 90.0, 10.0, 10.0);
assert!(annex_x_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 = annex_x_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!(annex_x_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!(annex_x_shortfalls_2031(&input).is_empty());
}
#[test]
fn phase2_stricter_than_phase1() {
let input = all_metals(16.0, 85.0, 6.0, 6.0);
assert!(annex_x_shortfalls_2031(&input).is_empty());
let shortfalls = annex_x_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 = annex_x_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 = annex_x_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());
}
}