compliance: grade on water too, and retire the two magic floats #71
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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.
|
/// The function that did not exist: measurement in, band out.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_measurement_becomes_a_band() {
|
fn a_measurement_becomes_a_band() {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::banding::{BandingScale, Combine, CompositeRating};
|
||||||
|
|
||||||
// ── EED ─────────────────────────────────────────────────────────────
|
// ── EED ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -33,9 +34,82 @@ pub struct EedReport {
|
||||||
|
|
||||||
impl EedReport {
|
impl EedReport {
|
||||||
/// `true` when PUE < 1.5 *and* renewable share ≥ 50 %.
|
/// `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 {
|
pub fn is_compliant(&self) -> bool {
|
||||||
self.pue < 1.5 && self.renewable_pct >= 50.0
|
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 ────────────────────────────────────────────────────
|
// ── CSRD Scope 3 ────────────────────────────────────────────────────
|
||||||
|
|
@ -176,7 +250,86 @@ pub fn generate_ai_act_report(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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 {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue