#[derive(Debug, Clone, PartialEq)]
pub enum CableInsulation {
Xlpe,
Epr,
PvcLv,
PvcMv,
MiPaper,
Lsoh,
}
impl CableInsulation {
pub fn max_temp_c(&self) -> f64 {
match self {
Self::Xlpe | Self::Epr | Self::Lsoh => 90.0,
Self::PvcLv | Self::PvcMv => 70.0,
Self::MiPaper => 85.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum InstallationMethod {
DirectBuried,
InDuct,
InAir,
OnTray,
Underwater,
ClipDirect,
TreedCable,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConductorMaterial {
Copper,
Aluminum,
CopperClad,
StrandedCopper,
FlexibleStranded,
}
impl ConductorMaterial {
pub fn alpha(&self) -> f64 {
match self {
Self::Copper | Self::StrandedCopper | Self::FlexibleStranded => 0.003_93,
Self::Aluminum => 0.004_03,
Self::CopperClad => 0.003_98, }
}
pub fn k_factor(&self) -> f64 {
match self {
Self::Copper | Self::StrandedCopper | Self::FlexibleStranded => 143.0,
Self::Aluminum => 94.0,
Self::CopperClad => 120.0, }
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum VoltageClass {
Lv400v,
Mv6kv,
Mv10kv,
Mv20kv,
Mv33kv,
Hv66kv,
Hv110kv,
Hv220kv,
Hv400kv,
}
impl VoltageClass {
pub fn voltage_kv(&self) -> f64 {
match self {
Self::Lv400v => 0.4,
Self::Mv6kv => 6.0,
Self::Mv10kv => 10.0,
Self::Mv20kv => 20.0,
Self::Mv33kv => 33.0,
Self::Hv66kv => 66.0,
Self::Hv110kv => 110.0,
Self::Hv220kv => 220.0,
Self::Hv400kv => 400.0,
}
}
pub fn from_kv(kv: f64) -> Self {
let table: &[(f64, VoltageClass)] = &[
(0.6, Self::Lv400v),
(8.0, Self::Mv6kv),
(15.0, Self::Mv10kv),
(26.0, Self::Mv20kv),
(50.0, Self::Mv33kv),
(88.0, Self::Hv66kv),
(160.0, Self::Hv110kv),
(300.0, Self::Hv220kv),
];
for &(upper, ref vc) in table {
if kv <= upper {
return *vc;
}
}
Self::Hv400kv
}
}
#[derive(Debug, Clone)]
pub struct CableSpec {
pub id: usize,
pub name: String,
pub voltage_class: VoltageClass,
pub conductor_material: ConductorMaterial,
pub insulation: CableInsulation,
pub conductor_cross_section_mm2: f64,
pub n_cores: u8,
pub resistivity_ohm_per_km: f64,
pub reactance_ohm_per_km: f64,
pub capacitance_nf_per_km: f64,
pub max_conductor_temp_c: f64,
pub rated_current_a: f64,
pub weight_kg_per_m: f64,
pub outer_diameter_mm: f64,
}
impl CableSpec {
pub fn cost_usd_per_m(&self) -> f64 {
let base = match self.voltage_class {
VoltageClass::Lv400v => 5.0,
VoltageClass::Mv6kv | VoltageClass::Mv10kv => 20.0,
VoltageClass::Mv20kv | VoltageClass::Mv33kv => 35.0,
VoltageClass::Hv66kv => 80.0,
VoltageClass::Hv110kv => 130.0,
VoltageClass::Hv220kv => 250.0,
VoltageClass::Hv400kv => 500.0,
};
base + self.conductor_cross_section_mm2 * 0.05
}
}
#[derive(Debug, Clone)]
pub struct InstallationConditions {
pub method: InstallationMethod,
pub ambient_temp_c: f64,
pub soil_thermal_resistivity_k_m_per_w: f64,
pub depth_of_lay_m: f64,
pub grouping_factor: f64,
pub n_cables_in_group: usize,
}
impl Default for InstallationConditions {
fn default() -> Self {
Self {
method: InstallationMethod::DirectBuried,
ambient_temp_c: 20.0,
soil_thermal_resistivity_k_m_per_w: 1.0,
depth_of_lay_m: 0.8,
grouping_factor: 0.0, n_cables_in_group: 1,
}
}
}
impl InstallationConditions {
pub fn reference_temp_c(&self) -> f64 {
match self.method {
InstallationMethod::InAir
| InstallationMethod::OnTray
| InstallationMethod::ClipDirect
| InstallationMethod::TreedCable => 30.0,
_ => 20.0,
}
}
}
#[derive(Debug, Clone)]
pub struct ThermalRating {
pub rated_current_a: f64,
pub rated_power_mw: f64,
pub derating_factor_temp: f64,
pub derating_factor_soil: f64,
pub derating_factor_grouping: f64,
pub total_derating_factor: f64,
pub conductor_temp_at_rated_c: f64,
pub joule_losses_w_per_m: f64,
}
#[derive(Debug, Clone)]
pub struct CableSizingResult {
pub selected_cable: CableSpec,
pub thermal_rating: ThermalRating,
pub n_parallel_circuits: usize,
pub total_length_km: f64,
pub total_capital_cost_usd: f64,
pub annual_losses_mwh: f64,
pub annual_loss_cost_usd: f64,
pub voltage_drop_pct: f64,
pub short_circuit_withstand_ka: f64,
pub recommended: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CableDatabase {
pub cables: Vec<CableSpec>,
}
impl CableDatabase {
pub fn find_cables_by_voltage_class(&self, vc: VoltageClass) -> Vec<&CableSpec> {
self.cables
.iter()
.filter(|c| c.voltage_class == vc)
.collect()
}
pub fn find_minimum_size(
&self,
required_current_a: f64,
voltage_class: VoltageClass,
) -> Option<&CableSpec> {
let mut candidates: Vec<&CableSpec> = self
.cables
.iter()
.filter(|c| c.voltage_class == voltage_class && c.rated_current_a >= required_current_a)
.collect();
candidates.sort_by(|a, b| {
a.conductor_cross_section_mm2
.partial_cmp(&b.conductor_cross_section_mm2)
.unwrap_or(std::cmp::Ordering::Equal)
});
candidates.into_iter().next()
}
fn largest_cable(&self, voltage_class: VoltageClass) -> Option<&CableSpec> {
self.cables
.iter()
.filter(|c| c.voltage_class == voltage_class)
.max_by(|a, b| {
a.conductor_cross_section_mm2
.partial_cmp(&b.conductor_cross_section_mm2)
.unwrap_or(std::cmp::Ordering::Equal)
})
}
}
fn grouping_factor(n: usize) -> f64 {
match n {
0 | 1 => 1.00,
2 => 0.85,
3 => 0.79,
4 => 0.75,
5 => 0.72,
_ => 0.68,
}
}
fn resistance_at_temp(r_20: f64, alpha: f64, t_op: f64) -> f64 {
r_20 * (1.0 + alpha * (t_op - 20.0))
}
#[derive(Debug, Clone)]
pub struct CableSizingEngine {
pub database: CableDatabase,
pub energy_price_usd_per_mwh: f64,
pub project_lifetime_years: f64,
pub discount_rate: f64,
pub contingency_factor: f64,
}
impl Default for CableSizingEngine {
fn default() -> Self {
Self::new()
}
}
impl CableSizingEngine {
pub fn new() -> Self {
Self {
database: Self::generate_standard_database(),
energy_price_usd_per_mwh: 60.0,
project_lifetime_years: 30.0,
discount_rate: 0.06,
contingency_factor: 1.25,
}
}
pub fn compute_thermal_rating(
cable: &CableSpec,
conditions: &InstallationConditions,
) -> ThermalRating {
let t_max = cable.max_conductor_temp_c;
let t_ref = conditions.reference_temp_c();
let t_amb = conditions.ambient_temp_c;
let numerator = t_max - t_amb;
let denominator = t_max - t_ref;
let k_t = if denominator > 0.0 && numerator > 0.0 {
(numerator / denominator).sqrt()
} else {
0.0 };
let k_rho = match conditions.method {
InstallationMethod::DirectBuried
| InstallationMethod::InDuct
| InstallationMethod::Underwater => {
let rho = conditions.soil_thermal_resistivity_k_m_per_w.max(0.01);
(1.0_f64 / rho).sqrt()
}
_ => 1.0,
};
let k_g = if conditions.grouping_factor > 0.0 {
conditions.grouping_factor
} else {
grouping_factor(conditions.n_cables_in_group)
};
let total = k_t * k_rho * k_g;
let derated_current = cable.rated_current_a * total;
let alpha = cable.conductor_material.alpha();
let r_op = resistance_at_temp(cable.resistivity_ohm_per_km, alpha, t_max) / 1000.0; let joule = derated_current * derated_current * r_op;
let v_kv = cable.voltage_class.voltage_kv();
let rated_power_mw = 3_f64.sqrt() * v_kv * derated_current / 1000.0;
ThermalRating {
rated_current_a: derated_current,
rated_power_mw,
derating_factor_temp: k_t,
derating_factor_soil: k_rho,
derating_factor_grouping: k_g,
total_derating_factor: total,
conductor_temp_at_rated_c: t_max,
joule_losses_w_per_m: joule,
}
}
pub fn compute_voltage_drop(
cable: &CableSpec,
current_a: f64,
length_km: f64,
power_factor: f64,
) -> f64 {
let cos_phi = power_factor.clamp(0.0, 1.0);
let sin_phi = (1.0 - cos_phi * cos_phi).max(0.0).sqrt();
let r = cable.resistivity_ohm_per_km;
let x = cable.reactance_ohm_per_km;
let delta_v = current_a * length_km * (r * cos_phi + x * sin_phi);
let v_phase = cable.voltage_class.voltage_kv() * 1000.0 / 3_f64.sqrt();
if v_phase < 1e-9 {
return 0.0;
}
delta_v / v_phase * 100.0
}
pub fn compute_short_circuit_withstand(cable: &CableSpec, fault_duration_s: f64) -> f64 {
let k = cable.conductor_material.k_factor();
let a = cable.conductor_cross_section_mm2;
let t = fault_duration_s.max(0.01);
k * a / (t.sqrt() * 1000.0) }
pub fn check_voltage_drop(result: &CableSizingResult, max_drop_pct: f64) -> bool {
result.voltage_drop_pct <= max_drop_pct
}
pub fn size_cable(
&self,
required_power_mw: f64,
voltage_kv: f64,
length_km: f64,
conditions: &InstallationConditions,
) -> CableSizingResult {
let voltage_class = VoltageClass::from_kv(voltage_kv);
let v_line = voltage_class.voltage_kv().max(0.001);
let pf = 0.9_f64;
let required_current_a = required_power_mw * 1e6 / (3_f64.sqrt() * v_line * 1e3 * pf);
let design_current_a = required_current_a * self.contingency_factor;
let (selected_cable, n_parallel) = if let Some(cable) = self
.database
.find_minimum_size(design_current_a, voltage_class)
{
(cable.clone(), 1)
} else {
let largest = self
.database
.largest_cable(voltage_class)
.cloned()
.unwrap_or_else(|| self.fallback_cable(voltage_class));
let rating = Self::compute_thermal_rating(&largest, conditions);
let single_rating = rating.rated_current_a.max(1.0);
let n = ((design_current_a / single_rating).ceil() as usize).max(1);
(largest, n)
};
let thermal_rating = Self::compute_thermal_rating(&selected_cable, conditions);
let effective_current_a = thermal_rating.rated_current_a * n_parallel as f64;
let current_per_cable = required_current_a / n_parallel as f64;
let vdrop_pct =
Self::compute_voltage_drop(&selected_cable, current_per_cable, length_km, pf);
let sc_ka = Self::compute_short_circuit_withstand(&selected_cable, 1.0);
let cable_metres = length_km * 1000.0;
let n_single_cables = if selected_cable.n_cores == 1 { 3 } else { 1 };
let capital_cost_usd = selected_cable.cost_usd_per_m()
* cable_metres
* n_single_cables as f64
* n_parallel as f64;
let loss_w_per_m = thermal_rating.joule_losses_w_per_m;
let annual_losses_mwh = loss_w_per_m * cable_metres * n_parallel as f64 * 8760.0 / 1e6;
let annual_loss_cost_usd = annual_losses_mwh * self.energy_price_usd_per_mwh;
let capacity_ok = effective_current_a >= required_current_a;
let vdrop_ok = vdrop_pct <= 5.0;
let recommended = capacity_ok && vdrop_ok;
CableSizingResult {
selected_cable,
thermal_rating,
n_parallel_circuits: n_parallel,
total_length_km: length_km,
total_capital_cost_usd: capital_cost_usd,
annual_losses_mwh,
annual_loss_cost_usd,
voltage_drop_pct: vdrop_pct,
short_circuit_withstand_ka: sc_ka,
recommended,
}
}
pub fn generate_standard_database() -> CableDatabase {
let mut cables = Vec::new();
let mut id = 0_usize;
for &(mm2, r, x, c_nf, i_a, w, od) in &[
(16.0, 1.150, 0.080, 200.0, 88.0, 0.60, 28.0),
(35.0, 0.524, 0.078, 230.0, 138.0, 0.90, 35.0),
(70.0, 0.268, 0.075, 270.0, 195.0, 1.30, 42.0),
(120.0, 0.153, 0.073, 310.0, 255.0, 1.80, 50.0),
(185.0, 0.099, 0.072, 350.0, 320.0, 2.50, 58.0),
(240.0, 0.075, 0.071, 390.0, 380.0, 3.20, 65.0),
] {
id += 1;
cables.push(CableSpec {
id,
name: format!("3×{} mm² Cu/XLPE LV", mm2 as usize),
voltage_class: VoltageClass::Lv400v,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: mm2,
n_cores: 3,
resistivity_ohm_per_km: r,
reactance_ohm_per_km: x,
capacitance_nf_per_km: c_nf,
max_conductor_temp_c: 90.0,
rated_current_a: i_a,
weight_kg_per_m: w,
outer_diameter_mm: od,
});
}
for &(mm2, r, x, c_nf, i_a, w, od) in &[
(35.0, 0.524, 0.115, 150.0, 125.0, 1.80, 48.0),
(70.0, 0.268, 0.110, 185.0, 180.0, 2.50, 57.0),
(120.0, 0.153, 0.107, 220.0, 240.0, 3.50, 67.0),
(185.0, 0.099, 0.104, 255.0, 305.0, 4.80, 77.0),
(240.0, 0.075, 0.102, 280.0, 360.0, 6.00, 86.0),
(300.0, 0.060, 0.100, 310.0, 410.0, 7.40, 95.0),
] {
id += 1;
cables.push(CableSpec {
id,
name: format!("3×{} mm² Cu/XLPE 10 kV", mm2 as usize),
voltage_class: VoltageClass::Mv10kv,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: mm2,
n_cores: 3,
resistivity_ohm_per_km: r,
reactance_ohm_per_km: x,
capacitance_nf_per_km: c_nf,
max_conductor_temp_c: 90.0,
rated_current_a: i_a,
weight_kg_per_m: w,
outer_diameter_mm: od,
});
}
for &(mm2, r, x, c_nf, i_a, w, od) in &[
(185.0, 0.099, 0.140, 220.0, 355.0, 5.50, 82.0),
(240.0, 0.075, 0.134, 260.0, 420.0, 6.80, 91.0),
(300.0, 0.060, 0.130, 300.0, 485.0, 8.30, 100.0),
(400.0, 0.047, 0.126, 340.0, 565.0, 10.50, 112.0),
(500.0, 0.037, 0.122, 380.0, 645.0, 13.00, 124.0),
] {
id += 1;
cables.push(CableSpec {
id,
name: format!("1×{} mm² Cu/XLPE 33 kV", mm2 as usize),
voltage_class: VoltageClass::Mv33kv,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: mm2,
n_cores: 1,
resistivity_ohm_per_km: r,
reactance_ohm_per_km: x,
capacitance_nf_per_km: c_nf,
max_conductor_temp_c: 90.0,
rated_current_a: i_a,
weight_kg_per_m: w,
outer_diameter_mm: od,
});
}
for &(mm2, r, x, c_nf, i_a, w, od) in &[
(300.0, 0.060, 0.150, 200.0, 560.0, 15.0, 108.0),
(400.0, 0.047, 0.145, 230.0, 650.0, 18.5, 120.0),
(500.0, 0.037, 0.140, 260.0, 745.0, 22.5, 132.0),
(630.0, 0.028, 0.135, 290.0, 855.0, 28.0, 146.0),
] {
id += 1;
cables.push(CableSpec {
id,
name: format!("1×{} mm² Cu/XLPE 110 kV", mm2 as usize),
voltage_class: VoltageClass::Hv110kv,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: mm2,
n_cores: 1,
resistivity_ohm_per_km: r,
reactance_ohm_per_km: x,
capacitance_nf_per_km: c_nf,
max_conductor_temp_c: 90.0,
rated_current_a: i_a,
weight_kg_per_m: w,
outer_diameter_mm: od,
});
}
CableDatabase { cables }
}
fn fallback_cable(&self, vc: VoltageClass) -> CableSpec {
CableSpec {
id: 9999,
name: format!("Fallback {:?}", vc),
voltage_class: vc,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: 240.0,
n_cores: 3,
resistivity_ohm_per_km: 0.075,
reactance_ohm_per_km: 0.10,
capacitance_nf_per_km: 280.0,
max_conductor_temp_c: 90.0,
rated_current_a: 360.0,
weight_kg_per_m: 5.0,
outer_diameter_mm: 80.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_engine() -> CableSizingEngine {
CableSizingEngine::new()
}
fn buried_conditions() -> InstallationConditions {
InstallationConditions::default()
}
#[test]
fn test_database_populated() {
let engine = default_engine();
for vc in [
VoltageClass::Lv400v,
VoltageClass::Mv10kv,
VoltageClass::Mv33kv,
VoltageClass::Hv110kv,
] {
let found = engine.database.find_cables_by_voltage_class(vc);
assert!(!found.is_empty(), "No cables for {:?}", vc);
}
}
#[test]
fn test_thermal_rating_derating_temp() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[0]
.clone();
let cond_cool = InstallationConditions {
ambient_temp_c: 10.0,
..Default::default()
};
let cond_hot = InstallationConditions {
ambient_temp_c: 40.0,
..Default::default()
};
let rating_cool = CableSizingEngine::compute_thermal_rating(&cable, &cond_cool);
let rating_hot = CableSizingEngine::compute_thermal_rating(&cable, &cond_hot);
assert!(
rating_cool.rated_current_a > rating_hot.rated_current_a,
"Cooler ambient must give higher rating: {} vs {}",
rating_cool.rated_current_a,
rating_hot.rated_current_a
);
}
#[test]
fn test_thermal_rating_derating_soil() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[0]
.clone();
let cond_good = InstallationConditions {
soil_thermal_resistivity_k_m_per_w: 0.7,
..Default::default()
};
let cond_poor = InstallationConditions {
soil_thermal_resistivity_k_m_per_w: 2.5,
..Default::default()
};
let rating_good = CableSizingEngine::compute_thermal_rating(&cable, &cond_good);
let rating_poor = CableSizingEngine::compute_thermal_rating(&cable, &cond_poor);
assert!(
rating_good.rated_current_a > rating_poor.rated_current_a,
"Better soil must give higher rating"
);
}
#[test]
fn test_thermal_rating_grouping() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[0]
.clone();
let cond_single = InstallationConditions {
n_cables_in_group: 1,
..Default::default()
};
let cond_group = InstallationConditions {
n_cables_in_group: 3,
..Default::default()
};
let rating_single = CableSizingEngine::compute_thermal_rating(&cable, &cond_single);
let rating_group = CableSizingEngine::compute_thermal_rating(&cable, &cond_group);
assert!(
rating_single.rated_current_a > rating_group.rated_current_a,
"Grouped cables must have lower rating per cable"
);
}
#[test]
fn test_thermal_rating_positive() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[2]
.clone();
let rating = CableSizingEngine::compute_thermal_rating(&cable, &buried_conditions());
assert!(
rating.rated_current_a > 0.0,
"Rated current must be positive"
);
assert!(rating.rated_power_mw > 0.0, "Rated power must be positive");
}
#[test]
fn test_size_cable_lv() {
let engine = default_engine();
let result = engine.size_cable(0.1, 0.4, 0.1, &buried_conditions());
assert!(
result.selected_cable.voltage_class == VoltageClass::Lv400v,
"Should select an LV cable"
);
assert!(result.n_parallel_circuits >= 1);
}
#[test]
fn test_size_cable_mv() {
let engine = default_engine();
let result = engine.size_cable(5.0, 10.0, 2.0, &buried_conditions());
assert!(
result.selected_cable.voltage_class == VoltageClass::Mv10kv,
"Should select MV 10 kV cable"
);
assert!(result.selected_cable.rated_current_a > 0.0);
}
#[test]
fn test_size_cable_parallel() {
let engine = default_engine();
let result = engine.size_cable(300.0, 110.0, 10.0, &buried_conditions());
assert!(
result.n_parallel_circuits > 1,
"Very large load must require parallel circuits, got {}",
result.n_parallel_circuits
);
}
#[test]
fn test_voltage_drop_formula() {
let cable = CableSpec {
id: 1,
name: "Test".into(),
voltage_class: VoltageClass::Mv10kv,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: 185.0,
n_cores: 3,
resistivity_ohm_per_km: 0.099,
reactance_ohm_per_km: 0.104,
capacitance_nf_per_km: 255.0,
max_conductor_temp_c: 90.0,
rated_current_a: 305.0,
weight_kg_per_m: 4.8,
outer_diameter_mm: 77.0,
};
let pf = 0.9_f64;
let sin_phi = (1.0 - pf * pf).sqrt();
let i = 200.0_f64;
let l = 3.0_f64;
let expected_delta_v =
i * l * (cable.resistivity_ohm_per_km * pf + cable.reactance_ohm_per_km * sin_phi);
let v_phase = 10.0e3 / 3_f64.sqrt();
let expected_pct = expected_delta_v / v_phase * 100.0;
let computed = CableSizingEngine::compute_voltage_drop(&cable, i, l, pf);
let diff = (computed - expected_pct).abs();
assert!(
diff < 1e-9,
"Voltage drop mismatch: {} vs {}",
computed,
expected_pct
);
}
#[test]
fn test_voltage_drop_pct_range() {
let engine = default_engine();
let result = engine.size_cable(5.0, 10.0, 5.0, &buried_conditions());
assert!(
result.voltage_drop_pct >= 0.0 && result.voltage_drop_pct < 10.0,
"Voltage drop {} out of expected range",
result.voltage_drop_pct
);
}
#[test]
fn test_sc_withstand_cu_xlpe() {
let cable = CableSpec {
id: 1,
name: "SC test Cu".into(),
voltage_class: VoltageClass::Mv10kv,
conductor_material: ConductorMaterial::Copper,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: 185.0,
n_cores: 3,
resistivity_ohm_per_km: 0.099,
reactance_ohm_per_km: 0.104,
capacitance_nf_per_km: 255.0,
max_conductor_temp_c: 90.0,
rated_current_a: 305.0,
weight_kg_per_m: 4.8,
outer_diameter_mm: 77.0,
};
let sc = CableSizingEngine::compute_short_circuit_withstand(&cable, 1.0);
let expected = 143.0 * 185.0 / 1000.0;
assert!(
(sc - expected).abs() < 1e-9,
"k=143 not used: got {}, expected {}",
sc,
expected
);
}
#[test]
fn test_sc_withstand_time_dependence() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[2]
.clone();
let sc_short = CableSizingEngine::compute_short_circuit_withstand(&cable, 0.1);
let sc_long = CableSizingEngine::compute_short_circuit_withstand(&cable, 1.0);
assert!(
sc_short > sc_long,
"Shorter fault duration must allow higher SC current"
);
}
#[test]
fn test_sc_withstand_area_dependence() {
let db = default_engine().database;
let cables_mv = db.find_cables_by_voltage_class(VoltageClass::Mv10kv);
assert!(cables_mv.len() >= 2);
let mut sorted = cables_mv.clone();
sorted.sort_by(|a, b| {
a.conductor_cross_section_mm2
.partial_cmp(&b.conductor_cross_section_mm2)
.unwrap_or(std::cmp::Ordering::Equal)
});
let sc_small = CableSizingEngine::compute_short_circuit_withstand(sorted[0], 1.0);
let sc_large =
CableSizingEngine::compute_short_circuit_withstand(sorted[sorted.len() - 1], 1.0);
assert!(
sc_large > sc_small,
"Larger cross-section must withstand more SC current"
);
}
#[test]
fn test_find_minimum_size() {
let db = CableSizingEngine::generate_standard_database();
let result = db.find_minimum_size(150.0, VoltageClass::Mv10kv);
assert!(result.is_some(), "Should find a cable");
let cable = result.unwrap();
assert!(
cable.rated_current_a >= 150.0,
"Selected cable must meet required current"
);
let others: Vec<&CableSpec> = db
.find_cables_by_voltage_class(VoltageClass::Mv10kv)
.into_iter()
.filter(|c| {
c.rated_current_a >= 150.0
&& c.conductor_cross_section_mm2 < cable.conductor_cross_section_mm2
})
.collect();
assert!(
others.is_empty(),
"Should select the minimum sufficient cable"
);
}
#[test]
fn test_capital_cost_positive() {
let engine = default_engine();
let result = engine.size_cable(5.0, 10.0, 5.0, &buried_conditions());
assert!(
result.total_capital_cost_usd > 0.0,
"Capital cost must be positive"
);
}
#[test]
fn test_annual_losses_positive() {
let engine = default_engine();
let result = engine.size_cable(5.0, 10.0, 5.0, &buried_conditions());
assert!(
result.annual_losses_mwh > 0.0,
"Annual losses must be positive at non-zero load"
);
}
#[test]
fn test_derating_factor_product() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[0]
.clone();
let cond = InstallationConditions {
ambient_temp_c: 30.0,
soil_thermal_resistivity_k_m_per_w: 1.5,
n_cables_in_group: 3,
..Default::default()
};
let rating = CableSizingEngine::compute_thermal_rating(&cable, &cond);
let expected_total = rating.derating_factor_temp
* rating.derating_factor_soil
* rating.derating_factor_grouping;
let diff = (rating.total_derating_factor - expected_total).abs();
assert!(diff < 1e-12, "Total derating must equal product of factors");
}
#[test]
fn test_conductor_temp_at_rated() {
let cable = default_engine()
.database
.find_cables_by_voltage_class(VoltageClass::Mv10kv)[0]
.clone();
let rating = CableSizingEngine::compute_thermal_rating(&cable, &buried_conditions());
let diff = (rating.conductor_temp_at_rated_c - cable.max_conductor_temp_c).abs();
assert!(
diff < 1e-9,
"Conductor temperature at rated current must equal max_conductor_temp_c"
);
}
#[test]
fn test_recommended_flag() {
let engine = default_engine();
let result = engine.size_cable(2.0, 10.0, 1.0, &buried_conditions());
assert!(
result.recommended,
"Well-sized cable for low load should be recommended"
);
}
#[test]
fn test_voltage_drop_check() {
let engine = default_engine();
let result = engine.size_cable(2.0, 10.0, 1.0, &buried_conditions());
let passes = CableSizingEngine::check_voltage_drop(&result, 5.0);
assert!(passes, "Short route should have voltage drop < 5 %");
}
#[test]
fn test_sc_withstand_al_k94() {
let cable = CableSpec {
id: 2,
name: "SC test Al".into(),
voltage_class: VoltageClass::Mv10kv,
conductor_material: ConductorMaterial::Aluminum,
insulation: CableInsulation::Xlpe,
conductor_cross_section_mm2: 300.0,
n_cores: 3,
resistivity_ohm_per_km: 0.100,
reactance_ohm_per_km: 0.100,
capacitance_nf_per_km: 280.0,
max_conductor_temp_c: 90.0,
rated_current_a: 320.0,
weight_kg_per_m: 4.0,
outer_diameter_mm: 75.0,
};
let sc = CableSizingEngine::compute_short_circuit_withstand(&cable, 1.0);
let expected = 94.0 * 300.0 / 1000.0;
assert!(
(sc - expected).abs() < 1e-9,
"Al k=94 not used: {} vs {}",
sc,
expected
);
}
#[test]
fn test_in_air_reference_temp() {
let cond = InstallationConditions {
method: InstallationMethod::InAir,
ambient_temp_c: 30.0,
..Default::default()
};
assert_eq!(cond.reference_temp_c(), 30.0);
}
#[test]
fn test_voltage_class_from_kv() {
assert_eq!(VoltageClass::from_kv(0.4), VoltageClass::Lv400v);
assert_eq!(VoltageClass::from_kv(10.0), VoltageClass::Mv10kv);
assert_eq!(VoltageClass::from_kv(33.0), VoltageClass::Mv33kv);
assert_eq!(VoltageClass::from_kv(110.0), VoltageClass::Hv110kv);
assert_eq!(VoltageClass::from_kv(400.0), VoltageClass::Hv400kv);
}
}