compliance: derive the A-G band instead of accepting one #64
2 changed files with 318 additions and 0 deletions
|
|
@ -0,0 +1,317 @@
|
|||
//! Turning a measurement into a rating — and carrying the scale that did it.
|
||||
//!
|
||||
//! # The gap
|
||||
//!
|
||||
//! [`crate::passport::EnergyLabel`] is an A–G enum with an ordering and a
|
||||
//! description, and nothing in this workspace ever *derives* one: every use
|
||||
//! supplies a letter the caller had already decided. The label is a container
|
||||
//! for someone else's judgement, which is exactly the shape a rating must not
|
||||
//! have. Meanwhile the one threshold rule that does exist,
|
||||
//! `EuComplianceReport::is_compliant`, is a hardcoded `pue < 1.5 &&
|
||||
//! renewable_pct >= 50.0` — two magic floats with no stated provenance, no
|
||||
//! version, and no way to band anything.
|
||||
//!
|
||||
//! # A letter is not a rating
|
||||
//!
|
||||
//! The central failure this module exists to prevent: **a band means nothing
|
||||
//! without the scale that produced it.** Two facilities both rated "B" under
|
||||
//! different threshold sets are not comparable, and nothing about the letter
|
||||
//! says so. A rating is therefore always `(label, scale, measured value)`
|
||||
//! together — [`Rating`] cannot be constructed without its scale — and
|
||||
//! [`Rating::comparable_with`] is an explicit check rather than an assumption.
|
||||
//!
|
||||
//! # Scales are data
|
||||
//!
|
||||
//! [`BandingScale`] is a value, not a `match` arm. A new scale, or a revision of
|
||||
//! an existing one, is a different `scale_id` rather than a recompile — and
|
||||
//! because the id travels inside every rating, a revision cannot silently
|
||||
//! re-band history. Thresholds are integers in a declared unit (milli-PUE
|
||||
//! rather than `1.5`), matching the integer discipline of the receipt family:
|
||||
//! floating-point boundaries make band edges depend on how a value was parsed.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::passport::EnergyLabel;
|
||||
|
||||
/// Which way "better" runs on a scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Direction {
|
||||
/// Smaller values earn better bands — PUE, µJ per unit of work, WUE.
|
||||
LowerIsBetter,
|
||||
/// Larger values earn better bands — renewable share, utilisation.
|
||||
HigherIsBetter,
|
||||
}
|
||||
|
||||
/// Errors from defining or applying a scale.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum BandingError {
|
||||
/// Boundaries do not run monotonically in the declared direction.
|
||||
#[error(
|
||||
"threshold {index} ({value}) is not monotonic for {direction:?} — a scale whose bands overlap cannot rank anything"
|
||||
)]
|
||||
NonMonotonic {
|
||||
/// Which boundary broke the ordering.
|
||||
index: usize,
|
||||
/// Its value.
|
||||
value: u64,
|
||||
/// The direction it was supposed to follow.
|
||||
direction: Direction,
|
||||
},
|
||||
/// A scale with no identity cannot be cited by a rating.
|
||||
#[error("a scale must carry a non-empty scale_id")]
|
||||
UnnamedScale,
|
||||
/// The unit is what makes the thresholds mean anything.
|
||||
#[error("a scale must declare the unit its thresholds are in")]
|
||||
UnitlessScale,
|
||||
}
|
||||
|
||||
/// A banding scale: six boundaries dividing a measured quantity into A–G.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BandingScale {
|
||||
/// Name and version of this scale. Travels inside every rating it produces,
|
||||
/// so a later revision is a different id and cannot re-band earlier
|
||||
/// ratings.
|
||||
pub scale_id: String,
|
||||
/// What is being banded (e.g. `"pue"`, `"uj_per_kilopixel"`).
|
||||
pub metric: String,
|
||||
/// The unit the thresholds are expressed in (e.g. `"milli_pue"`). Integer
|
||||
/// units are deliberate: a float boundary makes the band depend on how the
|
||||
/// value was parsed.
|
||||
pub unit: String,
|
||||
/// Which way better runs.
|
||||
pub direction: Direction,
|
||||
/// The six boundaries, in order: A|B, B|C, C|D, D|E, E|F, F|G.
|
||||
///
|
||||
/// A value exactly on a boundary earns the **better** band, so the
|
||||
/// boundary is the last value that still qualifies for the band above it.
|
||||
pub thresholds: [u64; 6],
|
||||
}
|
||||
|
||||
const LADDER: [EnergyLabel; 7] = [
|
||||
EnergyLabel::A,
|
||||
EnergyLabel::B,
|
||||
EnergyLabel::C,
|
||||
EnergyLabel::D,
|
||||
EnergyLabel::E,
|
||||
EnergyLabel::F,
|
||||
EnergyLabel::G,
|
||||
];
|
||||
|
||||
impl BandingScale {
|
||||
/// Check the scale can rank anything.
|
||||
///
|
||||
/// Overlapping or unordered boundaries are rejected rather than tolerated:
|
||||
/// a scale whose bands are not monotonic will assign different letters to
|
||||
/// the same value depending on evaluation order, which is worse than having
|
||||
/// no scale at all.
|
||||
pub fn validate(&self) -> Result<(), BandingError> {
|
||||
if self.scale_id.trim().is_empty() {
|
||||
return Err(BandingError::UnnamedScale);
|
||||
}
|
||||
if self.unit.trim().is_empty() {
|
||||
return Err(BandingError::UnitlessScale);
|
||||
}
|
||||
for i in 1..self.thresholds.len() {
|
||||
let ok = match self.direction {
|
||||
Direction::LowerIsBetter => self.thresholds[i] > self.thresholds[i - 1],
|
||||
Direction::HigherIsBetter => self.thresholds[i] < self.thresholds[i - 1],
|
||||
};
|
||||
if !ok {
|
||||
return Err(BandingError::NonMonotonic {
|
||||
index: i,
|
||||
value: self.thresholds[i],
|
||||
direction: self.direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Band a measured value, producing a rating that carries this scale.
|
||||
///
|
||||
/// Returns an error rather than a letter for an invalid scale: emitting a
|
||||
/// band from a scale that cannot rank is the failure mode this module is
|
||||
/// for.
|
||||
pub fn rate(&self, value: u64) -> Result<Rating, BandingError> {
|
||||
self.validate()?;
|
||||
let idx = match self.direction {
|
||||
Direction::LowerIsBetter => self.thresholds.iter().position(|t| value <= *t),
|
||||
Direction::HigherIsBetter => self.thresholds.iter().position(|t| value >= *t),
|
||||
};
|
||||
// No boundary claimed it, so it falls past the last one into G.
|
||||
let label = LADDER[idx.unwrap_or(6)];
|
||||
Ok(Rating {
|
||||
label,
|
||||
scale_id: self.scale_id.clone(),
|
||||
metric: self.metric.clone(),
|
||||
unit: self.unit.clone(),
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A band, inseparable from the scale and measurement that produced it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rating {
|
||||
/// The band earned.
|
||||
pub label: EnergyLabel,
|
||||
/// The scale that assigned it.
|
||||
pub scale_id: String,
|
||||
/// What was measured.
|
||||
pub metric: String,
|
||||
/// The unit of [`Rating::value`].
|
||||
pub unit: String,
|
||||
/// The measured value the band came from, kept so a reader can re-derive
|
||||
/// the letter instead of trusting it.
|
||||
pub value: u64,
|
||||
}
|
||||
|
||||
impl Rating {
|
||||
/// Whether two ratings can be meaningfully compared.
|
||||
///
|
||||
/// Only ratings from the same scale, metric and unit rank against each
|
||||
/// other. Two "B"s from different scales are two different claims that
|
||||
/// happen to share a letter, and comparing them is the specific error a
|
||||
/// bare A–G enum invites.
|
||||
pub fn comparable_with(&self, other: &Rating) -> bool {
|
||||
self.scale_id == other.scale_id && self.metric == other.metric && self.unit == other.unit
|
||||
}
|
||||
|
||||
/// Rank against another rating on the same scale.
|
||||
///
|
||||
/// `None` when they are not comparable — deliberately not `Ordering::Equal`,
|
||||
/// which would let incomparable ratings silently tie.
|
||||
pub fn better_than(&self, other: &Rating) -> Option<bool> {
|
||||
self.comparable_with(other).then(|| self.label < other.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// PUE in milli-units: 1.15 -> 1150. Integer boundaries, no float edges.
|
||||
fn pue_scale() -> 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, 2_000],
|
||||
}
|
||||
}
|
||||
|
||||
fn renewable_scale() -> BandingScale {
|
||||
BandingScale {
|
||||
scale_id: "example.renewable/1".into(),
|
||||
metric: "renewable_share".into(),
|
||||
unit: "ppm".into(),
|
||||
direction: Direction::HigherIsBetter,
|
||||
thresholds: [950_000, 800_000, 600_000, 400_000, 200_000, 50_000],
|
||||
}
|
||||
}
|
||||
|
||||
/// The function that did not exist: measurement in, band out.
|
||||
#[test]
|
||||
fn a_measurement_becomes_a_band() {
|
||||
let s = pue_scale();
|
||||
for (milli_pue, want) in [
|
||||
(1_050, EnergyLabel::A),
|
||||
(1_150, EnergyLabel::A), // exactly on the boundary earns the better band
|
||||
(1_151, EnergyLabel::B),
|
||||
(1_400, EnergyLabel::D), // in (1_350, 1_500] — the D band
|
||||
(1_500, EnergyLabel::D), // the boundary still earns D
|
||||
(1_501, EnergyLabel::E),
|
||||
(2_000, EnergyLabel::F),
|
||||
(2_001, EnergyLabel::G), // past the last boundary
|
||||
(9_999, EnergyLabel::G),
|
||||
] {
|
||||
assert_eq!(s.rate(milli_pue).unwrap().label, want, "milli_pue {milli_pue}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Higher-is-better runs the other way, with the same boundary rule.
|
||||
#[test]
|
||||
fn direction_is_honoured() {
|
||||
let s = renewable_scale();
|
||||
assert_eq!(s.rate(1_000_000).unwrap().label, EnergyLabel::A);
|
||||
assert_eq!(s.rate(950_000).unwrap().label, EnergyLabel::A);
|
||||
assert_eq!(s.rate(949_999).unwrap().label, EnergyLabel::B);
|
||||
assert_eq!(s.rate(0).unwrap().label, EnergyLabel::G);
|
||||
}
|
||||
|
||||
/// The whole point: a letter without its scale is not a rating, and two
|
||||
/// letters from different scales are two different claims that happen to
|
||||
/// share a character.
|
||||
#[test]
|
||||
fn ratings_from_different_scales_do_not_compare() {
|
||||
let a = pue_scale().rate(1_100).unwrap();
|
||||
let b = renewable_scale().rate(1_000_000).unwrap();
|
||||
assert_eq!(a.label, EnergyLabel::A);
|
||||
assert_eq!(b.label, EnergyLabel::A);
|
||||
|
||||
assert!(!a.comparable_with(&b), "different metrics must not compare");
|
||||
// Not `Some(false)` — refusing to rank is different from ranking equal.
|
||||
assert_eq!(a.better_than(&b), None);
|
||||
|
||||
// Same scale: ranking works and follows A < G.
|
||||
let worse = pue_scale().rate(1_600).unwrap();
|
||||
assert!(a.comparable_with(&worse));
|
||||
assert_eq!(a.better_than(&worse), Some(true));
|
||||
assert_eq!(worse.better_than(&a), Some(false));
|
||||
}
|
||||
|
||||
/// A revised scale is a different id, so it cannot silently re-band history.
|
||||
#[test]
|
||||
fn a_revised_scale_does_not_re_band_earlier_ratings() {
|
||||
let old = pue_scale().rate(1_300).unwrap();
|
||||
let mut v2 = pue_scale();
|
||||
v2.scale_id = "example.pue/2".into();
|
||||
v2.thresholds = [1_100, 1_200, 1_280, 1_400, 1_600, 1_900];
|
||||
let new = v2.rate(1_300).unwrap();
|
||||
|
||||
assert_eq!(old.label, EnergyLabel::C);
|
||||
assert_eq!(new.label, EnergyLabel::D, "the same value bands differently under v2");
|
||||
assert!(!old.comparable_with(&new), "a revision is not the same scale");
|
||||
}
|
||||
|
||||
/// A scale whose bands overlap would assign different letters to one value
|
||||
/// depending on evaluation order. Rejected, not tolerated.
|
||||
#[test]
|
||||
fn a_non_monotonic_scale_cannot_rate() {
|
||||
let mut s = pue_scale();
|
||||
s.thresholds = [1_150, 1_250, 1_100, 1_500, 1_700, 2_000];
|
||||
assert!(matches!(s.validate(), Err(BandingError::NonMonotonic { index: 2, .. })));
|
||||
// and `rate` refuses rather than emitting a band from a broken scale
|
||||
assert!(s.rate(1_200).is_err());
|
||||
|
||||
let mut d = renewable_scale();
|
||||
d.direction = Direction::LowerIsBetter; // thresholds now descend the wrong way
|
||||
assert!(d.validate().is_err());
|
||||
}
|
||||
|
||||
/// A scale must be citable and its numbers must mean something.
|
||||
#[test]
|
||||
fn a_scale_needs_an_identity_and_a_unit() {
|
||||
let mut s = pue_scale();
|
||||
s.scale_id = " ".into();
|
||||
assert_eq!(s.validate(), Err(BandingError::UnnamedScale));
|
||||
|
||||
let mut s = pue_scale();
|
||||
s.unit = String::new();
|
||||
assert_eq!(s.validate(), Err(BandingError::UnitlessScale));
|
||||
}
|
||||
|
||||
/// The measured value rides along, so a reader re-derives the letter rather
|
||||
/// than trusting it.
|
||||
#[test]
|
||||
fn a_rating_can_be_re_derived_from_what_it_carries() {
|
||||
let s = pue_scale();
|
||||
let r = s.rate(1_337).unwrap();
|
||||
assert_eq!(r.value, 1_337);
|
||||
assert_eq!(r.scale_id, "example.pue/1");
|
||||
assert_eq!(r.unit, "milli_pue");
|
||||
// Re-banding the carried value under the cited scale reproduces it.
|
||||
assert_eq!(s.rate(r.value).unwrap().label, r.label);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
#![warn(missing_docs)]
|
||||
|
||||
pub mod sci;
|
||||
pub mod banding;
|
||||
pub mod passport;
|
||||
pub mod eu_compliance;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue