Skip to content

Commit 07052d3

Browse files
committed
added validation gen type and costs
1 parent 5c924f4 commit 07052d3

File tree

4 files changed

+208
-4
lines changed

4 files changed

+208
-4
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Before opening a PR, make sure you complete all steps:
44

55
### 1. Development setup
6-
- [ ] Install dev and test dependencies:
6+
- [ ] Install dev and test dependencies:
77
```bash
88
pip install -e ".[dev,test]"
99
````
@@ -78,6 +78,3 @@ Before opening a PR, make sure you complete all steps:
7878
* [ ] **Rebase your branch onto the latest `main`/`dev` before opening a PR.**
7979
* [ ] Open a PR with a short description of your changes and add Alban Puech as a reviewer.
8080
* [ ] Ensure code, tests, and documentation are clear and complete.
81-
82-
83-

docs/components/validation.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ This module provides functions for validating the integrity and physical consist
4444

4545
::: gridfm_datakit.validation.validate_power_balance_equations
4646

47+
### `validate_constant_cost_generators_unchanged`
48+
49+
::: gridfm_datakit.validation.validate_constant_cost_generators_unchanged
50+
51+
### `validate_bus_type_generator_consistency`
52+
53+
::: gridfm_datakit.validation.validate_bus_type_generator_consistency
54+
4755
### `validate_scenario_indexing_consistency`
4856

4957
::: gridfm_datakit.validation.validate_scenario_indexing_consistency

docs/manual/generation_perturbations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ The module provides three options for generation perturbation strategies:
1010
- `PermuteGenCostGenerator` randomly permutes the generator cost coefficients across and among generator elements.
1111

1212
- `PerturbGenCostGenerator` applies a scaling factor to all generator cost coefficients. The scaling factor is sampled from a uniform distribution with a range given by `[max(0, 1-sigma), 1+sigma)`, where `sigma` is a user-defined adjustable parameter.
13+
14+
15+
16+
### Constant-Cost Generators
17+
18+
Generators with **constant-only costs** (where `c1 = 0` and `c2 = 0`, but `c0 ≠ 0`) are **excluded from perturbation and permutation** operations. These generators maintain identical cost coefficients across all scenarios.

gridfm_datakit/validation.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,22 @@ def validate_generated_data(
274274
except Exception as e:
275275
raise AssertionError(f"Power balance equations validation failed: {e}")
276276

277+
# Run Generator Cost Perturbation Tests
278+
try:
279+
validate_constant_cost_generators_unchanged(generated_data)
280+
except Exception as e:
281+
raise AssertionError(
282+
f"Constant cost generators unchanged validation failed: {e}",
283+
)
284+
285+
# Run Bus Type and Generator Consistency Tests
286+
try:
287+
validate_bus_type_generator_consistency(generated_data)
288+
except Exception as e:
289+
raise AssertionError(
290+
f"Bus type-generator consistency validation failed: {e}",
291+
)
292+
277293
return True
278294

279295

@@ -1144,6 +1160,183 @@ def validate_power_balance_equations(
11441160
print(" Power balance equations (Kirchhoff's Law): OK")
11451161

11461162

1163+
def validate_constant_cost_generators_unchanged(
1164+
generated_data: Dict[str, pd.DataFrame],
1165+
) -> None:
1166+
"""Test that generators with constant-only costs remain unchanged across scenarios (vectorized).
1167+
1168+
Generators with constant costs (only c0 != 0, with c1 == 0 and c2 == 0) should not
1169+
be perturbed or permuted, so their cost coefficients should remain identical across
1170+
all scenarios. This validation checks that constraint.
1171+
1172+
Args:
1173+
generated_data: Dictionary containing gen_data DataFrame.
1174+
1175+
Raises:
1176+
AssertionError: If any constant-cost generator has varying costs across scenarios.
1177+
"""
1178+
gen_data = generated_data["gen_data"]
1179+
1180+
if len(gen_data) == 0:
1181+
print(" Constant cost generators unchanged: no generators to validate")
1182+
return
1183+
1184+
scenarios = gen_data["scenario"].unique()
1185+
1186+
# Identify constant-cost generators (c1 == 0 and c2 == 0)
1187+
# We check the first scenario to identify which generators have constant costs
1188+
first_scenario_data = gen_data[gen_data["scenario"] == scenarios[0]].copy()
1189+
1190+
# Check if generators have non-zero c1 or c2 (columns cp1_eur_per_mw, cp2_eur_per_mw2)
1191+
# Constant-cost generators have both c1 and c2 equal to zero
1192+
constant_cost_mask = (first_scenario_data["cp1_eur_per_mw"] == 0) & (
1193+
first_scenario_data["cp2_eur_per_mw2"] == 0
1194+
)
1195+
1196+
# Use "idx" to uniquely identify generators (bus alone is not unique - multiple gens can be at same bus)
1197+
constant_cost_gen_idx = first_scenario_data[constant_cost_mask]["idx"].values
1198+
1199+
if len(constant_cost_gen_idx) == 0:
1200+
print(
1201+
" Constant cost generators unchanged: no constant-cost generators found",
1202+
)
1203+
return
1204+
1205+
print(
1206+
f" Constant cost generators unchanged: validating {len(constant_cost_gen_idx)} constant-cost generators across {len(scenarios)} scenarios",
1207+
)
1208+
1209+
# Filter to constant-cost generators only
1210+
constant_gen_data = gen_data[gen_data["idx"].isin(constant_cost_gen_idx)][
1211+
["scenario", "idx", "bus", "cp0_eur", "cp1_eur_per_mw", "cp2_eur_per_mw2"]
1212+
].copy()
1213+
1214+
# Get reference costs from first scenario (for each generator idx)
1215+
reference_costs = constant_gen_data[
1216+
constant_gen_data["scenario"] == scenarios[0]
1217+
].set_index("idx")[["cp0_eur", "cp1_eur_per_mw", "cp2_eur_per_mw2"]]
1218+
1219+
# Merge reference costs with all scenarios for vectorized comparison
1220+
comparison = constant_gen_data.merge(
1221+
reference_costs,
1222+
left_on="idx",
1223+
right_index=True,
1224+
suffixes=("", "_ref"),
1225+
)
1226+
1227+
# Vectorized comparison across all generators and scenarios
1228+
tolerance = 1e-9
1229+
c0_diff = np.abs(comparison["cp0_eur"] - comparison["cp0_eur_ref"])
1230+
c1_diff = np.abs(comparison["cp1_eur_per_mw"] - comparison["cp1_eur_per_mw_ref"])
1231+
c2_diff = np.abs(comparison["cp2_eur_per_mw2"] - comparison["cp2_eur_per_mw2_ref"])
1232+
1233+
# Find any mismatches
1234+
mismatches = (
1235+
(c0_diff >= tolerance) | (c1_diff >= tolerance) | (c2_diff >= tolerance)
1236+
)
1237+
1238+
if mismatches.any():
1239+
# Get first mismatch for error reporting
1240+
mismatch_idx = np.where(mismatches)[0][0]
1241+
mismatch_row = comparison.iloc[mismatch_idx]
1242+
raise AssertionError(
1243+
f"Generator idx={int(mismatch_row['idx'])} at bus {int(mismatch_row['bus'])} (constant-cost) has varying costs across scenarios. "
1244+
f"Scenario {int(mismatch_row['scenario'])}: "
1245+
f"c0={mismatch_row['cp0_eur']:.6f}, c1={mismatch_row['cp1_eur_per_mw']:.6f}, c2={mismatch_row['cp2_eur_per_mw2']:.6f} "
1246+
f"vs reference: c0={mismatch_row['cp0_eur_ref']:.6f}, c1={mismatch_row['cp1_eur_per_mw_ref']:.6f}, c2={mismatch_row['cp2_eur_per_mw2_ref']:.6f}",
1247+
)
1248+
1249+
print(" Constant cost generators unchanged: OK")
1250+
1251+
1252+
def validate_bus_type_generator_consistency(
1253+
generated_data: Dict[str, pd.DataFrame],
1254+
) -> None:
1255+
"""Test that bus types are consistent with generator presence (vectorized).
1256+
1257+
Validates fundamental power system constraints:
1258+
- PV buses (voltage-controlled) must have at least one active generator
1259+
- PQ buses (load buses) must have NO active generators
1260+
- REF buses (slack/reference) must have at least one active generator
1261+
1262+
Args:
1263+
generated_data: Dictionary containing bus_data and gen_data DataFrames.
1264+
1265+
Raises:
1266+
AssertionError: If any bus type constraint is violated.
1267+
"""
1268+
bus_data = generated_data["bus_data"]
1269+
gen_data = generated_data["gen_data"]
1270+
1271+
scenarios = bus_data["scenario"].unique()
1272+
print(
1273+
f" Bus type-generator consistency: validating {len(bus_data)} bus entries across {len(scenarios)} scenarios",
1274+
)
1275+
1276+
# Count active generators per (scenario, bus)
1277+
active_gens = (
1278+
gen_data[gen_data["in_service"] == 1]
1279+
.groupby(
1280+
["scenario", "bus"],
1281+
as_index=False,
1282+
)
1283+
.size()
1284+
)
1285+
active_gens.columns = ["scenario", "bus", "n_active_gens"]
1286+
1287+
# Merge with bus data
1288+
bus_with_gen_counts = bus_data.merge(
1289+
active_gens,
1290+
on=["scenario", "bus"],
1291+
how="left",
1292+
).fillna({"n_active_gens": 0})
1293+
1294+
bus_with_gen_counts["n_active_gens"] = bus_with_gen_counts["n_active_gens"].astype(
1295+
int,
1296+
)
1297+
1298+
# Validate PV buses have at least one active generator
1299+
pv_buses = bus_with_gen_counts[bus_with_gen_counts["PV"] == 1]
1300+
pv_no_gen = pv_buses[pv_buses["n_active_gens"] == 0]
1301+
1302+
if len(pv_no_gen) > 0:
1303+
first_violation = pv_no_gen.iloc[0]
1304+
raise AssertionError(
1305+
f"PV bus {int(first_violation['bus'])} in scenario {int(first_violation['scenario'])} "
1306+
f"has no active generators. PV buses must have at least one active generator to control voltage. "
1307+
f"Found {len(pv_no_gen)} total violations.",
1308+
)
1309+
1310+
# Validate PQ buses have NO active generators
1311+
pq_buses = bus_with_gen_counts[bus_with_gen_counts["PQ"] == 1]
1312+
pq_with_gen = pq_buses[pq_buses["n_active_gens"] > 0]
1313+
1314+
if len(pq_with_gen) > 0:
1315+
first_violation = pq_with_gen.iloc[0]
1316+
raise AssertionError(
1317+
f"PQ bus {int(first_violation['bus'])} in scenario {int(first_violation['scenario'])} "
1318+
f"has {int(first_violation['n_active_gens'])} active generator(s). PQ buses (load buses) "
1319+
f"must have no active generators. Found {len(pq_with_gen)} total violations.",
1320+
)
1321+
1322+
# Validate REF (slack) buses have at least one active generator
1323+
ref_buses = bus_with_gen_counts[bus_with_gen_counts["REF"] == 1]
1324+
ref_no_gen = ref_buses[ref_buses["n_active_gens"] == 0]
1325+
1326+
if len(ref_no_gen) > 0:
1327+
first_violation = ref_no_gen.iloc[0]
1328+
raise AssertionError(
1329+
f"REF/Slack bus {int(first_violation['bus'])} in scenario {int(first_violation['scenario'])} "
1330+
f"has no active generators. REF buses must have at least one active generator to balance the system. "
1331+
f"Found {len(ref_no_gen)} total violations.",
1332+
)
1333+
1334+
print(
1335+
f" Bus type-generator consistency: validated {len(pv_buses)} PV, {len(pq_buses)} PQ, {len(ref_buses)} REF bus entries",
1336+
)
1337+
print(" Bus type-generator consistency: OK")
1338+
1339+
11471340
def validate_scenario_indexing_consistency(
11481341
generated_data: Dict[str, pd.DataFrame],
11491342
) -> None:

0 commit comments

Comments
 (0)