diff --git a/docs/components/load.md b/docs/components/load.md
index 0d9675ac7..20aec7980 100644
--- a/docs/components/load.md
+++ b/docs/components/load.md
@@ -7,24 +7,29 @@ This module provides functions and classes for generating load scenarios.
### `LoadScenarioGeneratorBase`
-::: gridfm_datakit.perturbations.load_perturbation.LoadScenarioGeneratorBase
+:::: gridfm_datakit.perturbations.load_perturbation.LoadScenarioGeneratorBase
### `LoadScenariosFromAggProfile`
-::: gridfm_datakit.perturbations.load_perturbation.LoadScenariosFromAggProfile
+:::: gridfm_datakit.perturbations.load_perturbation.LoadScenariosFromAggProfile
### `Powergraph`
-::: gridfm_datakit.perturbations.load_perturbation.Powergraph
+:::: gridfm_datakit.perturbations.load_perturbation.Powergraph
+### `PrecomputedProfile`
+
+
+:::: gridfm_datakit.perturbations.load_perturbation.PrecomputedProfile
+
### `load_scenarios_to_df`
-::: gridfm_datakit.perturbations.load_perturbation.load_scenarios_to_df
+:::: gridfm_datakit.perturbations.load_perturbation.load_scenarios_to_df
### `plot_load_scenarios_combined`
-::: gridfm_datakit.perturbations.load_perturbation.plot_load_scenarios_combined
+:::: gridfm_datakit.perturbations.load_perturbation.plot_load_scenarios_combined
diff --git a/docs/manual/getting_started.md b/docs/manual/getting_started.md
index 57db8d10d..c2003c5a3 100644
--- a/docs/manual/getting_started.md
+++ b/docs/manual/getting_started.md
@@ -34,11 +34,12 @@ network:
network_dir: "scripts/grids" # if using source "file", this is the directory containing the network file (relative to the project root)
load:
- generator: "agg_load_profile" # Name of the load generator; options: agg_load_profile, powergraph
+ generator: "agg_load_profile" # Name of the load generator; options: agg_load_profile, powergraph, precomputed_profile
agg_profile: "default" # Name of the aggregated load profile
scenarios: 10000 # Number of different load scenarios to generate
+ scenario_file: "load-scenarios-precomputed.csv" # Only used if generator is "precomputed_profile"
# WARNING: the following parameters are only used if generator is "agg_load_profile"
- # if using generator "powergraph", these parameters are ignored
+ # if using generator "powergraph" or "precomputed_profile", these parameters are ignored
sigma: 0.2 # max local noise
change_reactive_power: true # If true, changes reactive power of loads. If False, keeps the ones from the case file
global_range: 0.4 # Range of the global scaling factor. used to set the lower bound of the scaling factor
diff --git a/docs/manual/load_scenarios.md b/docs/manual/load_scenarios.md
index ac0a974ec..95c4a3b8a 100644
--- a/docs/manual/load_scenarios.md
+++ b/docs/manual/load_scenarios.md
@@ -6,20 +6,20 @@ Load perturbations generate multiple load scenarios from an initial case file sc
-The module provides two main perturbation strategies:
+The module provides three main perturbation strategies:
### Comparison of Perturbation Strategies
-| Feature | `LoadScenariosFromAggProfile` | `Powergraph` |
-|-----------------------------|-------------------------------|-------------------------------|
-| **Global scaling** | ✅ Yes | ✅ Yes |
-| **Local (per-load) scaling**| ✅ Yes (via noise) | ❌ No |
-| **Reactive power perturbed**| ✅ Optional | ❌ No |
-| **Interpolation** | ✅ Yes | ✅ Yes |
-| **Use of real profile data**| ✅ Yes | ✅ Yes |
+| Feature | `LoadScenariosFromAggProfile` | `Powergraph` | `PrecomputedProfile` |
+|-----------------------------|-------------------------------|-------------------------------|-------------------------------|
+| **Global scaling** | ✅ Yes | ✅ Yes | ❌ No |
+| **Local (per-load) scaling**| ✅ Yes (via noise) | ❌ No | ❌ No |
+| **Reactive power perturbed**| ✅ Optional | ❌ No | ✅ From file |
+| **Interpolation** | ✅ Yes | ✅ Yes | ❌ No |
+| **Use of real profile data**| ✅ Yes | ✅ Yes | ✅ From file |
@@ -85,7 +85,7 @@ Sample config parameters:
```yaml
load:
- generator: "agg_load_profile" # Name of the load generator; options: agg_load_profile, powergraph
+ generator: "agg_load_profile" # Name of the load generator; options: agg_load_profile, powergraph, precomputed_profile
agg_profile: "default" # Name of the aggregated load profile
scenarios: 200 # Number of different load scenarios to generate
sigma: 0.2 # max local noise
@@ -129,6 +129,37 @@ load:
scenarios: 200 # Number of load scenarios to generate
```
+### `PrecomputedProfile`
+Loads scenarios directly from a CSV or Excel file. Each row specifies the active and
+reactive power for a given `(load_scenario, load)` pair. All pairs must be present.
+
+Required columns:
+
+- `load_scenario`: scenario index (0..n_scenarios-1)
+- `load`: bus index (0..n_buses-1) using the network's continuous indexing
+- `p_mw`: active power in MW
+- `q_mvar`: reactive power in MVAr
+
+Constraints:
+
+- `load_scenario` and `load` must be integer-valued.
+- All `(load_scenario, load)` pairs must be unique.
+- The file must include exactly `n_buses * n_scenarios` rows.
+
+Sample config parameters:
+
+```yaml
+load:
+ generator: "precomputed_profile"
+ scenario_file: "load-scenarios-precomputed.csv"
+ scenarios: 200
+```
+
+Example files:
+
+- `scripts/config/case14_config_precomputed_profile.yaml`
+- `scripts/precomputed_load_profiles/load-scenarios-precomputed-case14.csv`
+
## Aggregated load profiles
The following load profiles are available in the `gridfm-datakit/load_profiles` directory:
diff --git a/docs/manual/outputs.md b/docs/manual/outputs.md
index ec8a38fe3..635cef9c0 100644
--- a/docs/manual/outputs.md
+++ b/docs/manual/outputs.md
@@ -24,7 +24,7 @@ Load scenarios (per-element time series) produced by the selected load generator
Plot of the generated load scenarios.
#### `scenarios_{generator}.log`
-Generator-specific notes (e.g., bounds for the global scaling factor when using `agg_load_profile`).
+Generator-specific notes (e.g., bounds for the global scaling factor when using `agg_load_profile`, or source file path for `precomputed_profile`).
#### `n_scenarios.txt`
Metadata file containing the total number of scenarios (used for efficient partition management).
diff --git a/gridfm_datakit/perturbations/load_perturbation.py b/gridfm_datakit/perturbations/load_perturbation.py
index 0d2b2e335..f8e5c12be 100644
--- a/gridfm_datakit/perturbations/load_perturbation.py
+++ b/gridfm_datakit/perturbations/load_perturbation.py
@@ -567,6 +567,107 @@ def __call__(
return load_profiles
+class PrecomputedProfile(LoadScenarioGeneratorBase):
+ """Reads precomputed bus-demand scenarios and returns them as (n_buses, n_scenarios, 2).
+
+ CSV/XLSX columns:
+ - load_scenario : int scenario index (0..S-1)
+ - load : int BUS INDEX (0..n_buses-1) in *continuous indexing* used by Network
+ (i.e., after mapping to 0..n_buses-1)
+ - p_mw, q_mvar : floats
+ """
+
+ def __init__(self, scenario_file: str):
+ self.scenario_file = scenario_file
+
+ def _read(self) -> pd.DataFrame:
+ p = self.scenario_file
+ if p.lower().endswith((".xlsx", ".xls")):
+ return pd.read_excel(p)
+ return pd.read_csv(p)
+
+ def __call__(
+ self,
+ net, # type: Network
+ n_scenarios: int,
+ scenario_log: str,
+ max_iter: int, # unused, kept for interface compatibility
+ seed: int,
+ ) -> np.ndarray:
+ df = self._read()
+
+ required = {"load_scenario", "load", "p_mw", "q_mvar"}
+ missing = required - set(df.columns)
+ if missing:
+ raise ValueError(
+ f"Scenario file must contain columns {sorted(required)}; missing {sorted(missing)}. "
+ f"Got {list(df.columns)}"
+ )
+
+ # Validate integer-valued indices before casting
+ for col in ["load_scenario", "load"]:
+ numeric = pd.to_numeric(df[col], errors="coerce")
+ if numeric.isna().any():
+ raise ValueError(f"Column '{col}' must contain only numeric values.")
+ if not np.all(np.isclose(numeric, np.round(numeric))):
+ raise ValueError(f"Column '{col}' must contain integer-valued entries.")
+ df[col] = numeric.astype(int)
+
+ n_buses = int(np.asarray(net.buses).shape[0])
+
+ # Scenario index validation (0..n_scenarios-1)
+ min_scenario = int(df["load_scenario"].min())
+ max_scenario = int(df["load_scenario"].max())
+ if min_scenario < 0 or max_scenario >= n_scenarios:
+ raise ValueError(
+ "Scenario file contains out-of-range scenario indices in column 'load_scenario'. "
+ f"Expected 0..{n_scenarios - 1}, got min={min_scenario}, max={max_scenario}."
+ )
+
+ # Bus index validation (continuous indices 0..n_buses-1)
+ min_bus = int(df["load"].min())
+ max_bus = int(df["load"].max())
+ if min_bus < 0 or max_bus >= n_buses:
+ raise ValueError(
+ "Scenario file contains out-of-range bus indices in column 'load'. "
+ f"Expected 0..{n_buses - 1}, got min={min_bus}, max={max_bus}."
+ )
+
+ # uniqueness of (load_scenario, load) pairs
+ dup_mask = df.duplicated(subset=["load_scenario", "load"])
+ if dup_mask.any():
+ dup_count = int(dup_mask.sum())
+ raise ValueError(
+ f"Scenario file contains {dup_count} duplicate (load_scenario, load) pairs. "
+ "Each pair must be unique."
+ )
+
+ # check: require all scenario-bus pairs present
+ expected = n_buses * n_scenarios
+ actual = len(df)
+ if actual != expected:
+ raise ValueError(
+ f"Scenario file must contain exactly {expected} rows "
+ f"({n_buses} buses x {n_scenarios} scenarios); got {actual}."
+ )
+
+ # Allocate output: (n_buses, n_scenarios, 2)
+ out = np.zeros((n_buses, n_scenarios, 2), dtype=float)
+
+ s = df["load_scenario"].to_numpy(dtype=int)
+ b = df["load"].to_numpy(dtype=int)
+ out[b, s, 0] = df["p_mw"].to_numpy(dtype=float)
+ out[b, s, 1] = df["q_mvar"].to_numpy(dtype=float)
+
+ if scenario_log:
+ with open(scenario_log, "a") as f:
+ f.write(
+ f"precomputed_profile: scenarios={n_scenarios}, buses={n_buses}, "
+ f"path={self.scenario_file}\n"
+ )
+
+ return out
+
if __name__ == "__main__":
"""
diff --git a/gridfm_datakit/utils/param_handler.py b/gridfm_datakit/utils/param_handler.py
index cd1fca724..bfc5bf7c7 100644
--- a/gridfm_datakit/utils/param_handler.py
+++ b/gridfm_datakit/utils/param_handler.py
@@ -3,6 +3,7 @@
LoadScenarioGeneratorBase,
LoadScenariosFromAggProfile,
Powergraph,
+ PrecomputedProfile,
)
from typing import Dict, Any
import warnings
@@ -196,6 +197,19 @@ def get_load_scenario_generator(args: NestedNamespace) -> LoadScenarioGeneratorB
)
return Powergraph(args.agg_profile)
+
+ if args.generator == "precomputed_profile":
+ unused_args = {
+ key: value
+ for key, value in args.flatten().items()
+ if key not in ["generator", "scenario_file", "scenarios"]
+ }
+ if unused_args:
+ warnings.warn(
+ f"The following arguments are not used by the precomputed_profile generator: {unused_args}",
+ UserWarning,
+ )
+ return PrecomputedProfile(args.scenario_file)
def initialize_topology_generator(
diff --git a/scripts/config/case14_config_precomputed_profile.yaml b/scripts/config/case14_config_precomputed_profile.yaml
new file mode 100644
index 000000000..6045321cc
--- /dev/null
+++ b/scripts/config/case14_config_precomputed_profile.yaml
@@ -0,0 +1,51 @@
+network:
+ name: "case14_ieee" # Name of the power grid network (without extension)
+ source: "pglib" # Data source for the grid; options: pglib, file
+ # WARNING: the following parameter is only used if source is "file"
+ network_dir: "scripts/grids" # if using source "file", this is the directory containing the network file (relative to the project root)
+
+load:
+ generator: "precomputed_profile" # Name of the load generator; options: agg_load_profile, powergraph, precomputed_profile
+ agg_profile: "default" # Name of the aggregated load profile
+ scenarios: 2 # Number of different load scenarios to generate. #if using precomputed_profile, this should match the number of scenarios in the csv file
+ # WARNING: the following parameters are only used if generator is "agg_load_profile"
+ # if using generator "powergraph" or "precomputed_profile", these parameters are ignored
+ sigma: 0.0 # max local noise
+ change_reactive_power: false # If true, changes reactive power of loads. If False, keeps the ones from the case file
+ global_range: 0.4 # Range of the global scaling factor. used to set the lower bound of the scaling factor
+ max_scaling_factor: 4.0 # Max upper bound of the global scaling factor
+ step_size: 0.1 # Step size when finding the upper bound of the global scaling factor
+ start_scaling_factor: 1.0 # Initial value of the global scaling factor
+ # WARNING: the following parameters are only used if generator is "precomputed_profile"
+ # if using generator "agg_load_profile" or "powergraph", these parameters are ignored
+ scenario_file: "scripts/precomputed_load_profiles/load-scenarios-precomputed-case14.csv" # precomputed scenarios (cols: load_scenario, load, p_mw, q_mvar)
+
+topology_perturbation:
+ type: "random" # Type of topology generator; options: n_minus_k, random, none
+ # WARNING: the following parameters are only used if type is not "none"
+ k: 1 # Maximum number of components to drop in each perturbation
+ n_topology_variants: 2 # Number of unique perturbed topologies per scenario
+ elements: [branch, gen] # elements to perturb. options: branch, gen
+
+generation_perturbation:
+ type: "cost_permutation" # Type of generation perturbation; options: cost_permutation, cost_perturbation, none
+ # WARNING: the following parameter is only used if type is "cost_permutation"
+ sigma: 1.0 # Size of range used for sampling scaling factor
+
+admittance_perturbation:
+ type: "random_perturbation" # Type of admittance perturbation; options: random_perturbation, none
+ # WARNING: the following parameter is only used if type is "random_perturbation"
+ sigma: 0.2 # Size of range used for sampling scaling factor
+
+settings:
+ num_processes: 16 # Number of parallel processes to use
+ data_dir: "./data_out" # Directory to save generated data relative to the project root
+ large_chunk_size: 1000 # Number of load scenarios processed before saving
+ overwrite: true # If true, overwrites existing files, if false, appends to files
+ mode: "pf" # Mode of the script; options: pf, opf. pf: power flow data where one or more operating limits – the inequality constraints defined in OPF, e.g., voltage magnitude or branch limits – may be violated. opf: generates datapoints for training OPF solvers, with cost-optimal dispatches that satisfy all operating limits (OPF-feasible)
+ include_dc_res: true # If true, also stores the results of dc power flow or dc optimal power flow
+ enable_solver_logs: true # If true, write OPF/PF logs to {data_dir}/solver_log; PF fast and DCPF fast do not log.
+ pf_fast: true # Whether to use fast PF solver by default (compute_ac_pf from powermodels.jl); if false, uses Ipopt-based PF. Some networks (typically large ones e.g. case10000_goc) do not work with pf_fast: true. pf_fast is faster and more accurate than the Ipopt-based PF.
+ dcpf_fast: true # Whether to use fast DCPF solver by default (compute_dc_pf from PowerModels.jl)
+ max_iter: 200 # Max iterations for Ipopt-based solvers
+ seed: null # Seed for random number generation. If null, a random seed is generated (RECOMMENDED). To get the same data across runs, set the seed and note that ALL OTHER PARAMETERS IN THE CONFIG FILE MUST BE THE SAME.
diff --git a/scripts/precomputed_load_profiles/load-scenarios-precomputed-case14.csv b/scripts/precomputed_load_profiles/load-scenarios-precomputed-case14.csv
new file mode 100644
index 000000000..f50d152f9
--- /dev/null
+++ b/scripts/precomputed_load_profiles/load-scenarios-precomputed-case14.csv
@@ -0,0 +1,29 @@
+load_scenario,load,p_mw,q_mvar
+0,0,0,0
+0,1,21.7,12.7
+0,2,94.2,19.0
+0,3,47.8,-3.9
+0,4,7.6,1.6
+0,5,11.2,7.5
+0,6,0,0
+0,7,0,0
+0,8,29.5,16.6
+0,9,9.0,5.8
+0,10,3.5,1.8
+0,11,6.1,1.6
+0,12,13.5,5.8
+0,13,14.9,5.0
+1,0,0,0
+1,1,22.7,13.2
+1,2,94.2,19.0
+1,3,47.8,-3.9
+1,4,7.6,1.6
+1,5,11.2,7.5
+1,6,0,0
+1,7,0,0
+1,8,29.5,16.6
+1,9,9.0,5.8
+1,10,3.5,1.8
+1,11,6.1,1.6
+1,12,13.5,5.8
+1,13,14.9,5.0
\ No newline at end of file