use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, thiserror::Error)]
pub enum RecError {
#[error("REC {0} not found in registry")]
NotFound(u64),
#[error("REC {0} has invalid status for this operation: {1}")]
InvalidStatus(u64, String),
#[error("invalid parameter: {0}")]
InvalidParameter(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RenewableTechnology {
Solar,
Wind,
Hydro,
Geothermal,
Biomass,
TidalWave,
Other(String),
}
impl std::fmt::Display for RenewableTechnology {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Solar => write!(f, "Solar"),
Self::Wind => write!(f, "Wind"),
Self::Hydro => write!(f, "Hydro"),
Self::Geothermal => write!(f, "Geothermal"),
Self::Biomass => write!(f, "Biomass"),
Self::TidalWave => write!(f, "TidalWave"),
Self::Other(s) => write!(f, "Other({s})"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecStatus {
Active,
Retired,
Expired,
Transferred,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecCertificate {
pub rec_id: u64,
pub generator_id: usize,
pub technology: RenewableTechnology,
pub generation_mwh: f64,
pub vintage_month: (usize, usize),
pub issue_date: (usize, usize, usize),
pub expiry_date: (usize, usize, usize),
pub status: RecStatus,
pub region: String,
}
impl RecCertificate {
pub fn is_active(&self) -> bool {
self.status == RecStatus::Active
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecPortfolio {
pub owner_id: usize,
pub certificates: Vec<RecCertificate>,
}
impl RecPortfolio {
pub fn new(owner_id: usize) -> Self {
Self {
owner_id,
certificates: Vec::new(),
}
}
pub fn active_mwh(&self) -> f64 {
self.certificates
.iter()
.filter(|c| c.is_active())
.map(|c| c.generation_mwh)
.sum()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecMarket {
pub spot_price_usd_per_rec: f64,
pub forward_prices: Vec<(usize, f64)>,
pub liquidity_score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecComplianceCheck {
pub obligation_mwh: f64,
pub fulfilled_mwh: f64,
pub deficit_mwh: f64,
pub surplus_mwh: f64,
pub compliance_pct: f64,
pub penalty_usd: f64,
pub rec_cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecConfig {
pub registry_name: String,
pub jurisdiction: String,
pub vintage_year: usize,
pub rec_megawatt_hours: f64,
}
impl Default for RecConfig {
fn default() -> Self {
Self {
registry_name: "M-RETS".into(),
jurisdiction: "US".into(),
vintage_year: 2026,
rec_megawatt_hours: 1.0,
}
}
}
pub struct RecTracker {
config: RecConfig,
registry: Vec<RecCertificate>,
next_id: u64,
}
impl RecTracker {
pub fn new(config: RecConfig) -> Self {
Self {
config,
registry: Vec::new(),
next_id: 1,
}
}
pub fn issue_rec(
&mut self,
generator_id: usize,
tech: RenewableTechnology,
mwh: f64,
region: String,
) -> RecCertificate {
let id = self.next_id;
self.next_id += 1;
let year = self.config.vintage_year;
let cert = RecCertificate {
rec_id: id,
generator_id,
technology: tech,
generation_mwh: mwh,
vintage_month: (year, 1),
issue_date: (year, 1, 1),
expiry_date: (year + 5, 1, 1),
status: RecStatus::Active,
region,
};
self.registry.push(cert.clone());
cert
}
pub fn retire_rec(&mut self, rec_id: u64) -> Result<(), RecError> {
let cert = self
.registry
.iter_mut()
.find(|c| c.rec_id == rec_id)
.ok_or(RecError::NotFound(rec_id))?;
if cert.status != RecStatus::Active {
return Err(RecError::InvalidStatus(
rec_id,
format!("cannot retire {:?} certificate", cert.status),
));
}
cert.status = RecStatus::Retired;
Ok(())
}
pub fn transfer_rec(&mut self, rec_id: u64, _new_owner: usize) -> Result<(), RecError> {
let cert = self
.registry
.iter_mut()
.find(|c| c.rec_id == rec_id)
.ok_or(RecError::NotFound(rec_id))?;
if cert.status != RecStatus::Active {
return Err(RecError::InvalidStatus(
rec_id,
format!("cannot transfer {:?} certificate", cert.status),
));
}
cert.status = RecStatus::Transferred;
Ok(())
}
pub fn check_compliance(
&self,
portfolio: &RecPortfolio,
obligation_mwh: f64,
penalty_rate_usd_per_mwh: f64,
) -> RecComplianceCheck {
let fulfilled_mwh: f64 = portfolio
.certificates
.iter()
.filter(|c| c.is_active())
.map(|c| c.generation_mwh)
.sum();
let deficit_mwh = (obligation_mwh - fulfilled_mwh).max(0.0);
let surplus_mwh = (fulfilled_mwh - obligation_mwh).max(0.0);
let compliance_pct = if obligation_mwh > 0.0 {
(fulfilled_mwh / obligation_mwh * 100.0).min(100.0)
} else {
100.0
};
let penalty_usd = deficit_mwh * penalty_rate_usd_per_mwh;
RecComplianceCheck {
obligation_mwh,
fulfilled_mwh,
deficit_mwh,
surplus_mwh,
compliance_pct,
penalty_usd,
rec_cost_usd: 0.0, }
}
pub fn get_active_recs(&self) -> Vec<&RecCertificate> {
self.registry.iter().filter(|c| c.is_active()).collect()
}
pub fn total_active_mwh(&self) -> f64 {
self.registry
.iter()
.filter(|c| c.is_active())
.map(|c| c.generation_mwh)
.sum()
}
pub fn filter_by_technology(&self, tech: &RenewableTechnology) -> Vec<&RecCertificate> {
self.registry
.iter()
.filter(|c| &c.technology == tech)
.collect()
}
pub fn get_by_id(&self, rec_id: u64) -> Option<&RecCertificate> {
self.registry.iter().find(|c| c.rec_id == rec_id)
}
pub fn registry_size(&self) -> usize {
self.registry.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tracker() -> RecTracker {
RecTracker::new(RecConfig::default())
}
#[test]
fn test_issue_rec_active() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Solar, 1.0, "US-MISO".into());
assert_eq!(cert.status, RecStatus::Active);
assert_eq!(cert.generator_id, 1);
assert_eq!(tracker.get_active_recs().len(), 1);
assert!((tracker.total_active_mwh() - 1.0).abs() < 1e-12);
}
#[test]
fn test_retire_rec_status_transitions() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Wind, 1.0, "EU".into());
tracker.retire_rec(cert.rec_id).unwrap();
let stored = tracker.get_by_id(cert.rec_id).unwrap();
assert_eq!(stored.status, RecStatus::Retired);
assert!(tracker.get_active_recs().is_empty());
}
#[test]
fn test_double_retire_returns_error() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(2, RenewableTechnology::Hydro, 5.0, "AU".into());
tracker.retire_rec(cert.rec_id).unwrap();
let err = tracker.retire_rec(cert.rec_id);
assert!(err.is_err(), "Second retire should fail");
}
#[test]
fn test_compliance_deficit_penalty() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Solar, 80.0, "US".into());
let mut portfolio = RecPortfolio::new(100);
portfolio.certificates.push(cert);
let check = tracker.check_compliance(&portfolio, 100.0, 50.0);
assert!((check.deficit_mwh - 20.0).abs() < 1e-9);
assert!((check.surplus_mwh).abs() < 1e-9);
assert!((check.penalty_usd - 1000.0).abs() < 1e-6);
assert!((check.compliance_pct - 80.0).abs() < 1e-6);
}
#[test]
fn test_compliance_surplus_no_penalty() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Wind, 120.0, "EU".into());
let mut portfolio = RecPortfolio::new(200);
portfolio.certificates.push(cert);
let check = tracker.check_compliance(&portfolio, 100.0, 50.0);
assert!((check.surplus_mwh - 20.0).abs() < 1e-9);
assert!((check.deficit_mwh).abs() < 1e-9);
assert!((check.penalty_usd).abs() < 1e-12);
assert!((check.compliance_pct - 100.0).abs() < 1e-6);
}
#[test]
fn test_filter_by_technology_solar() {
let mut tracker = make_tracker();
tracker.issue_rec(1, RenewableTechnology::Solar, 1.0, "US".into());
tracker.issue_rec(2, RenewableTechnology::Wind, 1.0, "US".into());
tracker.issue_rec(3, RenewableTechnology::Solar, 2.0, "US".into());
let solar = tracker.filter_by_technology(&RenewableTechnology::Solar);
assert_eq!(solar.len(), 2, "Should find 2 solar RECs");
for c in &solar {
assert_eq!(c.technology, RenewableTechnology::Solar);
}
}
#[test]
fn test_transfer_changes_status() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Geothermal, 1.0, "US".into());
tracker.transfer_rec(cert.rec_id, 999).unwrap();
let stored = tracker.get_by_id(cert.rec_id).unwrap();
assert_eq!(stored.status, RecStatus::Transferred);
assert!(tracker.get_active_recs().is_empty());
}
#[test]
fn test_cannot_retire_transferred_rec() {
let mut tracker = make_tracker();
let cert = tracker.issue_rec(1, RenewableTechnology::Biomass, 1.0, "US".into());
tracker.transfer_rec(cert.rec_id, 99).unwrap();
assert!(tracker.retire_rec(cert.rec_id).is_err());
}
}