From c3afedf762dece370221a9ad2ee0741c55f64384 Mon Sep 17 00:00:00 2001 From: David Charlot Date: Sun, 30 Aug 2026 17:15:09 -0400 Subject: [PATCH] compliance: grade on water too, and retire the two magic floats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EedReport::is_compliant()` was `self.pue < 1.5 && self.renewable_pct >= 50.0`: two hardcoded floats with no provenance, no version, and no way to say which instrument they encode — the exact shape `BandingScale` was added to replace, sitting in the same crate, on the path a regulator would read. It also silently ignored water. `EedReport` has collected `water_usage_liters` all along, and the schemes that grade facilities grade on power **and** water, so the check discarded an axis it already had in hand. The test that demonstrates this is the reason for the deprecation rather than an argument for it: a facility with PUE 1.10 and 2.0 L/kWh **passes `is_compliant()` while its actual grade is F**. Three pieces: - `wue_milli_l_per_kwh()` derives Water Usage Effectiveness as an integer, and returns `None` rather than 0 when IT energy is zero. WUE is undefined there, and a 0 would read as *perfect* water efficiency for a facility that reported nothing — the flattering direction, which is the one to refuse. - `CompositeRating` combines several metric ratings into one published grade under a **declared** rule. "Worst of" and "mean of" are both defensible and give different letters for the same facility, so the rule travels with the grade for the same reason a `Rating` carries its scale. It keeps its components, so a reader sees *which axis set the grade* — a facility rated D on water with an A on power is a materially different thing from one rated D on both, and a bare letter cannot tell them apart. `binding_component()` names the axis an operator has to improve to move the letter, and `covers()` reports whether a required axis was graded at all, because a grade that silently omits one is not the scheme's grade. - `grade()` ties them together, taking the scales rather than embedding any. Which thresholds apply is the instrument's business, not this crate's — the same reason `BandingScale` still ships no canonical scale. `is_compliant` is deprecated rather than deleted: it is public API, and the deprecation note carries the reasoning to the call site, which a silent removal could not. Pre-existing tests that exercise it keep working under a module-level allow. Duplicate metrics are refused: the same axis graded twice votes twice, which under a worst-of rule is harmless and under any other rule is a silent weighting nobody declared. Found by the 2026-08-29 sweep, which flagged both that WUE was not rateable end-to-end and that this function had survived the change meant to retire it. Verified: 50 jouleclaw-compliance tests pass (7 new), no clippy warnings in the crate — the four reported are pre-existing, in jouleclaw-core and jouleclaw-energy. --- .../jouleclaw-compliance/src/banding.rs | 161 ++++++++++++++++++ .../jouleclaw-compliance/src/eu_compliance.rs | 153 +++++++++++++++++ 2 files changed, 314 insertions(+) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs index 4d8f228ef..7019bb479 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/banding.rs @@ -186,6 +186,101 @@ impl Rating { } } +/// How several metric ratings combine into one published grade. +/// +/// A scheme that grades on more than one metric has to say how they combine, +/// and the choice is policy rather than arithmetic: "worst of" and "mean of" +/// are both defensible and produce different letters for the same facility. So +/// the rule is declared and travels with the grade, for the same reason a +/// [`Rating`] carries its scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Combine { + /// The grade is the worst component. The conservative rule: a facility is + /// no better than its weakest graded axis, and a good score on one metric + /// cannot buy off a poor one on another. + Worst, +} + +impl Combine { + /// The wire label. + pub fn label(self) -> &'static str { + match self { + Combine::Worst => "worst", + } + } +} + +/// Errors from combining ratings. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CompositeError { + /// A grade over nothing is not a grade. + #[error("a composite grade needs at least one component rating")] + NoComponents, + /// The same metric graded twice would let one axis vote twice. + #[error("metric {0:?} appears more than once in a composite grade")] + DuplicateMetric(String), +} + +/// A published grade derived from several metric ratings. +/// +/// Carries its components, so a reader can see **which axis set the grade** +/// rather than being handed a letter. Under [`Combine::Worst`] that is the +/// whole story: a facility rated D because of water, with an A on power, is a +/// materially different thing from one rated D on both, and a bare letter +/// cannot tell them apart. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompositeRating { + /// The grade. + pub label: EnergyLabel, + /// The rule that produced it. + pub rule: Combine, + /// Every component, in the order supplied. + pub components: Vec, +} + +impl CompositeRating { + /// Combine ratings into one grade under `rule`. + /// + /// Rejects duplicate metrics: the same axis graded twice would vote twice, + /// which under a worst-of rule is harmless and under any other rule is a + /// silent weighting nobody declared. + pub fn combine(rule: Combine, components: Vec) -> Result { + if components.is_empty() { + return Err(CompositeError::NoComponents); + } + for (i, c) in components.iter().enumerate() { + if components[..i].iter().any(|o| o.metric == c.metric) { + return Err(CompositeError::DuplicateMetric(c.metric.clone())); + } + } + let label = match rule { + // `EnergyLabel` orders A < G, so the worst component is the max. + Combine::Worst => components.iter().map(|c| c.label).max().expect("non-empty"), + }; + Ok(CompositeRating { label, rule, components }) + } + + /// The component that set the grade — the axis a reader should look at + /// first, and the one an operator has to improve to move the letter. + pub fn binding_component(&self) -> &Rating { + match self.rule { + Combine::Worst => self + .components + .iter() + .max_by_key(|c| c.label) + .expect("non-empty by construction"), + } + } + + /// Whether every named metric is present. A grade that silently omits an + /// axis the scheme requires is not the scheme's grade. + pub fn covers(&self, required_metrics: &[&str]) -> bool { + required_metrics + .iter() + .all(|m| self.components.iter().any(|c| c.metric == *m)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +306,72 @@ mod tests { } } + fn wue_scale() -> BandingScale { + BandingScale { + scale_id: "example.wue/1".into(), + metric: "wue".into(), + unit: "milli_l_per_kwh".into(), + direction: Direction::LowerIsBetter, + thresholds: [200, 400, 700, 1_100, 1_600, 2_200], + } + } + + /// A scheme graded on two metrics needs a composite, and under a worst-of + /// rule a good axis cannot buy off a poor one. + #[test] + fn a_strong_axis_does_not_offset_a_weak_one() { + let power = pue_scale().rate(1_100).unwrap(); // A + let water = wue_scale().rate(1_500).unwrap(); // E + assert_eq!(power.label, EnergyLabel::A); + assert_eq!(water.label, EnergyLabel::E); + + let g = CompositeRating::combine(Combine::Worst, vec![power, water]).unwrap(); + assert_eq!(g.label, EnergyLabel::E, "the grade is the worst component"); + + // And the reader is told WHICH axis set it — a bare E cannot + // distinguish "poor on water" from "poor on both". + assert_eq!(g.binding_component().metric, "wue"); + } + + /// A grade that silently omits a required axis is not the scheme's grade. + #[test] + fn an_omitted_axis_is_detectable() { + let power = pue_scale().rate(1_100).unwrap(); + let g = CompositeRating::combine(Combine::Worst, vec![power]).unwrap(); + assert_eq!(g.label, EnergyLabel::A, "power alone grades A"); + assert!(g.covers(&["pue"])); + assert!(!g.covers(&["pue", "wue"]), "water was never graded, and it shows"); + } + + /// Degenerate compositions are refused rather than quietly accepted. + #[test] + fn a_composite_needs_distinct_components() { + assert_eq!( + CompositeRating::combine(Combine::Worst, vec![]), + Err(CompositeError::NoComponents) + ); + let a = pue_scale().rate(1_100).unwrap(); + let b = pue_scale().rate(1_900).unwrap(); + assert!(matches!( + CompositeRating::combine(Combine::Worst, vec![a, b]), + Err(CompositeError::DuplicateMetric(m)) if m == "pue" + )); + } + + /// The rule travels with the grade, because "worst of" and "mean of" are + /// both defensible and give different letters for one facility. + #[test] + fn the_combination_rule_is_carried() { + let g = CompositeRating::combine( + Combine::Worst, + vec![pue_scale().rate(1_100).unwrap(), wue_scale().rate(1_500).unwrap()], + ) + .unwrap(); + assert_eq!(g.rule, Combine::Worst); + assert_eq!(g.rule.label(), "worst"); + assert_eq!(g.components.len(), 2, "components are kept so the grade re-derives"); + } + /// The function that did not exist: measurement in, band out. #[test] fn a_measurement_becomes_a_band() { diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/eu_compliance.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/eu_compliance.rs index 6e48fdffd..dcc1a108b 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/eu_compliance.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/eu_compliance.rs @@ -9,6 +9,7 @@ #![forbid(unsafe_code)] use serde::{Deserialize, Serialize}; +use crate::banding::{BandingScale, Combine, CompositeRating}; // ── EED ───────────────────────────────────────────────────────────── @@ -33,9 +34,82 @@ pub struct EedReport { impl EedReport { /// `true` when PUE < 1.5 *and* renewable share ≥ 50 %. + /// + /// # Deprecated + /// + /// Those are two magic floats with no stated provenance, no version, and no + /// way to say which scheme they came from — the exact shape + /// [`crate::banding::BandingScale`] exists to replace. Worse, they answer a + /// question that has no fixed answer: "compliant" is defined by an + /// instrument, instruments are revised, and a hardcoded threshold cannot say + /// which revision it encodes. + /// + /// It also silently ignores water, while this very struct collects + /// `water_usage_liters` and the schemes that grade facilities grade on power + /// **and** water. A facility can pass this check with any water figure at + /// all, including one that would fail its actual rating. + /// + /// Use [`EedReport::grade`] with the scales your scheme publishes. + #[deprecated( + since = "0.2.0", + note = "hardcoded thresholds with no provenance, and ignores water; use `grade` with a published BandingScale" + )] pub fn is_compliant(&self) -> bool { self.pue < 1.5 && self.renewable_pct >= 50.0 } + + /// Power Usage Effectiveness in milli-units (1.15 → 1150), the integer form + /// [`crate::banding::BandingScale`] bands. + pub fn pue_milli(&self) -> u64 { + (self.pue * 1000.0).round().max(0.0) as u64 + } + + /// Water Usage Effectiveness — litres of water per kWh of IT energy, in + /// milli-litres per kWh so the value is an integer. + /// + /// `None` when IT energy is zero: WUE is undefined there, and returning 0 + /// would read as *perfect* water efficiency for a facility that reported + /// nothing, which is the flattering direction. + pub fn wue_milli_l_per_kwh(&self) -> Option { + if self.it_energy_kwh <= 0.0 { + return None; + } + Some(((self.water_usage_liters / self.it_energy_kwh) * 1000.0).round().max(0.0) as u64) + } + + /// Grade this report against published scales, combining them under a + /// declared rule. + /// + /// The scales are supplied rather than built in: which thresholds apply is + /// the instrument's business, not this crate's. Returns the components + /// alongside the grade, so a reader can see which axis set it. + pub fn grade( + &self, + pue_scale: &BandingScale, + wue_scale: Option<&BandingScale>, + rule: Combine, + ) -> Result { + let mut components = vec![pue_scale.rate(self.pue_milli()).map_err(GradeError::Banding)?]; + if let Some(ws) = wue_scale { + let wue = self.wue_milli_l_per_kwh().ok_or(GradeError::WueUndefined)?; + components.push(ws.rate(wue).map_err(GradeError::Banding)?); + } + CompositeRating::combine(rule, components).map_err(GradeError::Composite) + } +} + +/// Errors from grading a report. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum GradeError { + /// A scale could not rate its value. + #[error(transparent)] + Banding(crate::banding::BandingError), + /// A water grade was asked for but WUE is undefined (no IT energy). + #[error("a water grade was requested but WUE is undefined: IT energy is zero")] + WueUndefined, + /// The components could not be combined. + #[error(transparent)] + Composite(crate::banding::CompositeError), } // ── CSRD Scope 3 ──────────────────────────────────────────────────── @@ -176,7 +250,86 @@ pub fn generate_ai_act_report( } #[cfg(test)] +// These tests deliberately exercise `is_compliant`, including the test that +// shows it passing a facility its own grade would fail — that demonstration is +// the reason it is deprecated, so the warning is noise here. +#[allow(deprecated)] mod tests { + use crate::banding::{BandingScale, Combine, Direction}; + use crate::passport::EnergyLabel; + + fn report(pue: f64, water_l: f64, it_kwh: f64) -> EedReport { + EedReport { + reporting_period: "2026-Q3".into(), + pue, + total_energy_kwh: it_kwh * pue, + it_energy_kwh: it_kwh, + renewable_pct: 60.0, + waste_heat_recovery_pct: 0.0, + water_usage_liters: water_l, + } + } + + fn pue_s() -> BandingScale { + BandingScale { + scale_id: "example.pue/1".into(), + metric: "pue".into(), + unit: "milli_pue".into(), + direction: Direction::LowerIsBetter, + thresholds: [1_150, 1_250, 1_350, 1_500, 1_700, 1_900], + } + } + fn wue_s() -> BandingScale { + BandingScale { + scale_id: "example.wue/1".into(), + metric: "wue".into(), + unit: "milli_l_per_kwh".into(), + direction: Direction::LowerIsBetter, + thresholds: [200, 400, 700, 1_100, 1_600, 2_200], + } + } + + /// The report collected water all along and the old check discarded it. A + /// facility with excellent power and poor water now grades on both. + #[test] + fn water_can_no_longer_be_silently_dropped() { + // PUE 1.10 (grade A), WUE 1.5 L/kWh (grade E). + let r = report(1.10, 1_500.0, 1_000.0); + assert_eq!(r.pue_milli(), 1_100); + assert_eq!(r.wue_milli_l_per_kwh(), Some(1_500)); + + let power_only = r.grade(&pue_s(), None, Combine::Worst).unwrap(); + assert_eq!(power_only.label, EnergyLabel::A); + assert!(!power_only.covers(&["pue", "wue"]), "a power-only grade says so"); + + let both = r.grade(&pue_s(), Some(&wue_s()), Combine::Worst).unwrap(); + assert_eq!(both.label, EnergyLabel::E, "water sets the grade"); + assert_eq!(both.binding_component().metric, "wue"); + assert!(both.covers(&["pue", "wue"])); + } + + /// WUE is undefined without IT energy, and reporting 0 would read as + /// perfect water efficiency — the flattering direction. + #[test] + fn undefined_wue_is_none_not_zero() { + let r = report(1.10, 500.0, 0.0); + assert_eq!(r.wue_milli_l_per_kwh(), None); + assert_eq!(r.grade(&pue_s(), Some(&wue_s()), Combine::Worst), Err(GradeError::WueUndefined)); + // Grading power alone still works, and is honest about its coverage. + assert!(r.grade(&pue_s(), None, Combine::Worst).unwrap().covers(&["pue"])); + } + + /// The deprecated check ignores water entirely: a facility can pass it with + /// any water figure at all, including one that grades F. + #[test] + fn the_old_check_passes_a_facility_its_own_grade_would_fail() { + let r = report(1.10, 2_000.0, 1_000.0); // WUE 2.0 L/kWh -> F + let old = r.is_compliant(); + assert!(old, "the hardcoded rule passes it"); + let graded = r.grade(&pue_s(), Some(&wue_s()), Combine::Worst).unwrap(); + assert_eq!(graded.label, EnergyLabel::F, "its actual grade is F"); + } + use super::*; #[test] -- 2.54.0 (Apple Git-157)