#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Mandatory,
Conditional,
NotApplicable,
Unknown,
}
impl Requirement {
#[must_use]
pub const fn permits_presence(self) -> bool {
!matches!(self, Self::NotApplicable)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
Ev,
Lmt,
Industrial,
}
fn category_of(battery_type: &str) -> Option<Category> {
let t = battery_type.trim();
let eq = |s: &str| t.eq_ignore_ascii_case(s);
if eq("ev") {
Some(Category::Ev)
} else if eq("lmt") {
Some(Category::Lmt)
} else if eq("industrial") {
Some(Category::Industrial)
} else {
None
}
}
use Requirement::{Conditional as C, Mandatory as M, NotApplicable as X};
const REQUIREMENTS: &[(&str, Requirement, Requirement, Requirement)] = &[
("batteryType", M, M, M),
("batteryModelId", M, M, M),
("manufacturingPlace", M, M, M),
("manufacturingDate", M, M, M),
("batteryWeightKg", M, M, M),
("nominalCapacityAh", M, M, M),
("batteryChemistry", M, M, M),
("hazardousSubstances", M, M, M),
("usableExtinguishingAgent", M, M, M),
("criticalRawMaterials", M, M, M),
("carbonFootprintClass", X, X, X),
("dueDiligenceUrl", X, X, X),
("recycledContentCobaltPct", M, M, M),
("recycledContentLithiumPct", M, M, M),
("recycledContentNickelPct", M, M, M),
("recycledContentLeadPct", M, M, M),
("renewableContentPct", M, M, M),
("ratedCapacityAh", X, X, X),
("minimalVoltageV", M, M, M),
("nominalVoltageV", M, M, M),
("maximumVoltageV", M, M, M),
("originalPowerCapabilityW", M, M, M),
("powerLimitMinW", M, M, M),
("powerLimitMaxW", M, M, M),
("expectedLifetimeCycles", M, M, C),
("expectedLifetimeReferenceTest", M, M, C),
("capacityThresholdForExhaustionPct", M, X, X),
("notInUseTemperatureRange", M, M, M),
("notInUseTemperatureReferenceTest", M, M, M),
("commercialWarrantyPeriodMonths", C, C, C),
("initialRoundTripEfficiencyPct", M, M, C),
("roundTripEfficiencyAtHalfCycleLifePct", M, M, C),
("internalCellResistanceMohm", M, M, M),
("internalPackResistanceMohm", M, M, M),
("cycleLifeTestCRate", M, M, C),
("markingInformation", M, M, M),
("hazardSymbol", C, C, C),
("euDeclarationOfConformity", M, M, M),
("wasteBatteryInformation", M, M, M),
("cathodeMaterial", M, M, M),
("anodeMaterial", M, M, M),
("electrolyteMaterial", M, M, M),
("componentPartNumbers", M, M, M),
("sparePartsContacts", M, M, M),
("disassemblyInstructionsUrl", M, M, M),
("safetyMeasures", M, M, M),
("testReportResults", M, M, M),
("dynamicPerformance", M, M, C),
("stateOfHealth", M, M, C),
("batteryStatus", M, M, M),
("usageHistory", C, C, C),
];
#[must_use]
pub fn annex_xiii_requirement(field: &str, battery_type: &str) -> Requirement {
let Some(category) = category_of(battery_type) else {
return Requirement::Unknown;
};
let mut i = 0;
while i < REQUIREMENTS.len() {
let (name, ev, lmt, ind) = REQUIREMENTS[i];
if name.as_bytes() == field.as_bytes() {
return match category {
Category::Ev => ev,
Category::Lmt => lmt,
Category::Industrial => ind,
};
}
i += 1;
}
Requirement::Unknown
}
pub fn mandatory_fields(battery_type: &str) -> impl Iterator<Item = &'static str> {
let category = category_of(battery_type);
REQUIREMENTS.iter().filter_map(move |(name, ev, lmt, ind)| {
let r = match category? {
Category::Ev => *ev,
Category::Lmt => *lmt,
Category::Industrial => *ind,
};
(r == Requirement::Mandatory).then_some(*name)
})
}
pub fn fields_not_applicable<'a>(
present: &'a [&'a str],
battery_type: &str,
) -> impl Iterator<Item = &'a str> {
present
.iter()
.copied()
.filter(move |f| annex_xiii_requirement(f, battery_type) == Requirement::NotApplicable)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_1_k_is_ev_only() {
let f = "capacityThresholdForExhaustionPct";
assert_eq!(annex_xiii_requirement(f, "ev"), Requirement::Mandatory);
assert_eq!(annex_xiii_requirement(f, "lmt"), Requirement::NotApplicable);
assert_eq!(
annex_xiii_requirement(f, "industrial"),
Requirement::NotApplicable
);
}
#[test]
fn cycle_lifetime_is_conditional_for_industrial_only() {
for f in ["expectedLifetimeCycles", "expectedLifetimeReferenceTest"] {
assert_eq!(annex_xiii_requirement(f, "ev"), Requirement::Mandatory);
assert_eq!(annex_xiii_requirement(f, "lmt"), Requirement::Mandatory);
assert_eq!(
annex_xiii_requirement(f, "industrial"),
Requirement::Conditional,
"{f}: 'only applicable for some industrial batteries where \
lifetime can be expressed in cycles'"
);
}
}
#[test]
fn the_suppressed_ah_figure_is_not_the_mandatory_one() {
assert_eq!(
annex_xiii_requirement("ratedCapacityAh", "ev"),
Requirement::NotApplicable
);
assert_eq!(
annex_xiii_requirement("nominalCapacityAh", "ev"),
Requirement::Mandatory
);
}
#[test]
fn the_deferred_data_points_are_barred_for_every_category() {
for field in ["carbonFootprintClass", "dueDiligenceUrl"] {
for category in ["ev", "lmt", "industrial"] {
assert_eq!(
annex_xiii_requirement(field, category),
Requirement::NotApplicable,
"{field} is deferred by the guidance for {category}"
);
}
assert!(
!annex_xiii_requirement(field, "ev").permits_presence(),
"{field} must not be carried while the format is unspecified"
);
}
let present = [
"productIdentifier",
"dueDiligenceUrl",
"carbonFootprintClass",
];
let mut flagged = fields_not_applicable(&present, "ev");
assert_eq!(flagged.next(), Some("dueDiligenceUrl"));
assert_eq!(flagged.next(), Some("carbonFootprintClass"));
assert_eq!(
flagged.next(),
None,
"productIdentifier is Unknown to this table, and Unknown is not barred"
);
assert_eq!(
annex_xiii_requirement("productIdentifier", "ev"),
Requirement::Unknown,
"if the guidance is ever read against the product identifier, this \
control stops being one and the line above needs rewriting"
);
}
#[test]
fn portable_and_sli_are_unknown_not_exempt() {
for t in ["portable", "starting-lighting-ignition", "sli", ""] {
assert_eq!(
annex_xiii_requirement("batteryType", t),
Requirement::Unknown,
"{t} is outside the guidance's scope"
);
}
}
#[test]
fn an_unnamed_field_is_unknown() {
assert_eq!(
annex_xiii_requirement("somethingNobodyHasChecked", "ev"),
Requirement::Unknown
);
}
#[test]
fn category_matching_ignores_case_and_padding() {
assert_eq!(
annex_xiii_requirement("batteryType", " EV "),
Requirement::Mandatory
);
}
#[test]
fn not_applicable_is_the_only_verdict_that_bars_presence() {
assert!(!Requirement::NotApplicable.permits_presence());
for r in [
Requirement::Mandatory,
Requirement::Conditional,
Requirement::Unknown,
] {
assert!(r.permits_presence());
}
}
}