use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum IslandResyncError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("invalid time step dt_s={0}: must be positive")]
InvalidTimeStep(f64),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IslandDetectionConfig {
pub nominal_freq_hz: f64,
pub nominal_voltage_pu: f64,
pub detection_methods: Vec<IslandDetectionMethod>,
pub confirmation_time_ms: f64,
pub reconnect_criteria: ResyncCriteria,
}
impl Default for IslandDetectionConfig {
fn default() -> Self {
Self {
nominal_freq_hz: 60.0,
nominal_voltage_pu: 1.0,
detection_methods: vec![
IslandDetectionMethod::Rocof {
threshold_hz_per_s: 1.0,
},
IslandDetectionMethod::Voltage {
under_pu: 0.88,
over_pu: 1.10,
},
IslandDetectionMethod::Frequency {
under_hz: 59.3,
over_hz: 60.5,
},
],
confirmation_time_ms: 160.0,
reconnect_criteria: ResyncCriteria::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum IslandDetectionMethod {
Rocof {
threshold_hz_per_s: f64,
},
Voltage {
under_pu: f64,
over_pu: f64,
},
Frequency {
under_hz: f64,
over_hz: f64,
},
VectorShift {
threshold_deg: f64,
},
ReactiveExport {
threshold_mvar: f64,
},
HarmonicDistortion {
thd_increase_pct: f64,
},
}
impl IslandDetectionMethod {
pub fn name(&self) -> &'static str {
match self {
Self::Rocof { .. } => "ROCOF",
Self::Voltage { .. } => "UnderOverVoltage",
Self::Frequency { .. } => "UnderOverFrequency",
Self::VectorShift { .. } => "VectorShift",
Self::ReactiveExport { .. } => "ReactiveExport",
Self::HarmonicDistortion { .. } => "HarmonicDistortion",
}
}
#[allow(clippy::too_many_arguments)]
fn is_triggered(
&self,
freq_hz: f64,
voltage_pu: f64,
rocof_hz_per_s: f64,
q_export_mvar: f64,
thd_pct: f64,
angle_shift_deg: f64,
nominal_thd_pct: f64,
) -> bool {
match self {
Self::Rocof { threshold_hz_per_s } => rocof_hz_per_s.abs() > *threshold_hz_per_s,
Self::Voltage { under_pu, over_pu } => voltage_pu < *under_pu || voltage_pu > *over_pu,
Self::Frequency { under_hz, over_hz } => freq_hz < *under_hz || freq_hz > *over_hz,
Self::VectorShift { threshold_deg } => angle_shift_deg.abs() > *threshold_deg,
Self::ReactiveExport { threshold_mvar } => q_export_mvar.abs() > *threshold_mvar,
Self::HarmonicDistortion { thd_increase_pct } => {
(thd_pct - nominal_thd_pct) > *thd_increase_pct
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResyncCriteria {
pub voltage_diff_max_pu: f64,
pub angle_diff_max_deg: f64,
pub freq_diff_max_hz: f64,
pub sync_window_s: f64,
}
impl Default for ResyncCriteria {
fn default() -> Self {
Self {
voltage_diff_max_pu: 0.05,
angle_diff_max_deg: 10.0,
freq_diff_max_hz: 0.2,
sync_window_s: 0.5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IslandingState {
pub time_s: f64,
pub is_islanded: bool,
pub detection_method: Option<String>,
pub islanding_start_s: Option<f64>,
pub freq_deviation_hz: f64,
pub voltage_deviation_pu: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResyncResult {
pub resync_attempted: bool,
pub resync_success: bool,
pub resync_time_s: f64,
pub sync_attempts: usize,
pub final_angle_error_deg: f64,
pub final_voltage_error_pu: f64,
pub sync_check_relay_action: String,
}
#[derive(Debug, Clone)]
pub struct ResyncParams {
pub island_freq_hz: f64,
pub island_voltage_pu: f64,
pub island_angle_deg: f64,
pub grid_freq_hz: f64,
pub grid_voltage_pu: f64,
pub grid_angle_deg: f64,
pub governor_response_mw: f64,
pub avr_response_pu: f64,
pub dt_s: f64,
pub max_time_s: f64,
}
#[derive(Debug, Clone)]
pub struct IslandResyncController {
config: IslandDetectionConfig,
nominal_thd_pct: f64,
}
impl IslandResyncController {
pub fn new(config: IslandDetectionConfig) -> Self {
Self {
config,
nominal_thd_pct: 2.0,
}
}
pub fn set_nominal_thd(&mut self, thd_pct: f64) {
self.nominal_thd_pct = thd_pct;
}
pub fn detect_islanding(
&self,
freq_hz: f64,
voltage_pu: f64,
rocof_hz_per_s: f64,
q_export_mvar: f64,
thd_pct: f64,
angle_shift_deg: f64,
) -> IslandingState {
let mut triggered_method: Option<String> = None;
for method in &self.config.detection_methods {
if method.is_triggered(
freq_hz,
voltage_pu,
rocof_hz_per_s,
q_export_mvar,
thd_pct,
angle_shift_deg,
self.nominal_thd_pct,
) {
triggered_method = Some(method.name().to_string());
break;
}
}
let is_islanded = triggered_method.is_some();
let islanding_start_s = if is_islanded { Some(0.0) } else { None };
IslandingState {
time_s: 0.0,
is_islanded,
detection_method: triggered_method,
islanding_start_s,
freq_deviation_hz: freq_hz - self.config.nominal_freq_hz,
voltage_deviation_pu: voltage_pu - self.config.nominal_voltage_pu,
}
}
pub fn simulate_resync(&self, params: &ResyncParams) -> ResyncResult {
if params.dt_s <= 0.0 {
return ResyncResult {
resync_attempted: true,
resync_success: false,
resync_time_s: 0.0,
sync_attempts: 0,
final_angle_error_deg: (params.island_angle_deg - params.grid_angle_deg).abs(),
final_voltage_error_pu: (params.island_voltage_pu - params.grid_voltage_pu).abs(),
sync_check_relay_action: "BLOCKED: invalid dt_s".to_string(),
};
}
let crit = &self.config.reconnect_criteria;
let tau_gov = 5.0_f64;
let freq_gain = (1.0 / tau_gov) * (1.0 + params.governor_response_mw / 100.0).min(10.0);
let k_avr = params.avr_response_pu.max(0.01);
let mut f_isl = params.island_freq_hz;
let mut v_isl = params.island_voltage_pu;
let mut theta_isl = params.island_angle_deg;
let mut theta_grid = params.grid_angle_deg;
let mut t = 0.0_f64;
let mut sync_timer = 0.0_f64;
let mut sync_attempts = 0usize;
loop {
if t >= params.max_time_s {
break;
}
let freq_err = f_isl - params.grid_freq_hz;
f_isl -= freq_gain * freq_err * params.dt_s;
let volt_err = v_isl - params.grid_voltage_pu;
v_isl -= k_avr * volt_err * params.dt_s;
theta_isl += 360.0 * f_isl * params.dt_s;
theta_grid += 360.0 * params.grid_freq_hz * params.dt_s;
t += params.dt_s;
sync_attempts += 1;
let raw_diff = (theta_isl - theta_grid) % 360.0;
let angle_err = if raw_diff > 180.0 {
360.0 - raw_diff
} else if raw_diff < -180.0 {
raw_diff + 360.0
} else {
raw_diff.abs()
};
let volt_err_abs = (v_isl - params.grid_voltage_pu).abs();
let freq_err_abs = (f_isl - params.grid_freq_hz).abs();
if angle_err <= crit.angle_diff_max_deg
&& volt_err_abs <= crit.voltage_diff_max_pu
&& freq_err_abs <= crit.freq_diff_max_hz
{
sync_timer += params.dt_s;
if sync_timer >= crit.sync_window_s {
return ResyncResult {
resync_attempted: true,
resync_success: true,
resync_time_s: t,
sync_attempts,
final_angle_error_deg: angle_err,
final_voltage_error_pu: volt_err_abs,
sync_check_relay_action: "CLOSE — sync criteria met".to_string(),
};
}
} else {
sync_timer = 0.0;
}
}
let raw_diff = (theta_isl - theta_grid) % 360.0;
let angle_err = if raw_diff > 180.0 {
360.0 - raw_diff
} else if raw_diff < -180.0 {
raw_diff + 360.0
} else {
raw_diff.abs()
};
ResyncResult {
resync_attempted: true,
resync_success: false,
resync_time_s: params.max_time_s,
sync_attempts,
final_angle_error_deg: angle_err,
final_voltage_error_pu: (v_isl - params.grid_voltage_pu).abs(),
sync_check_relay_action: "BLOCKED — sync window never achieved".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_ctrl() -> IslandResyncController {
IslandResyncController::new(IslandDetectionConfig::default())
}
#[test]
fn test_rocof_detection() {
let ctrl = default_ctrl();
let state = ctrl.detect_islanding(60.0, 1.0, 2.0, 0.0, 2.0, 0.0);
assert!(
state.is_islanded,
"ROCOF above threshold must trigger islanding"
);
assert_eq!(state.detection_method.as_deref(), Some("ROCOF"));
}
#[test]
fn test_rocof_no_trigger_below_threshold() {
let ctrl = default_ctrl();
let state = ctrl.detect_islanding(60.0, 1.0, 0.5, 0.0, 2.0, 0.0);
assert!(!state.is_islanded, "ROCOF below threshold must not trigger");
assert!(state.detection_method.is_none());
}
#[test]
fn test_voltage_detection() {
let cfg = IslandDetectionConfig {
detection_methods: vec![IslandDetectionMethod::Voltage {
under_pu: 0.88,
over_pu: 1.10,
}],
..IslandDetectionConfig::default()
};
let ctrl = IslandResyncController::new(cfg);
let state = ctrl.detect_islanding(60.0, 0.80, 0.0, 0.0, 2.0, 0.0);
assert!(state.is_islanded, "Under-voltage must trigger islanding");
assert_eq!(state.detection_method.as_deref(), Some("UnderOverVoltage"));
assert!(
state.voltage_deviation_pu < 0.0,
"Deviation must be negative for under-voltage"
);
let state2 = ctrl.detect_islanding(60.0, 1.15, 0.0, 0.0, 2.0, 0.0);
assert!(state2.is_islanded, "Over-voltage must trigger islanding");
}
#[test]
fn test_vector_shift_detection() {
let cfg = IslandDetectionConfig {
detection_methods: vec![IslandDetectionMethod::VectorShift { threshold_deg: 8.0 }],
..IslandDetectionConfig::default()
};
let ctrl = IslandResyncController::new(cfg);
let state = ctrl.detect_islanding(60.0, 1.0, 0.0, 0.0, 2.0, 15.0);
assert!(
state.is_islanded,
"Phase jump > threshold must trigger islanding"
);
assert_eq!(state.detection_method.as_deref(), Some("VectorShift"));
let state2 = ctrl.detect_islanding(60.0, 1.0, 0.0, 0.0, 2.0, 5.0);
assert!(
!state2.is_islanded,
"Phase jump below threshold must not trigger"
);
}
#[test]
fn test_resync_success() {
let cfg = IslandDetectionConfig {
reconnect_criteria: ResyncCriteria {
voltage_diff_max_pu: 0.05,
angle_diff_max_deg: 30.0, freq_diff_max_hz: 0.2,
sync_window_s: 0.2,
},
..IslandDetectionConfig::default()
};
let ctrl = IslandResyncController::new(cfg);
let params = ResyncParams {
island_freq_hz: 60.05, island_voltage_pu: 1.02,
island_angle_deg: 5.0,
grid_freq_hz: 60.0,
grid_voltage_pu: 1.0,
grid_angle_deg: 0.0,
governor_response_mw: 500.0,
avr_response_pu: 10.0,
dt_s: 0.01,
max_time_s: 120.0,
};
let result = ctrl.simulate_resync(¶ms);
assert!(result.resync_attempted);
assert!(
result.resync_success,
"Resync should succeed with strong governor/AVR and small deviation; got: {}",
result.sync_check_relay_action
);
assert!(
result.resync_time_s < 120.0,
"Should converge before timeout"
);
assert!(
result.final_voltage_error_pu <= 0.05,
"Final voltage error must meet criteria"
);
}
#[test]
fn test_resync_failure() {
let ctrl = default_ctrl();
let params = ResyncParams {
island_freq_hz: 55.0,
island_voltage_pu: 1.0,
island_angle_deg: 0.0,
grid_freq_hz: 60.0,
grid_voltage_pu: 1.0,
grid_angle_deg: 0.0,
governor_response_mw: 0.5,
avr_response_pu: 0.05,
dt_s: 0.1,
max_time_s: 1.5,
};
let result = ctrl.simulate_resync(¶ms);
assert!(result.resync_attempted);
assert!(
!result.resync_success,
"Resync should fail with weak response and short time"
);
assert_eq!(result.resync_time_s, 1.5);
}
}