52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187 | def sanity_checks(net: pp.auxiliary.pandapowerNet) -> dict: # noqa: PLR0915
"""Run pandapower diagnostic checks on the network.
Executes all available diagnostic functions to validate the network
structure and catch common modeling errors.
Note: Disconnected spatial fragments are filtered during bus/line extraction
(_get_buses_and_lines). Any remaining disconnected elements indicate
voltage-level isolation issues after transformer assignment.
Args:
net: A pandapower Network object to validate.
Returns:
dict with keys for each diagnostic check and their results.
Results are lists of issues found, empty if no issues detected.
"""
results = {}
issues_found = [False] # Use list for mutable reference in helper function
log.info("Starting network sanity checks...")
log.info(
f"Network size: {len(net.bus)} buses, {len(net.line)} lines, "
f"{len(net.trafo)} transformers, {len(net.gen)} generators, {len(net.load)} loads"
)
# Checks that don't require parameters
simple_checks = {
"invalid_values": InvalidValues(),
"missing_bus_indices": MissingBusIndices(),
"multiple_voltage_controlling_elements_per_bus": MultipleVoltageControllingElementsPerBus(),
"no_ext_grid": NoExtGrid(),
"deviation_from_std_type": DeviationFromStdType(),
}
for name, checker in simple_checks.items():
try:
errors = checker.diagnostic(net)
results[name] = errors
_log_check_result(name, errors, issues_found)
except Exception as e:
error_msg = f"Error running diagnostic: {e!s}"
results[name] = error_msg
_log_check_result(name, error_msg, issues_found)
# Special handling for disconnected elements:
# Spatial fragments are now filtered during bus/line extraction (_cleanup_disconnected_fragments).
# Any remaining disconnections indicate voltage-level isolation after transformer assignment.
try:
disconnected = DisconnectedElements().diagnostic(net) or []
results["disconnected_elements"] = disconnected
if isinstance(disconnected, list) and len(disconnected) > 0:
issues_found[0] = True
element_count = sum(
len(v) if isinstance(v, list) else 0
for fragment in disconnected
for v in fragment.values()
)
log.warning(
f" ✗ disconnected_elements: {len(disconnected)} fragment(s), "
f"{element_count} disconnected element(s) found"
)
log.debug(f" Disconnected: {disconnected}")
results["note_disconnected"] = (
f"Found {element_count} disconnected element(s). "
"Spatial fragments were filtered during bus extraction. "
"Remaining disconnections indicate voltage-level isolation after "
"transformer assignment (may need manual review)."
)
else:
log.debug(" ✓ disconnected_elements: OK")
except Exception as e:
error_msg = f"Error running diagnostic: {e!s}"
results["disconnected_elements"] = error_msg
_log_check_result("disconnected_elements", error_msg, issues_found)
# Checks that require parameters - use sensible defaults.
# max_x_ohm is set above pandapower's 100 ohm default because the NEM includes
# genuinely long EHV/sub-transmission lines (up to ~360 km) whose total
# reactance from the per-km _LINE_PARAMS naturally exceeds 100 ohm without
# indicating a data problem.
try:
errors = ImplausibleImpedanceValues().diagnostic(
net, min_r_ohm=0.0, min_x_ohm=0.0, max_r_ohm=100.0, max_x_ohm=200.0
)
results["implausible_impedance_values"] = errors
_log_check_result("implausible_impedance_values", errors, issues_found)
except Exception as e:
error_msg = f"Error running diagnostic: {e!s}"
results["implausible_impedance_values"] = error_msg
_log_check_result("implausible_impedance_values", error_msg, issues_found)
# Informational only (does not flip issues_found): very long/high-impedance
# lines typically represent real lightly-loaded radial spans (e.g. rural
# sub-transmission over 150+ km) rather than data errors -- a better Ohm/km
# constant alone cannot fix the underlying long-line load-flow behavior, so
# this is reported separately from the hard-failure checks above.
try:
in_service_lines = net.line[net.line["in_service"]]
vn_kv = net.bus.loc[in_service_lines["from_bus"], "vn_kv"].to_numpy()
z_base_ohm = vn_kv**2 / 100.0 # 100 MVA base, matching implausible_impedance_values
x_pu = (
in_service_lines["length_km"] * in_service_lines["x_ohm_per_km"]
).to_numpy() / z_base_ohm
flagged = in_service_lines.index[x_pu > 1.0].tolist()
results["long_high_impedance_lines"] = flagged
if flagged:
log.info(
f" ℹ long_high_impedance_lines: {len(flagged)} line(s) with x_pu > 1.0 " # noqa: RUF001
"(likely long/lightly-loaded radial spans, not a parameter bug)"
)
log.debug(f" Lines: {flagged}")
else:
log.debug(" ✓ long_high_impedance_lines: OK")
except Exception as e:
results["long_high_impedance_lines"] = f"Error running diagnostic: {e!s}"
log.error(f" ✗ long_high_impedance_lines: {e!s}")
try:
errors = NominalVoltagesMismatch().diagnostic(net, nom_voltage_tolerance=0.05)
results["nominal_voltages_mismatch"] = errors
_log_check_result("nominal_voltages_mismatch", errors, issues_found)
except Exception as e:
error_msg = f"Error running diagnostic: {e!s}"
results["nominal_voltages_mismatch"] = error_msg
_log_check_result("nominal_voltages_mismatch", error_msg, issues_found)
# Summary
if issues_found[0]:
log.warning(
"Network sanity checks completed with issues found. Review logs above for details."
)
else:
log.info("Network sanity checks completed successfully - no issues detected!")
return results
|