use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThermalClass {
ClassA,
ClassB,
ClassF,
ClassH,
}
impl ThermalClass {
pub fn max_temp_celsius(self) -> f64 {
match self {
ThermalClass::ClassA => 105.0,
ThermalClass::ClassB => 130.0,
ThermalClass::ClassF => 155.0,
ThermalClass::ClassH => 180.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotorProtConfig {
pub overload_trip_class: u8,
pub locked_rotor_time_s: f64,
pub phase_unbalance_pct_trip: f64,
pub undervoltage_pu_trip: f64,
pub ground_fault_threshold_a: f64,
pub restart_lockout_s: f64,
pub overload_sf_factor: f64,
}
impl Default for MotorProtConfig {
fn default() -> Self {
Self {
overload_trip_class: 10,
locked_rotor_time_s: 10.0,
phase_unbalance_pct_trip: 5.0,
undervoltage_pu_trip: 0.85,
ground_fault_threshold_a: 1.0,
restart_lockout_s: 300.0,
overload_sf_factor: 1.15,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TripCause {
ThermalOverload,
LockedRotor,
PhaseUnbalance,
GroundFault,
Undervoltage,
ManualTrip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotorTrip {
pub time_s: f64,
pub cause: TripCause,
pub thermal_state_at_trip: f64,
pub current_at_trip_a: f64,
}
#[derive(Debug, Clone)]
pub enum MotorProtError {
ThermalTrip,
LockedRotorTrip,
PhaseUnbalanceTrip(f64),
GroundFaultTrip,
UndervoltageTrip,
}
impl std::fmt::Display for MotorProtError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MotorProtError::ThermalTrip => write!(f, "Motor thermal overload trip"),
MotorProtError::LockedRotorTrip => write!(f, "Motor locked-rotor trip"),
MotorProtError::PhaseUnbalanceTrip(pct) => {
write!(f, "Phase unbalance trip: {:.2} %", pct)
}
MotorProtError::GroundFaultTrip => write!(f, "Motor ground-fault trip"),
MotorProtError::UndervoltageTrip => write!(f, "Motor under-voltage trip"),
}
}
}
impl std::error::Error for MotorProtError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotorProtStatus {
pub thermal_state: f64,
pub thermal_alarm: bool,
pub thermal_trip: bool,
pub phase_unbalance_pct: f64,
pub phase_unbalance_trip: bool,
pub locked_rotor_trip: bool,
pub ground_fault_trip: bool,
pub undervoltage_trip: bool,
pub restart_locked_out: bool,
pub trip_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MotorProtectionRelay {
pub motor_id: String,
pub rated_current_a: f64,
pub rated_voltage_kv: f64,
pub service_factor: f64,
pub thermal_class: ThermalClass,
pub config: MotorProtConfig,
thermal_state: f64,
last_update_s: f64,
trip_history: Vec<MotorTrip>,
stall_time_s: f64,
undervoltage_timer_s: f64,
last_trip_time_s: f64,
last_unbalance_pct: f64,
tripped_thermal: bool,
tripped_phase: bool,
tripped_locked_rotor: bool,
tripped_ground: bool,
tripped_undervoltage: bool,
}
impl MotorProtectionRelay {
pub fn new(
motor_id: String,
rated_current_a: f64,
rated_voltage_kv: f64,
service_factor: f64,
thermal_class: ThermalClass,
config: MotorProtConfig,
) -> Self {
Self {
motor_id,
rated_current_a,
rated_voltage_kv,
service_factor,
thermal_class,
config,
thermal_state: 0.0,
last_update_s: 0.0,
trip_history: Vec::new(),
stall_time_s: 0.0,
undervoltage_timer_s: 0.0,
last_trip_time_s: f64::NEG_INFINITY,
last_unbalance_pct: 0.0,
tripped_thermal: false,
tripped_phase: false,
tripped_locked_rotor: false,
tripped_ground: false,
tripped_undervoltage: false,
}
}
pub fn update_thermal_state(
&mut self,
current_a: f64,
dt_s: f64,
current_time_s: f64,
) -> Result<(), MotorProtError> {
let m = current_a / self.rated_current_a;
let m_sq = m * m;
let theta = self.thermal_state;
let new_theta = if m > 1.0 {
let tau_heat = Self::heating_tau(self.config.overload_trip_class, m);
let d_theta = (m_sq - theta) / tau_heat;
theta + d_theta * dt_s
} else {
let tau_cool = 2.0 * f64::from(self.config.overload_trip_class) * 60.0;
let d_theta = -theta / tau_cool;
theta + d_theta * dt_s
};
self.thermal_state = new_theta.clamp(0.0, 1.5);
self.last_update_s = current_time_s;
if self.thermal_state >= 1.0 {
self.tripped_thermal = true;
self.record_trip(
current_time_s,
TripCause::ThermalOverload,
self.thermal_state,
current_a,
);
return Err(MotorProtError::ThermalTrip);
}
Ok(())
}
fn heating_tau(trip_class: u8, m: f64) -> f64 {
let m_sq = m * m;
let denom = m_sq - 1.0;
if denom < 1e-9 {
return f64::from(trip_class) * 60.0 * 1e6;
}
let ratio = m_sq / denom;
if ratio <= 0.0 {
return f64::from(trip_class) * 60.0;
}
let ln_ratio = ratio.ln();
if ln_ratio < 1e-12 {
return f64::from(trip_class) * 60.0 * 1e6;
}
f64::from(trip_class) * 60.0 / ln_ratio
}
pub fn check_phase_unbalance(
&mut self,
va: f64,
vb: f64,
vc: f64,
current_time_s: f64,
) -> Result<f64, MotorProtError> {
let avg = (va + vb + vc) / 3.0;
if avg < 1e-12 {
self.last_unbalance_pct = 100.0;
self.tripped_phase = true;
self.record_trip(
current_time_s,
TripCause::PhaseUnbalance,
self.thermal_state,
0.0,
);
return Err(MotorProtError::PhaseUnbalanceTrip(100.0));
}
let max_dev = (va - avg).abs().max((vb - avg).abs()).max((vc - avg).abs());
let pct = 100.0 * max_dev / avg;
self.last_unbalance_pct = pct;
if pct > self.config.phase_unbalance_pct_trip {
self.tripped_phase = true;
self.record_trip(
current_time_s,
TripCause::PhaseUnbalance,
self.thermal_state,
0.0,
);
return Err(MotorProtError::PhaseUnbalanceTrip(pct));
}
Ok(pct)
}
pub fn check_locked_rotor(
&mut self,
current_a: f64,
speed_rpm: f64,
rated_rpm: f64,
dt_s: f64,
current_time_s: f64,
) -> Result<(), MotorProtError> {
let stalled = speed_rpm < 0.05 * rated_rpm && current_a > 3.0 * self.rated_current_a;
if stalled {
self.stall_time_s += dt_s;
if self.stall_time_s > self.config.locked_rotor_time_s {
self.tripped_locked_rotor = true;
self.record_trip(
current_time_s,
TripCause::LockedRotor,
self.thermal_state,
current_a,
);
return Err(MotorProtError::LockedRotorTrip);
}
} else {
self.stall_time_s = 0.0;
}
Ok(())
}
pub fn check_ground_fault(
&mut self,
ig_a: f64,
current_time_s: f64,
) -> Result<(), MotorProtError> {
if ig_a.abs() > self.config.ground_fault_threshold_a {
self.tripped_ground = true;
self.record_trip(
current_time_s,
TripCause::GroundFault,
self.thermal_state,
ig_a.abs(),
);
return Err(MotorProtError::GroundFaultTrip);
}
Ok(())
}
pub fn check_undervoltage(
&mut self,
v_pu: f64,
dt_s: f64,
current_time_s: f64,
) -> Result<(), MotorProtError> {
if v_pu < self.config.undervoltage_pu_trip {
self.undervoltage_timer_s += dt_s;
} else {
self.undervoltage_timer_s = 0.0;
}
if self.undervoltage_timer_s > 0.5 {
self.tripped_undervoltage = true;
self.record_trip(
current_time_s,
TripCause::Undervoltage,
self.thermal_state,
0.0,
);
return Err(MotorProtError::UndervoltageTrip);
}
Ok(())
}
pub fn nema_trip_curve(trip_class: u8, overload_factor: f64) -> f64 {
let sf = 1.15_f64;
let sf_sq = sf * sf;
let m_sq = overload_factor * overload_factor;
let denom = m_sq - sf_sq;
if denom <= 0.0 {
return f64::INFINITY;
}
let class_f = match trip_class {
5 => 5.0_f64,
10 => 10.0,
20 => 20.0,
30 => 30.0,
_ => return f64::INFINITY,
};
class_f * sf_sq / denom
}
pub fn reset_thermal(&mut self) {
self.thermal_state = 0.0;
self.stall_time_s = 0.0;
self.undervoltage_timer_s = 0.0;
self.tripped_thermal = false;
self.tripped_phase = false;
self.tripped_locked_rotor = false;
self.tripped_ground = false;
self.tripped_undervoltage = false;
}
pub fn manual_trip(&mut self, current_time_s: f64, current_a: f64) {
self.record_trip(
current_time_s,
TripCause::ManualTrip,
self.thermal_state,
current_a,
);
}
pub fn protection_status(&self, current_time_s: f64) -> MotorProtStatus {
let elapsed_since_trip = current_time_s - self.last_trip_time_s;
let restart_locked =
!self.trip_history.is_empty() && elapsed_since_trip < self.config.restart_lockout_s;
MotorProtStatus {
thermal_state: self.thermal_state,
thermal_alarm: self.thermal_state > 0.9,
thermal_trip: self.tripped_thermal,
phase_unbalance_pct: self.last_unbalance_pct,
phase_unbalance_trip: self.tripped_phase,
locked_rotor_trip: self.tripped_locked_rotor,
ground_fault_trip: self.tripped_ground,
undervoltage_trip: self.tripped_undervoltage,
restart_locked_out: restart_locked,
trip_count: self.trip_history.len(),
}
}
pub fn trip_history(&self) -> &[MotorTrip] {
&self.trip_history
}
fn record_trip(
&mut self,
time_s: f64,
cause: TripCause,
thermal_state_at_trip: f64,
current_at_trip_a: f64,
) {
self.last_trip_time_s = time_s;
self.trip_history.push(MotorTrip {
time_s,
cause,
thermal_state_at_trip,
current_at_trip_a,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_relay(trip_class: u8) -> MotorProtectionRelay {
let config = MotorProtConfig {
overload_trip_class: trip_class,
ground_fault_threshold_a: 1.0,
locked_rotor_time_s: 10.0,
phase_unbalance_pct_trip: 5.0,
undervoltage_pu_trip: 0.85,
restart_lockout_s: 300.0,
..MotorProtConfig::default()
};
MotorProtectionRelay::new(
"M_test".into(),
100.0, 0.4, 1.15, ThermalClass::ClassF,
config,
)
}
#[test]
fn test_class10_overload_6x_fla() {
let t = MotorProtectionRelay::nema_trip_curve(10, 6.0);
assert!(t.is_finite(), "Expected finite trip time, got {}", t);
assert!(t > 0.0, "Trip time must be positive");
let t_slow = MotorProtectionRelay::nema_trip_curve(10, 2.0);
assert!(
t < t_slow,
"6x should trip faster than 2x: {} vs {}",
t,
t_slow
);
}
#[test]
fn test_phase_unbalance_trip_and_no_trip() {
let mut relay = default_relay(10);
let v_avg = 230.0_f64;
let va = v_avg * 1.015;
let vb = v_avg * 0.985;
let vc = v_avg;
let result_ok = relay.check_phase_unbalance(va, vb, vc, 0.0);
assert!(result_ok.is_ok(), "3% unbalance should not trip");
let mut relay2 = default_relay(10);
let va6 = v_avg * 1.06;
let vb6 = v_avg * 0.94;
let vc6 = v_avg;
let result_trip = relay2.check_phase_unbalance(va6, vb6, vc6, 1.0);
assert!(
matches!(result_trip, Err(MotorProtError::PhaseUnbalanceTrip(_))),
"6% unbalance should trip"
);
}
#[test]
fn test_locked_rotor_trip_after_11s() {
let mut relay = default_relay(10);
let dt = 1.0_f64;
let mut tripped = false;
for i in 0..15 {
let t = i as f64;
let res = relay.check_locked_rotor(400.0, 0.0, 1500.0, dt, t);
if res.is_err() {
tripped = true;
break;
}
}
assert!(tripped, "Locked-rotor should trip after > 10 s stall");
assert_eq!(
relay.trip_history().last().map(|tr| tr.cause),
Some(TripCause::LockedRotor)
);
}
#[test]
fn test_ground_fault_instant_trip() {
let mut relay = default_relay(10);
let result = relay.check_ground_fault(2.0, 0.5);
assert!(
matches!(result, Err(MotorProtError::GroundFaultTrip)),
"Ground fault should trip immediately"
);
assert_eq!(relay.trip_history().len(), 1);
assert_eq!(relay.trip_history()[0].cause, TripCause::GroundFault);
}
#[test]
fn test_thermal_accumulation_and_cooling() {
let mut relay = default_relay(10);
let i_overload = 150.0; let dt = 1.0_f64;
for step in 0..30 {
let _ = relay.update_thermal_state(i_overload, dt, step as f64);
}
let hot = relay.thermal_state;
assert!(hot > 0.0, "Thermal state should have risen");
let cool_start = relay.thermal_state;
for step in 30..150 {
let _ = relay.update_thermal_state(100.0, dt, step as f64); }
let cooled = relay.thermal_state;
assert!(
cooled < cool_start,
"Thermal state should decrease during cooling: {} < {}",
cooled,
cool_start
);
}
#[test]
fn test_service_factor_no_immediate_trip() {
let mut relay = default_relay(10);
let result = relay.update_thermal_state(115.0, 0.1, 0.1);
assert!(
result.is_ok(),
"SF-rated current must not cause immediate trip"
);
assert!(relay.thermal_state < 1.0);
}
#[test]
fn test_restart_lockout_enforcement() {
let mut relay = default_relay(10);
let _ = relay.check_ground_fault(5.0, 0.0);
let status_early = relay.protection_status(10.0); assert!(
status_early.restart_locked_out,
"Should be locked out at 10 s"
);
let status_late = relay.protection_status(350.0); assert!(
!status_late.restart_locked_out,
"Should be unlocked at 350 s"
);
}
#[test]
fn test_multiple_trips_stored_in_history() {
let mut relay = default_relay(10);
let _ = relay.check_ground_fault(3.0, 1.0);
let v_avg = 230.0_f64;
let _ = relay.check_phase_unbalance(v_avg * 1.06, v_avg * 0.94, v_avg, 2.0);
assert_eq!(relay.trip_history().len(), 2, "Both trips must be recorded");
assert_eq!(relay.trip_history()[0].cause, TripCause::GroundFault);
assert_eq!(relay.trip_history()[1].cause, TripCause::PhaseUnbalance);
}
#[test]
fn test_undervoltage_trip_after_500ms() {
let mut relay = default_relay(10);
let dt = 0.1_f64; let mut tripped = false;
for i in 0..10 {
let t = i as f64 * dt;
if relay.check_undervoltage(0.80, dt, t).is_err() {
tripped = true;
break;
}
}
assert!(tripped, "Under-voltage should trip after > 500 ms");
}
#[test]
fn test_nema_class_ordering() {
let t10 = MotorProtectionRelay::nema_trip_curve(10, 3.0);
let t20 = MotorProtectionRelay::nema_trip_curve(20, 3.0);
let t30 = MotorProtectionRelay::nema_trip_curve(30, 3.0);
assert!(t10 < t20, "Class 10 faster than 20: {} vs {}", t10, t20);
assert!(t20 < t30, "Class 20 faster than 30: {} vs {}", t20, t30);
assert!(t10.is_finite() && t20.is_finite() && t30.is_finite());
}
}