@@ -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+
11471340def validate_scenario_indexing_consistency (
11481341 generated_data : Dict [str , pd .DataFrame ],
11491342) -> None :
0 commit comments