#[derive(Debug, Clone, PartialEq)]
pub enum VoltageRecoveryStatus {
FullRecovery,
SlowRecovery,
NonRecovery,
Collapse,
Oscillatory,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FaultType {
ThreePhase,
SingleLineToGround,
LineToLine,
DoubleLineToGround,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StallType {
SinglePhaseMotor,
ThreePhaseMotor,
HvacCompressor,
Industrial,
}
#[derive(Debug, Clone)]
pub struct VoltageTrajectory {
pub bus_id: usize,
pub time_s: Vec<f64>,
pub voltage_pu: Vec<f64>,
pub post_fault_min_pu: f64,
pub recovery_time_s: f64,
pub status: VoltageRecoveryStatus,
pub motor_stalls: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct MotorLoad {
pub id: usize,
pub bus_id: usize,
pub stall_type: StallType,
pub rated_mw: f64,
pub rated_mvar: f64,
pub stall_voltage_pu: f64,
pub reconnect_voltage_pu: f64,
pub stall_time_s: f64,
pub thermal_trip_time_s: f64,
pub stalled: bool,
pub stall_start_time: Option<f64>,
pub tripped: bool,
}
impl MotorLoad {
pub fn new(
id: usize,
bus_id: usize,
stall_type: StallType,
rated_mw: f64,
rated_mvar: f64,
) -> Self {
Self {
id,
bus_id,
stall_type,
rated_mw,
rated_mvar,
stall_voltage_pu: 0.65,
reconnect_voltage_pu: 0.80,
stall_time_s: 0.5,
thermal_trip_time_s: 3.0,
stalled: false,
stall_start_time: None,
tripped: false,
}
}
}
#[derive(Debug, Clone)]
pub struct FaultEvent {
pub bus_id: usize,
pub fault_type: FaultType,
pub fault_impedance_pu: f64,
pub fault_time_s: f64,
pub clearing_time_s: f64,
pub pre_fault_voltage_pu: f64,
}
impl FaultEvent {
pub fn new(
bus_id: usize,
fault_type: FaultType,
fault_impedance_pu: f64,
fault_time_s: f64,
pre_fault_voltage_pu: f64,
) -> Self {
Self {
bus_id,
fault_type,
fault_impedance_pu,
fault_time_s,
clearing_time_s: fault_time_s + 0.1,
pre_fault_voltage_pu,
}
}
}
#[derive(Debug, Clone)]
pub struct BusVoltageModel {
pub bus_id: usize,
pub v_pre_fault_pu: f64,
pub v_during_fault_pu: f64,
pub v_post_fault_pu: f64,
pub time_constant_s: f64,
pub motor_reactive_demand_mvar: f64,
}
impl BusVoltageModel {
pub fn new(bus_id: usize, v_pre_fault_pu: f64, v_post_fault_pu: f64) -> Self {
Self {
bus_id,
v_pre_fault_pu,
v_during_fault_pu: v_pre_fault_pu,
v_post_fault_pu,
time_constant_s: 0.5,
motor_reactive_demand_mvar: 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct TvsaResult {
pub fault_event: FaultEvent,
pub voltage_trajectories: Vec<VoltageTrajectory>,
pub stalled_motors: Vec<usize>,
pub tripped_motors: Vec<usize>,
pub worst_bus_id: usize,
pub worst_sag_pu: f64,
pub recovery_index: f64,
pub voltage_stability_margin_pu: f64,
pub overall_status: VoltageRecoveryStatus,
}
#[derive(Debug, Clone)]
pub struct TvsaEngine {
pub buses: Vec<BusVoltageModel>,
pub motor_loads: Vec<MotorLoad>,
pub dt_s: f64,
pub simulation_duration_s: f64,
pub voltage_threshold_pu: f64,
pub collapse_threshold_pu: f64,
pub q_max_available_mvar: f64,
}
impl TvsaEngine {
const Z_THEVENIN: f64 = 0.1;
const OSCILLATORY_SIGN_CHANGES: u32 = 6;
pub fn new(buses: Vec<BusVoltageModel>) -> Self {
Self {
buses,
motor_loads: Vec::new(),
dt_s: 0.01,
simulation_duration_s: 10.0,
voltage_threshold_pu: 0.95,
collapse_threshold_pu: 0.50,
q_max_available_mvar: 100.0,
}
}
pub fn with_q_headroom(mut self, mvar: f64) -> Self {
self.q_max_available_mvar = mvar;
self
}
pub fn add_motor_load(&mut self, motor: MotorLoad) {
self.motor_loads.push(motor);
}
pub fn run_assessment(&mut self, fault: FaultEvent) -> TvsaResult {
for m in &mut self.motor_loads {
m.stalled = false;
m.stall_start_time = None;
m.tripped = false;
}
for b in &mut self.buses {
b.motor_reactive_demand_mvar = 0.0;
}
let buses_snapshot: Vec<BusVoltageModel> = self.buses.clone();
for bus in &mut self.buses {
bus.v_during_fault_pu = Self::compute_during_fault_voltage(
bus.v_pre_fault_pu,
fault.fault_impedance_pu,
Self::Z_THEVENIN,
);
}
let mut trajectories: Vec<VoltageTrajectory> = Vec::new();
for bus in &buses_snapshot {
let bus_updated = self
.buses
.iter()
.find(|b| b.bus_id == bus.bus_id)
.cloned()
.unwrap_or_else(|| bus.clone());
let traj = self.simulate_voltage_trajectory(&bus_updated, &fault);
trajectories.push(traj);
}
let stalled_motors: Vec<usize> = self
.motor_loads
.iter()
.filter(|m| m.stalled || m.tripped)
.map(|m| m.id)
.collect();
let tripped_motors: Vec<usize> = self
.motor_loads
.iter()
.filter(|m| m.tripped)
.map(|m| m.id)
.collect();
let (worst_bus_id, worst_sag_pu) = trajectories
.iter()
.map(|t| (t.bus_id, t.post_fault_min_pu))
.fold((0usize, f64::MAX), |(wb, ws), (bid, sag)| {
if sag < ws {
(bid, sag)
} else {
(wb, ws)
}
});
let worst_sag_pu = if worst_sag_pu == f64::MAX {
1.0
} else {
worst_sag_pu
};
let recovery_index = self.compute_recovery_index(&trajectories);
let overall_status = trajectories
.iter()
.find(|t| t.bus_id == worst_bus_id)
.map(|t| t.status.clone())
.unwrap_or(VoltageRecoveryStatus::FullRecovery);
let mut result = TvsaResult {
fault_event: fault,
voltage_trajectories: trajectories,
stalled_motors,
tripped_motors,
worst_bus_id,
worst_sag_pu,
recovery_index,
voltage_stability_margin_pu: 0.0,
overall_status,
};
result.voltage_stability_margin_pu = self.compute_stability_margin(&result);
result
}
pub fn simulate_voltage_trajectory(
&mut self,
bus: &BusVoltageModel,
fault: &FaultEvent,
) -> VoltageTrajectory {
let n_steps = ((self.simulation_duration_s / self.dt_s).ceil() as usize).max(1);
let mut time_s = Vec::with_capacity(n_steps);
let mut voltage_pu = Vec::with_capacity(n_steps);
let mut post_fault_min = bus.v_pre_fault_pu;
let mut recovery_time_s = self.simulation_duration_s; let mut reached_threshold = false;
let mut motor_stalls_on_bus: Vec<usize> = Vec::new();
let mut sign_changes: u32 = 0;
let mut prev_dv: f64 = 0.0;
for step in 0..n_steps {
let t = step as f64 * self.dt_s;
time_s.push(t);
let v = if t < fault.fault_time_s {
bus.v_pre_fault_pu
} else if t < fault.clearing_time_s {
bus.v_during_fault_pu
} else {
let tau = bus.time_constant_s.max(1e-9);
let t_since_clear = t - fault.clearing_time_s;
let motor_q: f64 = self
.motor_loads
.iter()
.filter(|m| m.bus_id == bus.bus_id && m.stalled && !m.tripped)
.map(|m| m.rated_mvar * 3.0)
.sum();
let q_penalty = if self.q_max_available_mvar > 0.0 {
(motor_q / self.q_max_available_mvar).min(0.5)
} else {
0.0
};
let v_post_eq = (bus.v_post_fault_pu - q_penalty).max(0.0);
let v_calc = v_post_eq
+ (bus.v_during_fault_pu - v_post_eq) * (-(t_since_clear / tau)).exp();
v_calc.clamp(0.0, 1.2)
};
voltage_pu.push(v);
self.step_motor_dynamics(bus.bus_id, v, t);
for m in &self.motor_loads {
if m.bus_id == bus.bus_id && m.stalled && !motor_stalls_on_bus.contains(&m.id) {
motor_stalls_on_bus.push(m.id);
}
}
if t >= fault.fault_time_s && v < post_fault_min {
post_fault_min = v;
}
if t >= fault.clearing_time_s && !reached_threshold && v >= self.voltage_threshold_pu {
recovery_time_s = t - fault.clearing_time_s;
reached_threshold = true;
}
if step > 0 {
let dv = v - voltage_pu.get(step - 1).copied().unwrap_or(v);
if prev_dv * dv < 0.0 {
sign_changes += 1;
}
prev_dv = dv;
}
}
let voltage_at_end = voltage_pu.last().copied().unwrap_or(0.0);
let is_oscillatory = sign_changes >= Self::OSCILLATORY_SIGN_CHANGES
&& voltage_at_end > self.collapse_threshold_pu;
let status = if is_oscillatory {
VoltageRecoveryStatus::Oscillatory
} else {
Self::classify_voltage_recovery(post_fault_min, recovery_time_s, voltage_at_end)
};
if !reached_threshold {
recovery_time_s = self.simulation_duration_s;
}
VoltageTrajectory {
bus_id: bus.bus_id,
time_s,
voltage_pu,
post_fault_min_pu: post_fault_min,
recovery_time_s,
status,
motor_stalls: motor_stalls_on_bus,
}
}
pub fn compute_during_fault_voltage(v_pre: f64, fault_impedance: f64, z_thevenin: f64) -> f64 {
let denom = z_thevenin + fault_impedance;
if denom < 1e-12 {
return 0.0;
}
(v_pre * z_thevenin / denom).clamp(0.0, v_pre)
}
pub fn step_motor_dynamics(&mut self, bus_id: usize, v_bus_pu: f64, t_s: f64) {
for motor in &mut self.motor_loads {
if motor.bus_id != bus_id || motor.tripped {
continue;
}
if !motor.stalled {
if v_bus_pu < motor.stall_voltage_pu {
motor.stalled = true;
motor.stall_start_time = Some(t_s);
}
} else {
let stall_elapsed = t_s - motor.stall_start_time.unwrap_or(t_s);
if stall_elapsed >= motor.thermal_trip_time_s {
motor.tripped = true;
motor.stalled = false;
continue;
}
if v_bus_pu >= motor.reconnect_voltage_pu {
motor.stalled = false;
motor.stall_start_time = None;
}
}
}
}
pub fn compute_recovery_index(&self, trajectories: &[VoltageTrajectory]) -> f64 {
if trajectories.is_empty() {
return 1.0;
}
let sum: f64 = trajectories
.iter()
.map(|t| match &t.status {
VoltageRecoveryStatus::FullRecovery => 1.0,
VoltageRecoveryStatus::SlowRecovery => 0.6,
VoltageRecoveryStatus::Oscillatory => 0.4,
VoltageRecoveryStatus::NonRecovery => 0.2,
VoltageRecoveryStatus::Collapse => 0.0,
})
.sum();
(sum / trajectories.len() as f64).clamp(0.0, 1.0)
}
pub fn classify_voltage_recovery(
_min_v: f64,
recovery_time: f64,
voltage_at_end: f64,
) -> VoltageRecoveryStatus {
if voltage_at_end < 0.50 {
return VoltageRecoveryStatus::Collapse;
}
if voltage_at_end < 0.95 {
return VoltageRecoveryStatus::NonRecovery;
}
if recovery_time >= 2.0 {
return VoltageRecoveryStatus::SlowRecovery;
}
VoltageRecoveryStatus::FullRecovery
}
pub fn compute_stability_margin(&self, result: &TvsaResult) -> f64 {
result.worst_sag_pu - self.collapse_threshold_pu
}
pub fn run_n1_assessment(&mut self, faults: &[FaultEvent]) -> Vec<TvsaResult> {
faults
.iter()
.map(|f| self.run_assessment(f.clone()))
.collect()
}
pub fn identify_critical_buses(&self, results: &[TvsaResult]) -> Vec<(usize, f64)> {
use std::collections::HashMap;
let mut worst_per_bus: HashMap<usize, f64> = HashMap::new();
for result in results {
for traj in &result.voltage_trajectories {
let entry = worst_per_bus.entry(traj.bus_id).or_insert(f64::MAX);
if traj.post_fault_min_pu < *entry {
*entry = traj.post_fault_min_pu;
}
}
}
let mut pairs: Vec<(usize, f64)> = worst_per_bus.into_iter().collect();
pairs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
pairs
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_bus(bus_id: usize) -> BusVoltageModel {
BusVoltageModel {
bus_id,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.5,
v_post_fault_pu: 0.98,
time_constant_s: 0.3,
motor_reactive_demand_mvar: 0.0,
}
}
fn simple_fault(bus_id: usize, z_fault: f64) -> FaultEvent {
FaultEvent::new(bus_id, FaultType::ThreePhase, z_fault, 0.1, 1.0)
}
#[test]
fn test_voltage_during_fault_zero_impedance() {
let v = TvsaEngine::compute_during_fault_voltage(1.0, 0.0, 0.1);
assert!(
(v - 1.0).abs() < 1e-9,
"bolted fault remote-bus voltage should equal pre-fault: v={v}"
);
}
#[test]
fn test_voltage_during_fault_high_impedance() {
let v = TvsaEngine::compute_during_fault_voltage(1.0, 100.0, 0.1);
assert!(v < 0.01, "high-z fault: v={v}");
}
#[test]
fn test_voltage_during_fault_moderate_impedance() {
let v = TvsaEngine::compute_during_fault_voltage(1.0, 0.4, 0.1);
assert!((v - 0.2).abs() < 1e-9, "moderate-z: v={v}");
}
#[test]
fn test_recovery_classification_full() {
let s = TvsaEngine::classify_voltage_recovery(0.70, 1.5, 0.97);
assert_eq!(s, VoltageRecoveryStatus::FullRecovery);
}
#[test]
fn test_recovery_classification_slow() {
let s = TvsaEngine::classify_voltage_recovery(0.60, 4.0, 0.96);
assert_eq!(s, VoltageRecoveryStatus::SlowRecovery);
}
#[test]
fn test_recovery_classification_collapse() {
let s = TvsaEngine::classify_voltage_recovery(0.30, 12.0, 0.40);
assert_eq!(s, VoltageRecoveryStatus::Collapse);
}
#[test]
fn test_recovery_classification_non_recovery() {
let s = TvsaEngine::classify_voltage_recovery(0.55, 12.0, 0.80);
assert_eq!(s, VoltageRecoveryStatus::NonRecovery);
}
#[test]
fn test_motor_stall_at_low_voltage() {
let mut engine = TvsaEngine::new(vec![simple_bus(0)]);
let motor = MotorLoad::new(0, 0, StallType::HvacCompressor, 1.0, 0.5);
engine.add_motor_load(motor);
engine.step_motor_dynamics(0, 0.50, 0.0); assert!(
engine.motor_loads[0].stalled,
"motor should stall at 0.50 pu"
);
}
#[test]
fn test_motor_no_stall_high_voltage() {
let mut engine = TvsaEngine::new(vec![simple_bus(0)]);
let motor = MotorLoad::new(0, 0, StallType::ThreePhaseMotor, 2.0, 1.0);
engine.add_motor_load(motor);
engine.step_motor_dynamics(0, 0.90, 0.0); assert!(
!engine.motor_loads[0].stalled,
"motor should NOT stall at 0.90 pu"
);
}
#[test]
fn test_motor_reconnect_after_recovery() {
let mut engine = TvsaEngine::new(vec![simple_bus(0)]);
let mut motor = MotorLoad::new(0, 0, StallType::SinglePhaseMotor, 1.0, 0.4);
motor.stalled = true;
motor.stall_start_time = Some(0.0);
engine.add_motor_load(motor);
engine.step_motor_dynamics(0, 0.85, 0.3);
assert!(
!engine.motor_loads[0].stalled,
"motor should reconnect when V > reconnect_voltage"
);
}
#[test]
fn test_motor_thermal_trip() {
let mut engine = TvsaEngine::new(vec![simple_bus(0)]);
let mut motor = MotorLoad::new(0, 0, StallType::Industrial, 3.0, 1.5);
motor.stalled = true;
motor.stall_start_time = Some(0.0); engine.add_motor_load(motor);
engine.step_motor_dynamics(0, 0.50, 4.0);
assert!(engine.motor_loads[0].tripped, "motor should thermally trip");
assert!(
!engine.motor_loads[0].stalled,
"tripped motor should not be stalled"
);
}
#[test]
fn test_trajectory_simulation_single_bus() {
let bus = simple_bus(0);
let mut engine = TvsaEngine::new(vec![bus.clone()]);
engine.simulation_duration_s = 2.0;
engine.dt_s = 0.01;
let fault = simple_fault(0, 0.2);
let traj = engine.simulate_voltage_trajectory(&bus, &fault);
let expected = ((2.0_f64 / 0.01).ceil() as usize).max(1);
assert_eq!(traj.time_s.len(), expected);
assert_eq!(traj.voltage_pu.len(), expected);
}
#[test]
fn test_trajectory_min_voltage() {
let bus = BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.3,
v_post_fault_pu: 0.97,
time_constant_s: 0.5,
motor_reactive_demand_mvar: 0.0,
};
let mut engine = TvsaEngine::new(vec![bus.clone()]);
let fault = FaultEvent {
bus_id: 0,
fault_type: FaultType::SingleLineToGround,
fault_impedance_pu: 0.3,
fault_time_s: 0.1,
clearing_time_s: 0.2,
pre_fault_voltage_pu: 1.0,
};
let traj = engine.simulate_voltage_trajectory(&bus, &fault);
assert!(
traj.post_fault_min_pu < 1.0,
"min voltage {} should be < pre-fault 1.0",
traj.post_fault_min_pu
);
}
#[test]
fn test_recovery_time_positive() {
let bus = BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.6,
v_post_fault_pu: 0.97,
time_constant_s: 0.3,
motor_reactive_demand_mvar: 0.0,
};
let mut engine = TvsaEngine::new(vec![bus.clone()]);
let fault = simple_fault(0, 0.2);
let traj = engine.simulate_voltage_trajectory(&bus, &fault);
assert!(
traj.recovery_time_s >= 0.0,
"recovery_time must be ≥ 0, got {}",
traj.recovery_time_s
);
}
#[test]
fn test_run_assessment_basic() {
let bus = simple_bus(0);
let mut engine = TvsaEngine::new(vec![bus]);
let fault = simple_fault(0, 0.2);
let result = engine.run_assessment(fault);
assert_eq!(result.voltage_trajectories.len(), 1);
assert!(result.worst_sag_pu >= 0.0);
assert!(result.worst_sag_pu <= 1.2);
}
#[test]
fn test_worst_bus_identification() {
let bus0 = BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.8,
v_post_fault_pu: 0.98,
time_constant_s: 0.3,
motor_reactive_demand_mvar: 0.0,
};
let bus1 = BusVoltageModel {
bus_id: 1,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.2, v_post_fault_pu: 0.97,
time_constant_s: 0.5,
motor_reactive_demand_mvar: 0.0,
};
let mut engine = TvsaEngine::new(vec![bus0, bus1]);
let fault = FaultEvent {
bus_id: 1,
fault_type: FaultType::ThreePhase,
fault_impedance_pu: 10.0, fault_time_s: 0.1,
clearing_time_s: 0.2,
pre_fault_voltage_pu: 1.0,
};
let result = engine.run_assessment(fault);
assert!(
result.worst_bus_id == 0 || result.worst_bus_id == 1,
"worst_bus_id should be 0 or 1, got {}",
result.worst_bus_id
);
let min_traj = result
.voltage_trajectories
.iter()
.map(|t| t.post_fault_min_pu)
.fold(f64::MAX, f64::min);
assert!(
(result.worst_sag_pu - min_traj).abs() < 1e-9,
"worst_sag_pu should equal minimum trajectory sag"
);
}
#[test]
fn test_recovery_index_bounds() {
let buses: Vec<BusVoltageModel> = (0..5).map(simple_bus).collect();
let mut engine = TvsaEngine::new(buses);
let fault = simple_fault(0, 0.2);
let result = engine.run_assessment(fault);
assert!(
result.recovery_index >= 0.0 && result.recovery_index <= 1.0,
"recovery_index={} out of [0,1]",
result.recovery_index
);
}
#[test]
fn test_stability_margin_positive_stable() {
let bus = BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.75,
v_post_fault_pu: 0.98,
time_constant_s: 0.3,
motor_reactive_demand_mvar: 0.0,
};
let mut engine = TvsaEngine::new(vec![bus]);
let fault = simple_fault(0, 0.05);
let result = engine.run_assessment(fault);
assert!(
result.voltage_stability_margin_pu > 0.0,
"margin={} should be positive for stable scenario",
result.voltage_stability_margin_pu
);
}
#[test]
fn test_stability_margin_negative_collapse() {
let bus = simple_bus(0);
let engine = TvsaEngine::new(vec![bus]);
let result = TvsaResult {
fault_event: simple_fault(0, 0.0),
voltage_trajectories: vec![],
stalled_motors: vec![],
tripped_motors: vec![],
worst_bus_id: 0,
worst_sag_pu: 0.20, recovery_index: 0.0,
voltage_stability_margin_pu: 0.0,
overall_status: VoltageRecoveryStatus::Collapse,
};
let margin = engine.compute_stability_margin(&result);
assert!(
margin < 0.0,
"margin={} should be negative for collapsed system",
margin
);
}
#[test]
fn test_n1_assessment_multiple_faults() {
let buses: Vec<BusVoltageModel> = (0..3).map(simple_bus).collect();
let mut engine = TvsaEngine::new(buses);
let faults: Vec<FaultEvent> = (0..3).map(|i| simple_fault(i, 0.2)).collect();
let results = engine.run_n1_assessment(&faults);
assert_eq!(results.len(), 3, "one result per fault");
}
#[test]
fn test_critical_buses_sorted() {
let buses: Vec<BusVoltageModel> = (0..3).map(simple_bus).collect();
let mut engine = TvsaEngine::new(buses);
let faults: Vec<FaultEvent> = (0..3).map(|i| simple_fault(i, 0.2)).collect();
let results = engine.run_n1_assessment(&faults);
let critical = engine.identify_critical_buses(&results);
assert!(!critical.is_empty());
for pair in critical.windows(2) {
assert!(
pair[0].1 <= pair[1].1,
"critical buses not sorted: {:?} > {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn test_stalled_motors_increase_reactive() {
let bus = BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 0.4, v_post_fault_pu: 0.98,
time_constant_s: 0.5,
motor_reactive_demand_mvar: 0.0,
};
let mut engine = TvsaEngine::new(vec![bus.clone()]);
let motor = MotorLoad::new(0, 0, StallType::HvacCompressor, 2.0, 1.0);
engine.add_motor_load(motor);
let fault = FaultEvent {
bus_id: 0,
fault_type: FaultType::ThreePhase,
fault_impedance_pu: 0.0,
fault_time_s: 0.1,
clearing_time_s: 0.2,
pre_fault_voltage_pu: 1.0,
};
let traj = engine.simulate_voltage_trajectory(&bus, &fault);
assert!(
!traj.motor_stalls.is_empty(),
"stalled motors should be recorded in trajectory"
);
}
#[test]
fn test_fault_event_default_clearing_time() {
let fault = FaultEvent::new(0, FaultType::DoubleLineToGround, 0.0, 0.5, 1.0);
assert!(
(fault.clearing_time_s - 0.6).abs() < 1e-9,
"clearing_time should be 0.6"
);
}
#[test]
fn test_motor_load_defaults() {
let m = MotorLoad::new(7, 3, StallType::Industrial, 5.0, 2.5);
assert!((m.stall_voltage_pu - 0.65).abs() < 1e-9);
assert!((m.reconnect_voltage_pu - 0.80).abs() < 1e-9);
assert!((m.stall_time_s - 0.5).abs() < 1e-9);
assert!((m.thermal_trip_time_s - 3.0).abs() < 1e-9);
assert!(!m.stalled);
assert!(!m.tripped);
assert!(m.stall_start_time.is_none());
}
#[test]
fn test_engine_defaults() {
let engine = TvsaEngine::new(vec![]);
assert!((engine.dt_s - 0.01).abs() < 1e-9);
assert!((engine.simulation_duration_s - 10.0).abs() < 1e-9);
assert!((engine.voltage_threshold_pu - 0.95).abs() < 1e-9);
assert!((engine.collapse_threshold_pu - 0.50).abs() < 1e-9);
}
#[test]
fn test_recovery_index_empty() {
let engine = TvsaEngine::new(vec![]);
let idx = engine.compute_recovery_index(&[]);
assert!((idx - 1.0).abs() < 1e-9, "empty trajectories → index = 1.0");
}
#[test]
fn test_tvsa_q_headroom_scales_motor_penalty() {
let make_bus = || BusVoltageModel {
bus_id: 0,
v_pre_fault_pu: 1.0,
v_during_fault_pu: 1.0, v_post_fault_pu: 0.97,
time_constant_s: 0.3,
motor_reactive_demand_mvar: 0.0,
};
let make_fault = || FaultEvent {
bus_id: 0,
fault_type: FaultType::ThreePhase,
fault_impedance_pu: 0.4,
fault_time_s: 0.1,
clearing_time_s: 0.2,
pre_fault_voltage_pu: 1.0,
};
let make_motor = || {
let mut m = MotorLoad::new(0, 0, StallType::HvacCompressor, 2.0, 5.0);
m.reconnect_voltage_pu = 1.5;
m.thermal_trip_time_s = 1_000.0;
m
};
let mut engine50 = TvsaEngine::new(vec![make_bus()]).with_q_headroom(50.0);
engine50.add_motor_load(make_motor());
let result50 = engine50.run_assessment(make_fault());
assert!(
!result50.stalled_motors.is_empty(),
"engine50: motor should stall during the deep voltage sag"
);
let mut engine200 = TvsaEngine::new(vec![make_bus()]).with_q_headroom(200.0);
engine200.add_motor_load(make_motor());
let result200 = engine200.run_assessment(make_fault());
assert!(
!result200.stalled_motors.is_empty(),
"engine200: motor should stall during the deep voltage sag"
);
let v50 = result50
.voltage_trajectories
.first()
.and_then(|t| t.voltage_pu.last().copied())
.expect("engine50 trajectory should exist");
let v200 = result200
.voltage_trajectories
.first()
.and_then(|t| t.voltage_pu.last().copied())
.expect("engine200 trajectory should exist");
assert!(
v50 < v200,
"lower Q headroom (50 MVAr) should yield lower final recovery voltage: v50={v50:.4} v200={v200:.4}"
);
}
}