compliance: grade on water too, and retire the two magic floats #71

Merged
dcharlot merged 1 commit from compliance/composite-grade into main 2026-09-03 15:11:26 -04:00
2 changed files with 314 additions and 0 deletions

View file

@ -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<Rating>,
}
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<Rating>) -> Result<Self, CompositeError> {
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() {

View file

@ -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<u64> {
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<CompositeRating, GradeError> {
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]