use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub enum ComplianceError {
InvalidProfile(String),
MissingData(String),
}
impl fmt::Display for ComplianceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidProfile(msg) => write!(f, "invalid profile: {msg}"),
Self::MissingData(msg) => write!(f, "missing data: {msg}"),
}
}
}
impl std::error::Error for ComplianceError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GridCodeStandard {
EntsoE2016,
NercNerc2022,
IecIec61400,
AusNem,
Uk2019,
Germany2018,
Custom { name: String },
}
impl GridCodeStandard {
pub fn display_name(&self) -> String {
match self {
Self::EntsoE2016 => "ENTSO-E RfG 2016".to_string(),
Self::NercNerc2022 => "NERC 2022".to_string(),
Self::IecIec61400 => "IEC 61400-21".to_string(),
Self::AusNem => "Australian NEM".to_string(),
Self::Uk2019 => "UK Grid Code 2019".to_string(),
Self::Germany2018 => "VDE-AR-N 4120:2018".to_string(),
Self::Custom { name } => name.clone(),
}
}
fn lvrt_envelope(&self) -> Vec<(f64, f64)> {
match self {
Self::EntsoE2016 | Self::IecIec61400 => vec![
(0.0, 0.0),
(150.0, 0.0),
(150.0, 0.85),
(500.0, 0.85),
(1500.0, 0.9),
(3000.0, 1.0),
],
Self::NercNerc2022 => vec![(0.0, 0.0), (625.0, 0.0), (3000.0, 0.9)],
Self::AusNem => vec![(0.0, 0.0), (200.0, 0.0), (200.0, 0.7), (2000.0, 0.9)],
Self::Uk2019 => vec![
(0.0, 0.0),
(140.0, 0.0),
(140.0, 0.8),
(1200.0, 0.85),
(2500.0, 0.9),
],
Self::Germany2018 => vec![(0.0, 0.0), (150.0, 0.0), (150.0, 0.85), (1500.0, 0.9)],
Self::Custom { .. } => vec![
(0.0, 0.0),
(150.0, 0.0),
(150.0, 0.85),
(500.0, 0.85),
(1500.0, 0.9),
(3000.0, 1.0),
],
}
}
fn required_reactive_range(&self) -> (f64, f64) {
match self {
Self::EntsoE2016 | Self::NercNerc2022 | Self::Uk2019 | Self::Germany2018 => {
(-0.33, 0.33)
}
Self::IecIec61400 => (-0.40, 0.40),
Self::AusNem => (-0.395, 0.395),
Self::Custom { .. } => (-0.33, 0.33),
}
}
fn required_rocof_hz_s(&self) -> f64 {
match self {
Self::EntsoE2016 | Self::Germany2018 => 2.0,
Self::NercNerc2022 => 1.5,
Self::IecIec61400 | Self::Uk2019 => 1.0,
Self::AusNem => 4.0,
Self::Custom { .. } => 2.0,
}
}
fn droop_limits_pct(&self) -> (f64, f64) {
(2.0, 10.0)
}
fn thd_limits_pct(&self) -> (f64, f64) {
(5.0, 8.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GeneratorType {
WindOnshore,
WindOffshore,
SolarPv,
Battery,
Chp,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceCheckerConfig {
pub grid_code: GridCodeStandard,
pub generator_type: GeneratorType,
pub rated_mw: f64,
pub rated_voltage_kv: f64,
pub connection_point_kv: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComplianceCategory {
LowVoltageRideThrough,
HighVoltageRideThrough,
FrequencyResponse,
ReactiveCapability,
VoltageRegulation,
PowerQuality,
ProtectionSettings,
CommunicationsAndControl,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ComplianceSeverity {
Critical,
Major,
Minor,
Informational,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceTest {
pub name: String,
pub category: ComplianceCategory,
pub requirement: String,
pub test_value: f64,
pub limit: f64,
pub passed: bool,
pub severity: ComplianceSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComplianceReport {
pub generator_id: String,
pub grid_code: String,
pub assessment_date: String,
pub tests: Vec<ComplianceTest>,
pub overall_compliant: bool,
pub critical_failures: usize,
pub major_failures: usize,
pub compliance_score_pct: f64,
pub recommendations: Vec<String>,
}
pub struct ComplianceChecker {
config: ComplianceCheckerConfig,
}
impl ComplianceChecker {
pub fn new(config: ComplianceCheckerConfig) -> Self {
Self { config }
}
#[allow(clippy::too_many_arguments)]
pub fn assess(
&self,
generator_id: &str,
lvrt_profile: &[(f64, f64)],
reactive_capability: (f64, f64),
pf_range: (f64, f64),
freq_response_droop_pct: f64,
rocof_capability_hz_per_s: f64,
thd_voltage_pct: f64,
thd_current_pct: f64,
) -> Result<ComplianceReport, ComplianceError> {
if lvrt_profile.is_empty() {
return Err(ComplianceError::InvalidProfile(
"LVRT profile must contain at least one point".to_string(),
));
}
let mut tests: Vec<ComplianceTest> = Vec::new();
tests.extend(self.check_lvrt(lvrt_profile));
let (q_min, q_max) = reactive_capability;
let (pf_lead, pf_lag) = pf_range;
tests.extend(self.check_reactive(q_min, q_max, pf_lead, pf_lag));
tests.extend(
self.check_frequency_response(freq_response_droop_pct, rocof_capability_hz_per_s),
);
tests.extend(self.check_power_quality(thd_voltage_pct, thd_current_pct));
let total = tests.len();
let passed_count = tests.iter().filter(|t| t.passed).count();
let critical_failures = tests
.iter()
.filter(|t| !t.passed && matches!(t.severity, ComplianceSeverity::Critical))
.count();
let major_failures = tests
.iter()
.filter(|t| !t.passed && matches!(t.severity, ComplianceSeverity::Major))
.count();
let compliance_score_pct = if total == 0 {
100.0
} else {
(passed_count as f64 / total as f64) * 100.0
};
let overall_compliant = critical_failures == 0 && major_failures == 0;
let recommendations = Self::build_recommendations(&tests);
Ok(ComplianceReport {
generator_id: generator_id.to_string(),
grid_code: self.config.grid_code.display_name(),
assessment_date: "unknown".to_string(),
tests,
overall_compliant,
critical_failures,
major_failures,
compliance_score_pct,
recommendations,
})
}
pub fn check_lvrt(&self, actual: &[(f64, f64)]) -> Vec<ComplianceTest> {
let envelope = self.config.grid_code.lvrt_envelope();
let t_max = envelope.last().map(|&(t, _)| t).unwrap_or(3000.0);
let mut times: Vec<f64> = envelope.iter().map(|&(t, _)| t).collect();
times.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let mut sample_times: Vec<f64> = Vec::new();
let steps = 300usize;
for i in 0..=steps {
sample_times.push(t_max * i as f64 / steps as f64);
}
for t in times {
sample_times.push(t + 0.001); }
sample_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
sample_times.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let mut worst_margin: f64 = f64::MAX;
let mut passed = true;
for t in sample_times {
let v_req = interpolate_envelope_max(&envelope, t);
let v_actual = interpolate_profile(actual, t);
let margin = v_actual - v_req;
if margin < worst_margin {
worst_margin = margin;
}
if v_actual < v_req - 1e-6 {
passed = false;
}
}
let worst_margin_display = if worst_margin == f64::MAX {
0.0
} else {
worst_margin
};
vec![ComplianceTest {
name: "LVRT_envelope".to_string(),
category: ComplianceCategory::LowVoltageRideThrough,
requirement: format!(
"Generator must remain connected when voltage follows the {} LVRT envelope",
self.config.grid_code.display_name()
),
test_value: worst_margin_display,
limit: 0.0, passed,
severity: ComplianceSeverity::Critical,
}]
}
pub fn check_reactive(
&self,
q_min: f64,
q_max: f64,
pf_lead: f64,
pf_lag: f64,
) -> Vec<ComplianceTest> {
let (req_q_min, req_q_max) = self.config.grid_code.required_reactive_range();
let mut out = Vec::new();
let q_min_ok = q_min <= req_q_min + 1e-9;
let q_max_ok = q_max >= req_q_max - 1e-9;
let q_passed = q_min_ok && q_max_ok;
let q_test_value = (q_max - q_min) / 2.0;
out.push(ComplianceTest {
name: "reactive_Q_range".to_string(),
category: ComplianceCategory::ReactiveCapability,
requirement: format!(
"Q range must span [{:.2}, +{:.2}] pu at rated P",
req_q_min, req_q_max
),
test_value: q_test_value,
limit: req_q_max,
passed: q_passed,
severity: ComplianceSeverity::Major,
});
let required_min_pf = (1.0_f64 / (1.0 + req_q_max * req_q_max).sqrt()).max(0.85);
let pf_actual = pf_lead.max(pf_lag);
let pf_passed = pf_actual <= required_min_pf + 1e-6;
out.push(ComplianceTest {
name: "reactive_PF_range".to_string(),
category: ComplianceCategory::ReactiveCapability,
requirement: format!(
"Power factor capability must be ≤ {:.3} (lead/lag)",
required_min_pf
),
test_value: pf_actual,
limit: required_min_pf,
passed: pf_passed,
severity: ComplianceSeverity::Major,
});
out
}
pub fn check_frequency_response(&self, droop_pct: f64, rocof_hz_s: f64) -> Vec<ComplianceTest> {
let (droop_min, droop_max) = self.config.grid_code.droop_limits_pct();
let req_rocof = self.config.grid_code.required_rocof_hz_s();
let mut out = Vec::new();
let droop_passed = droop_pct >= droop_min - 1e-9 && droop_pct <= droop_max + 1e-9;
out.push(ComplianceTest {
name: "freq_droop".to_string(),
category: ComplianceCategory::FrequencyResponse,
requirement: format!(
"Primary frequency droop must be in [{droop_min:.0}, {droop_max:.0}] \u{25}"
),
test_value: droop_pct,
limit: droop_max,
passed: droop_passed,
severity: ComplianceSeverity::Major,
});
let rocof_passed = rocof_hz_s >= req_rocof - 1e-9;
out.push(ComplianceTest {
name: "freq_ROCOF_withstand".to_string(),
category: ComplianceCategory::FrequencyResponse,
requirement: format!(
"Generator must withstand ROCOF of {req_rocof:.1} \u{5B}Hz/s\u{5D}"
),
test_value: rocof_hz_s,
limit: req_rocof,
passed: rocof_passed,
severity: ComplianceSeverity::Major,
});
out
}
fn check_power_quality(&self, thd_v_pct: f64, thd_i_pct: f64) -> Vec<ComplianceTest> {
let (v_limit, i_limit) = self.config.grid_code.thd_limits_pct();
vec![
ComplianceTest {
name: "pq_THD_voltage".to_string(),
category: ComplianceCategory::PowerQuality,
requirement: format!("Terminal voltage THD must be < {v_limit:.0} %"),
test_value: thd_v_pct,
limit: v_limit,
passed: thd_v_pct <= v_limit + 1e-9,
severity: ComplianceSeverity::Minor,
},
ComplianceTest {
name: "pq_THD_current".to_string(),
category: ComplianceCategory::PowerQuality,
requirement: format!("Injected current THD must be < {i_limit:.0} %"),
test_value: thd_i_pct,
limit: i_limit,
passed: thd_i_pct <= i_limit + 1e-9,
severity: ComplianceSeverity::Minor,
},
]
}
fn build_recommendations(tests: &[ComplianceTest]) -> Vec<String> {
let mut recs: Vec<String> = Vec::new();
for t in tests.iter().filter(|t| !t.passed) {
let rec = match t.name.as_str() {
"LVRT_envelope" => format!(
"[CRITICAL] LVRT: upgrade inverter firmware / hardware to maintain \
connection during grid faults per the required voltage-time envelope. \
Current margin: {:.3} pu below requirement.",
t.limit - t.test_value
),
"reactive_Q_range" => format!(
"[MAJOR] Reactive Q-range: increase reactive power capability from \
±{:.2} pu to ±{:.2} pu by upgrading inverter rating or adding \
reactive compensation.",
t.test_value, t.limit
),
"reactive_PF_range" => format!(
"[MAJOR] Power factor: declare a minimum PF of {:.3} (lead/lag) \
to satisfy Q requirements.",
t.limit
),
"freq_droop" => format!(
"[MAJOR] Frequency droop: adjust governor/inverter droop from {:.1} % \
to a value in the permitted range.",
t.test_value
),
"freq_ROCOF_withstand" => format!(
"[MAJOR] ROCOF: increase anti-islanding relay ROCOF threshold from \
{:.1} to {:.1} Hz/s to avoid premature tripping.",
t.test_value, t.limit
),
"pq_THD_voltage" => format!(
"[MINOR] Voltage THD: install harmonic filters or upgrade inverter \
switching strategy to reduce voltage THD from {:.1} % to < {:.0} %.",
t.test_value, t.limit
),
"pq_THD_current" => format!(
"[MINOR] Current THD: reduce current harmonic injection from {:.1} % \
to < {:.0} % using active filters or modified PWM control.",
t.test_value, t.limit
),
other => format!(
"[INFO] Test '{other}' failed (value={:.3}, limit={:.3}).",
t.test_value, t.limit
),
};
recs.push(rec);
}
recs
}
}
pub fn interpolate_profile(profile: &[(f64, f64)], x_query: f64) -> f64 {
if profile.is_empty() {
return 0.0;
}
if x_query < profile[0].0 {
return profile[0].1;
}
if x_query > profile[profile.len() - 1].0 {
return profile[profile.len() - 1].1;
}
let at_x: Vec<f64> = profile
.iter()
.filter(|&&(x, _)| (x - x_query).abs() < 1e-9)
.map(|&(_, y)| y)
.collect();
if !at_x.is_empty() {
return at_x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
}
for i in 1..profile.len() {
let (x0, y0) = profile[i - 1];
let (x1, y1) = profile[i];
if x_query < x1 {
if (x1 - x0).abs() < 1e-15 {
return y1;
}
let t = (x_query - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
profile[profile.len() - 1].1
}
fn interpolate_envelope_max(envelope: &[(f64, f64)], t_ms: f64) -> f64 {
let mut at_t: Vec<f64> = envelope
.iter()
.filter(|&&(t, _)| (t - t_ms).abs() < 1e-9)
.map(|&(_, v)| v)
.collect();
if !at_t.is_empty() {
at_t.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
return *at_t.last().unwrap_or(&0.0);
}
interpolate_profile(envelope, t_ms)
}
#[cfg(test)]
mod tests {
use super::*;
fn entso_config() -> ComplianceCheckerConfig {
ComplianceCheckerConfig {
grid_code: GridCodeStandard::EntsoE2016,
generator_type: GeneratorType::WindOnshore,
rated_mw: 10.0,
rated_voltage_kv: 33.0,
connection_point_kv: 33.0,
}
}
fn good_lvrt() -> Vec<(f64, f64)> {
vec![
(0.0, 0.0),
(150.0, 0.0),
(150.0, 0.90), (500.0, 0.90),
(1500.0, 0.95),
(3000.0, 1.0),
]
}
#[test]
fn test_compliant_generator_all_pass() {
let checker = ComplianceChecker::new(entso_config());
let report = checker
.assess(
"gen1",
&good_lvrt(),
(-0.4, 0.4),
(0.92, 0.92),
5.0,
3.0,
3.0,
5.0,
)
.expect("assess must succeed");
assert!(
report.overall_compliant,
"all tests should pass: {:?}",
report.tests
);
assert_eq!(report.critical_failures, 0);
assert_eq!(report.major_failures, 0);
assert!(
(report.compliance_score_pct - 100.0).abs() < 1e-3,
"score should be 100 %, got {:.2}",
report.compliance_score_pct
);
}
#[test]
fn test_lvrt_failure_is_critical() {
let checker = ComplianceChecker::new(entso_config());
let bad_lvrt = vec![
(0.0, 0.0),
(300.0, 0.0), (500.0, 0.5),
(3000.0, 0.8),
];
let report = checker
.assess(
"gen2",
&bad_lvrt,
(-0.4, 0.4),
(0.92, 0.92),
5.0,
3.0,
3.0,
5.0,
)
.unwrap();
assert!(
report.critical_failures > 0,
"LVRT violation must be flagged as Critical"
);
assert!(!report.overall_compliant);
}
#[test]
fn test_reactive_capability_major_failure() {
let checker = ComplianceChecker::new(entso_config());
let report = checker
.assess(
"gen3",
&good_lvrt(),
(-0.05, 0.05),
(0.999, 0.999),
5.0,
3.0,
3.0,
5.0,
)
.unwrap();
assert!(
report.major_failures > 0,
"Insufficient Q range must be flagged as Major"
);
}
#[test]
fn test_score_100_for_fully_compliant() {
let config = ComplianceCheckerConfig {
grid_code: GridCodeStandard::IecIec61400,
generator_type: GeneratorType::WindOnshore,
rated_mw: 3.0,
rated_voltage_kv: 33.0,
connection_point_kv: 33.0,
};
let checker = ComplianceChecker::new(config);
let report = checker
.assess(
"gen4",
&good_lvrt(),
(-0.45, 0.45),
(0.92, 0.92),
4.0,
2.0,
2.0,
4.0,
)
.unwrap();
assert!(
(report.compliance_score_pct - 100.0).abs() < 1e-3,
"score should be 100 %, got {:.2}",
report.compliance_score_pct
);
}
#[test]
fn test_recommendations_generated_for_failures() {
let config = ComplianceCheckerConfig {
grid_code: GridCodeStandard::Germany2018,
generator_type: GeneratorType::SolarPv,
rated_mw: 1.0,
rated_voltage_kv: 0.4,
connection_point_kv: 0.4,
};
let checker = ComplianceChecker::new(config);
let bad_lvrt = vec![
(0.0, 0.0),
(500.0, 0.0), (3000.0, 0.5),
];
let report = checker
.assess(
"gen5",
&bad_lvrt,
(-0.05, 0.05),
(0.999, 0.999),
5.0,
3.0,
3.0,
5.0,
)
.unwrap();
assert!(
!report.recommendations.is_empty(),
"recommendations must be generated for failures"
);
}
#[test]
fn test_nerc_standard_625ms_zero_voltage_allowed() {
let config = ComplianceCheckerConfig {
grid_code: GridCodeStandard::NercNerc2022,
generator_type: GeneratorType::WindOnshore,
rated_mw: 100.0,
rated_voltage_kv: 138.0,
connection_point_kv: 138.0,
};
let checker = ComplianceChecker::new(config);
let lvrt = vec![(0.0, 0.0), (625.0, 0.0), (626.0, 0.9), (3000.0, 0.95)];
let report = checker
.assess(
"gen6",
&lvrt,
(-0.35, 0.35),
(0.94, 0.94),
5.0,
2.0,
3.0,
6.0,
)
.unwrap();
let lvrt_tests: Vec<_> = report
.tests
.iter()
.filter(|t| matches!(t.category, ComplianceCategory::LowVoltageRideThrough))
.collect();
assert!(
lvrt_tests.iter().all(|t| t.passed),
"NERC LVRT: 625 ms zero voltage must pass"
);
}
#[test]
fn test_interpolate_profile_bounds() {
let profile = vec![(0.0, 0.0), (100.0, 1.0), (200.0, 0.5)];
assert!((interpolate_profile(&profile, -10.0) - 0.0).abs() < 1e-12);
assert!((interpolate_profile(&profile, 300.0) - 0.5).abs() < 1e-12);
assert!((interpolate_profile(&profile, 50.0) - 0.5).abs() < 1e-12);
assert!((interpolate_profile(&profile, 150.0) - 0.75).abs() < 1e-12);
}
#[test]
fn test_aus_nem_standard() {
let config = ComplianceCheckerConfig {
grid_code: GridCodeStandard::AusNem,
generator_type: GeneratorType::SolarPv,
rated_mw: 50.0,
rated_voltage_kv: 66.0,
connection_point_kv: 66.0,
};
let checker = ComplianceChecker::new(config);
let lvrt = vec![
(0.0, 0.0),
(200.0, 0.0),
(200.0, 0.75), (2000.0, 0.95),
];
let report = checker
.assess("gen7", &lvrt, (-0.4, 0.4), (0.92, 0.92), 5.0, 5.0, 3.0, 5.0)
.unwrap();
let lvrt_tests: Vec<_> = report
.tests
.iter()
.filter(|t| matches!(t.category, ComplianceCategory::LowVoltageRideThrough))
.collect();
assert!(
lvrt_tests.iter().all(|t| t.passed),
"AusNEM LVRT test should pass with compliant profile"
);
}
}