compliance: grade on water too, and retire the two magic floats (re-land) #76
2 changed files with 314 additions and 0 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue