use cimmodel::base::FastMap as HashMap;
use cimmodel::CimDataset;
use crate::Violation;
use super::Fields;
pub fn validate(dataset: &CimDataset) -> Vec<Violation> {
let mut v = Vec::new();
v.extend(check_ac_line_segment_base_voltage(dataset));
v.extend(check_regulating_control_target_value_tap_changer(dataset));
v.extend(check_ac_line_segment_base_voltage_diff(dataset));
v.extend(check_boundary_point_bppl(dataset));
v.extend(check_equivalent_injection_regulation_capability_not_hvdc(dataset));
v
}
fn terminal_nominal_voltage(dataset: &CimDataset, term: &Fields) -> Option<f64> {
let tn_id = term.reference("Terminal.TopologicalNode")?.trim_start_matches('#').to_string();
let tn = Fields::get(dataset, &tn_id, "TopologicalNode")?;
let bv_id = tn.reference("TopologicalNode.BaseVoltage")?.trim_start_matches('#').to_string();
let bv = Fields::get(dataset, &bv_id, "BaseVoltage")?;
bv.f64("BaseVoltage.nominalVoltage")
}
fn build_terminals_by_equipment_seq(dataset: &CimDataset) -> HashMap<String, HashMap<i64, String>> {
let mut map: HashMap<String, HashMap<i64, String>> = HashMap::default();
for mrid in dataset.by_type.get("Terminal").into_iter().flatten() {
let entry = &dataset.entries[mrid];
if let Some(term) = Fields::of_class(entry, "Terminal")
&& let Some(ce) = &term.reference("Terminal.ConductingEquipment") {
let eq_id = ce.trim_start_matches('#').to_string();
if let Some(seq) = term.i64("ACDCTerminal.sequenceNumber") {
map.entry(eq_id).or_default().insert(seq, mrid.clone());
}
}
}
map
}
fn check_ac_line_segment_base_voltage(dataset: &CimDataset) -> Vec<Violation> {
let by_eq = build_terminals_by_equipment_seq(dataset);
let mut v = Vec::new();
for mrid in dataset.by_type.get("ACLineSegment").into_iter().flatten() {
let terms = match by_eq.get(mrid) { Some(t) => t, None => continue };
let t1_id = match terms.get(&1) { Some(id) => id, None => continue };
let t2_id = match terms.get(&2) { Some(id) => id, None => continue };
let t1 = match dataset.entries.get(t1_id).and_then(|e| Fields::of_class(e, "Terminal")) { Some(t) => t, None => continue };
let t2 = match dataset.entries.get(t2_id).and_then(|e| Fields::of_class(e, "Terminal")) { Some(t) => t, None => continue };
let v1 = match terminal_nominal_voltage(dataset, &t1) { Some(v) => v, None => continue };
let v2 = match terminal_nominal_voltage(dataset, &t2) { Some(v) => v, None => continue };
if t1.reference("Terminal.TopologicalNode") == t2.reference("Terminal.TopologicalNode") { continue; }
if v1 != v2 {
v.push(Violation {
object_id: mrid.clone(), rule_id: "eqn301:ACLineSegment-baseVoltage".into(),
name: "C:301:EQ:ACLineSegment:baseVoltage".into(), class: "ACLineSegment".into(),
property: "ACLineSegment.BaseVoltage".into(),
message: format!("The ACLineSegment has different BaseVoltage.nominalVoltage at the two ends. Voltage at end 1 is: {}. Voltage at end 2 is: {}.", v1, v2),
severity: "sh:Warning".into(), description: String::new(),
});
}
}
v
}
fn check_regulating_control_target_value_tap_changer(dataset: &CimDataset) -> Vec<Violation> {
let mut v = Vec::new();
let voltage_suffix = "voltage";
let mut rtc_by_tcc: HashMap<String, Vec<String>> = HashMap::default();
for rtc_mrid in dataset.by_type.get("RatioTapChanger").into_iter().flatten() {
let rtc_entry = &dataset.entries[rtc_mrid];
if let Some(rtc) = Fields::of_class(rtc_entry, "RatioTapChanger")
&& let Some(r) = &rtc.reference("TapChanger.TapChangerControl") {
let tcc_id = r.trim_start_matches('#').to_string();
rtc_by_tcc.entry(tcc_id).or_default().push(rtc_mrid.clone());
}
}
for rc_mrid in dataset.by_type.get("RegulatingControl").into_iter().chain(dataset.by_type.get("TapChangerControl")).flatten() {
let rc_entry = &dataset.entries[rc_mrid];
let rc = Fields::of(rc_entry);
let mode_ok = rc.enumeration("RegulatingControl.mode").is_some_and(|r| r.ends_with(voltage_suffix));
let enabled = rc.bool("RegulatingControl.enabled") == Some(true);
let Some(target_value) = rc.f64("RegulatingControl.targetValue") else { continue };
let terminal_ref = rc.reference("RegulatingControl.Terminal");
if !mode_ok || !enabled { continue; }
let term_id = match terminal_ref { Some(r) => r.trim_start_matches('#'), None => continue };
for rtc_mrid in rtc_by_tcc.get(rc_mrid).into_iter().flatten() {
let rtc_entry = &dataset.entries[rtc_mrid];
let rtc = match Fields::of_class(rtc_entry, "RatioTapChanger") { Some(r) => r, None => continue };
if rtc.bool("TapChanger.controlEnabled") != Some(true) { continue; }
let nominal_u = (|| -> Option<f64> {
let term = Fields::get(dataset, term_id, "Terminal")?;
let cn_id = term.reference("Terminal.ConnectivityNode")?.trim_start_matches('#');
let cn = Fields::get(dataset, cn_id, "ConnectivityNode")?;
let cnc_id = cn.reference("ConnectivityNode.ConnectivityNodeContainer")?.trim_start_matches('#');
let vl = Fields::get(dataset, cnc_id, "VoltageLevel")?;
let bv_id = vl.reference("VoltageLevel.BaseVoltage")?.trim_start_matches('#');
Fields::get(dataset, bv_id, "BaseVoltage")?.f64("BaseVoltage.nominalVoltage")
})();
let nominal_u = match nominal_u { Some(u) if u != 0.0 => u, _ => continue };
let target_pu = target_value / nominal_u;
let (Some(svi), Some(high), Some(neutral), Some(low)) = (
rtc.f64("RatioTapChanger.stepVoltageIncrement"), rtc.i64("TapChanger.highStep"),
rtc.i64("TapChanger.neutralStep"), rtc.i64("TapChanger.lowStep")) else { continue };
let step_pct = svi / 100.0;
let upper_limit = 1.0 + step_pct * (high - neutral) as f64;
let lower_limit = 1.0 - step_pct * (neutral - low) as f64;
if target_pu < lower_limit || target_pu > upper_limit {
v.push(Violation {
object_id: rc_mrid.clone(), rule_id: "eqn452:RegulatingControl.targetValue-tapChanger".into(),
name: "C:452:EQ:RegulatingControl.targetValue:tapChanger".into(), class: "RegulatingControl".into(),
property: "RegulatingControl.targetValue".into(),
message: format!("Target value PU ({}) is outside TapChanger capability limits [{}, {}].", target_pu, lower_limit, upper_limit),
severity: "sh:Violation".into(), description: String::new(),
});
}
}
}
v
}
fn check_ac_line_segment_base_voltage_diff(dataset: &CimDataset) -> Vec<Violation> {
let by_eq = build_terminals_by_equipment_seq(dataset);
let mut v = Vec::new();
for mrid in dataset.by_type.get("ACLineSegment").into_iter().flatten() {
let terms = match by_eq.get(mrid) { Some(t) => t, None => continue };
let t1_id = match terms.get(&1) { Some(id) => id, None => continue };
let t2_id = match terms.get(&2) { Some(id) => id, None => continue };
let t1 = match dataset.entries.get(t1_id).and_then(|e| Fields::of_class(e, "Terminal")) { Some(t) => t, None => continue };
let t2 = match dataset.entries.get(t2_id).and_then(|e| Fields::of_class(e, "Terminal")) { Some(t) => t, None => continue };
let v1 = match terminal_nominal_voltage(dataset, &t1) { Some(v) => v, None => continue };
let v2 = match terminal_nominal_voltage(dataset, &t2) { Some(v) => v, None => continue };
let diff = if v1 < v2 { (v2 - v1) / v1 } else { (v1 - v2) / v2 };
if diff > 0.1 {
v.push(Violation {
object_id: mrid.clone(), rule_id: "eqn600:ACLineSegment-BaseVoltageDiff".into(),
name: "C:600:EQ:ACLineSegment:BaseVoltageDiff".into(), class: "ACLineSegment".into(),
property: "rdf:type".into(),
message: format!("More than 10% difference of BaseVoltage.nominalVoltage at the two ends (V1: {}, V2: {}).", v1, v2),
severity: "sh:Violation".into(), description: String::new(),
});
}
}
v
}
const BRANCHES: &[&str] = &[
"ACLineSegment", "PowerTransformer", "DCLineSegment", "DCSeriesDevice", "DCSwitch", "DCDisconnector",
"DCBreaker", "DCChopper", "Switch", "Disconnector", "Fuse", "GroundDisconnector", "Jumper", "Breaker",
"LoadBreakSwitch", "DisconnectingCircuitBreaker", "Cut", "SeriesCompensator", "EquivalentBranch",
];
fn check_boundary_point_bppl(dataset: &CimDataset) -> Vec<Violation> {
let mut nodes: Vec<&str> = Vec::new();
for mrid in dataset.by_type.get("BoundaryPoint").into_iter().flatten() {
if let Some(r) = Fields::of(&dataset.entries[mrid]).reference("BoundaryPoint.ConnectivityNode") {
nodes.push(r.trim_start_matches('#'));
}
}
nodes.sort_unstable();
nodes.dedup();
let mut cn_classes: HashMap<&str, Vec<&str>> = HashMap::default();
for mrid in dataset.by_type.get("Terminal").into_iter().flatten() {
let term = Fields::of(&dataset.entries[mrid]);
if let (Some(cn), Some(eq)) = (term.reference("Terminal.ConnectivityNode"), term.reference("Terminal.ConductingEquipment"))
&& let Some(e) = dataset.entries.get(eq.trim_start_matches('#')) {
cn_classes.entry(cn.trim_start_matches('#')).or_default().push(e.type_name());
}
}
let mut v = Vec::new();
for cn_id in nodes {
let Some(cn) = Fields::get(dataset, cn_id, "ConnectivityNode") else { continue };
if !cn.has("IdentifiedObject.name") { continue; }
let classes = cn_classes.get(cn_id).map(Vec::as_slice).unwrap_or_default();
let has_branch = classes.iter().any(|c| BRANCHES.contains(c));
let has_injection = classes.contains(&"EquivalentInjection");
if has_branch && !has_injection {
v.push(Violation {
object_id: cn_id.to_string(), rule_id: "eqn600:BoundaryPoint-bppl1Bppl2".into(),
name: "C:600:EQ:BoundaryPoint:bppl1Bppl2".into(), class: "ConnectivityNode".into(),
property: "rdf:type".into(),
message: "Boundary Point ConnectivityNode does not have an EquivalentInjection connected.".into(),
severity: "sh:Violation".into(), description: String::new(),
});
}
if !has_branch {
v.push(Violation {
object_id: cn_id.to_string(), rule_id: "eqn600:BoundaryPoint-bppl3".into(),
name: "C:600:EQ:BoundaryPoint:bppl3".into(), class: "ConnectivityNode".into(),
property: "rdf:type".into(),
message: "Boundary Point ConnectivityNode does not have a two-terminal ConductingEquipment connected.".into(),
severity: "sh:Info".into(), description: String::new(),
});
}
}
v
}
fn check_equivalent_injection_regulation_capability_not_hvdc(dataset: &CimDataset) -> Vec<Violation> {
let mut cn_is_dc: HashMap<String, bool> = HashMap::default();
for mrid in dataset.by_type.get("BoundaryPoint").into_iter().flatten() {
let entry = &dataset.entries[mrid];
if let Some(bp) = Fields::of_class(entry, "BoundaryPoint")
&& let Some(r) = &bp.reference("BoundaryPoint.ConnectivityNode")
&& let Some(is_dc) = bp.bool("BoundaryPoint.isDirectCurrent") {
cn_is_dc.insert(r.trim_start_matches('#').to_string(), is_dc);
}
}
let mut equip_non_hvdc_bp: HashMap<String, bool> = HashMap::default();
for term_mrid in dataset.by_type.get("Terminal").into_iter().flatten() {
let term = match dataset.entries.get(term_mrid).and_then(|e| Fields::of_class(e, "Terminal")) { Some(t) => t, None => continue };
let eq_id = match term.reference("Terminal.ConductingEquipment") { Some(r) => r.trim_start_matches('#').to_string(), None => continue };
if let Some(cn) = &term.reference("Terminal.ConnectivityNode") {
let cn_id = cn.trim_start_matches('#');
if let Some(&is_dc) = cn_is_dc.get(cn_id)
&& !is_dc {
equip_non_hvdc_bp.insert(eq_id, true);
}
}
}
let mut v = Vec::new();
for ei_mrid in dataset.by_type.get("EquivalentInjection").into_iter().flatten() {
let ei_entry = &dataset.entries[ei_mrid];
let ei = match Fields::of_class(ei_entry, "EquivalentInjection") { Some(e) => e, None => continue };
let is_non_hvdc_bp = equip_non_hvdc_bp.contains_key(ei_mrid.as_str());
if is_non_hvdc_bp
&& let Some(capable) = ei.bool("EquivalentInjection.regulationCapability")
&& (capable || ei.has("EquivalentInjection.ReactiveCapabilityCurve")) {
v.push(Violation {
object_id: ei_mrid.clone(), rule_id: "eqn600:EquivalentInjection.regulationCapability-notHVDC".into(),
name: "C:600:EQ:EquivalentInjection.regulationCapability:notHvdc".into(), class: "EquivalentInjection".into(),
property: "EquivalentInjection.regulationCapability".into(),
message: "EquivalentInjection at non-HVDC BoundaryPoint has regulationCapability=true or a ReactiveCapabilityCurve.".into(),
severity: "sh:Violation".into(), description: String::new(),
});
}
}
v
}