API Reference

This section provides detailed API documentation for all WEC-Grid classes and functions, auto-generated from the source code docstrings.

Core Components

Engine

Main orchestrator for WEC-Grid simulations and cross-platform power system analysis.

Coordinates WEC farm integration with PSS®E and PyPSA power system modeling backends. Manages simulation workflows, time synchronization, and visualization for grid studies.

Attributes:

Name Type Description
case_file str | None

Path to power system case file (.RAW).

case_name str | None

Human-readable case identifier.

time WECGridTime

Time coordination and snapshot management.

psse PSSEModeler | None

PSS®E simulation interface.

pypsa PyPSAModeler | None

PyPSA simulation interface.

wec_farms List[WECFarm]

Collection of WEC farms in simulation.

database WECGridDB

Database interface for simulation data.

plot WECGridPlot

Visualization and plotting interface.

wecsim WECSimRunner

WEC-Sim integration for device modeling.

sbase float | None

System base power in MVA.

Example

engine = Engine() engine.case("IEEE_30_bus") engine.load(["psse", "pypsa"]) engine.apply_wec("North Farm", size=5, bus_location=14) engine.simulate(load_curve=True) engine.plot.comparison_suite()

Notes
  • PSS®E requires commercial license; PyPSA is open-source
  • WEC data from WEC-Sim simulations (requires MATLAB)
  • Supports cross-platform validation studies
TODO
  • Consider renaming to WECGridEngine for clarity
  • need a way to map GridState componet names to modeler component names
Source code in src/wecgrid/engine.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
class Engine:
    """Main orchestrator for WEC-Grid simulations and cross-platform power system analysis.

    Coordinates WEC farm integration with PSS®E and PyPSA power system modeling backends.
    Manages simulation workflows, time synchronization, and visualization for grid studies.

    Attributes:
        case_file (str | None): Path to power system case file (.RAW).
        case_name (str | None): Human-readable case identifier.
        time (WECGridTime): Time coordination and snapshot management.
        psse (PSSEModeler | None): PSS®E simulation interface.
        pypsa (PyPSAModeler | None): PyPSA simulation interface.
        wec_farms (List[WECFarm]): Collection of WEC farms in simulation.
        database (WECGridDB): Database interface for simulation data.
        plot (WECGridPlot): Visualization and plotting interface.
        wecsim (WECSimRunner): WEC-Sim integration for device modeling.
        sbase (float | None): System base power in MVA.

    Example:
        >>> engine = Engine()
        >>> engine.case("IEEE_30_bus")
        >>> engine.load(["psse", "pypsa"])
        >>> engine.apply_wec("North Farm", size=5, bus_location=14)
        >>> engine.simulate(load_curve=True)
        >>> engine.plot.comparison_suite()

    Notes:
        - PSS®E requires commercial license; PyPSA is open-source
        - WEC data from WEC-Sim simulations (requires MATLAB)
        - Supports cross-platform validation studies

    TODO:
        - Consider renaming to WECGridEngine for clarity
        - need a way to map GridState componet names to modeler component names
    """

    def __init__(self):
        """Initialize the WEC-Grid Engine with default configuration.

        Creates engine instance ready for case loading and simulation setup.
        All modelers are None until explicitly loaded via load() method.
        """
        self.case_file: Optional[str] = None
        self.case_name: Optional[str] = None
        self.time = WECGridTime()  # TODO this needs more functionality
        self.psse: Optional[PSSEModeler] = None
        self.pypsa: Optional[PyPSAModeler] = None
        self.wec_farms: List[WECFarm] = []
        self.database = WECGridDB(self)
        self.plot = WECGridPlot(self)
        self.wecsim: WECSimRunner = WECSimRunner(self.database)
        self.sbase: Optional[float] = None

    # print(r"""

    #  __     __     ______     ______     ______     ______     __     _____
    # /\ \  _ \ \   /\  ___\   /\  ___\   /\  ___\   /\  == \   /\ \   /\  __-.
    # \ \ \/ ".\ \  \ \  __\   \ \ \____  \ \ \__ \  \ \  __<   \ \ \  \ \ \/\ \
    #  \ \__/".~\_\  \ \_____\  \ \_____\  \ \_____\  \ \_\ \_\  \ \_\  \ \____-
    #   \/_/   \/_/   \/_____/   \/_____/   \/_____/   \/_/ /_/   \/_/   \/____/
    #             """)

    def __repr__(self) -> str:
        """String representation of Engine.

        Returns:
            str: Tree-style summary
        """
        return (
            f"Engine:\n"
            f"├─ Case: {self.case_name}\n"
            f"├─ PyPSA: {'Loaded' if self.pypsa else 'Not Loaded'}\n"
            f"├─ PSS/E: {'Loaded' if self.psse else 'Not Loaded'}\n"
            f"├─ WEC-Farms/WECs: {len(self.wec_farms)} - {len(self.wec_farms) and sum(len(farm.wec_devices) for farm in self.wec_farms) or 0}\n"
            f"└─ Buses: {len(self.pypsa.bus) if self.pypsa else len(self.psse.bus) if self.psse else 0}\n"
            f"\n"
            f"Sbase: {self.sbase if self.sbase else 'Not Loaded'} MVA"
        )

    def case(self, case_file: str):
        """Specify the power system case file for subsequent loading.

        Args:
            case_file (str): Path or identifier for a PSS®E RAW case file. Examples:
                - Full paths: ``"/path/to/system.RAW"``
                - Bundled cases: ``"IEEE_30_bus"``
                - With extension: ``"IEEE_39_bus.RAW"``

        Example:
            >>> engine.case("IEEE_30_bus")
            >>> print(engine.case_name)
            IEEE 30 bus

        Notes:
            This method only stores the file path and a human-friendly name.
            It does not verify that the file exists or is loadable.
            Only PSS®E RAW (.RAW) format is supported.
        """
        self.case_file = str(case_file)
        self.case_name = Path(case_file).stem.replace("_", " ").replace("-", " ")

    def load(self, software: List[str]) -> None:
        """Initialize power system simulation backends.

        Args:
            software (List[str]): Backends to initialize ("psse", "pypsa").

        Raises:
            ValueError: If no case file loaded or invalid software name.
            RuntimeError: If initialization fails (missing license, etc.).

        Example:
            >>> engine.case("IEEE_30_bus")
            >>> engine.load(["psse", "pypsa"])

        Notes:
            - PSS®E requires commercial license; PyPSA is open-source
            - Enables cross-platform validation studies
            - Both backends are independent and can simulate separately

        TODO:
            - Add error handling for PSS®E license failures
        """
        if self.case_file is None:
            raise ValueError(
                "No case file set. Use `engine.case('path/to/case.RAW')` first."
            )

        for name in software:
            name = name.lower()
            if name == "psse":
                self.psse = PSSEModeler(self)
                self.psse.init_api()
                self.sbase = self.psse.sbase
                # TODO: check if error is thrown if init fails
            elif name == "pypsa":
                self.pypsa = PyPSAModeler(self)
                self.pypsa.init_api()
                self.sbase = self.pypsa.sbase
                # if self.psse is not None:
                #     self.psse.adjust_reactive_lim()
                # TODO: check if error is thrown if init fails
            else:
                raise ValueError(
                    f"Unsupported software: '{name}'. Use 'psse' or 'pypsa'."
                )

    def apply_wec(
        self,
        farm_name: str,
        size: int = 1,
        wec_sim_id: int = 1,
        bus_location: int = 1,
        connecting_bus: int = 1,  # todo this should default to swing bus
        scaling_factor: int = 1,  # used for scaling wec power output
    ) -> None:
        """Add a Wave Energy Converter (WEC) farm to the power system.

        Args:
            farm_name (str): Human-readable WEC farm identifier.
            size (int, optional): Number of WEC devices in farm. Defaults to 1.
            wec_sim_id (int, optional): Database simulation ID for WEC data. Defaults to 1.
            bus_location (int, optional): Grid bus for WEC connection. Defaults to 1.
            connecting_bus (int, optional): Network topology connection bus. Defaults to 1.
            scaling_factor (int, optional): Multiplier applied to WEC power output
                [unitless]. Defaults to 1.

        Example:
            >>> engine.apply_wec("North Coast Farm", size=20, bus_location=14)
            >>> print(f"Total farms: {len(engine.wec_farms)}")
            Total farms: 1

        Notes:
            - Farm power scales linearly with device count
            - WEC data sourced from database using sim_id
            - Generator IDs are auto-assigned sequentially based on farm order

        TODO:
            - Fix PSS®E generator ID limitation (max 9 farms)
            - Default connecting_bus should be swing bus
        """
        wec_farm: WECFarm = WECFarm(
            farm_name=farm_name,
            farm_id=len(self.wec_farms) + 1,  # Unique farm_id for each farm,
            gen_name="",
            database=self.database,
            time=self.time,
            wec_sim_id=wec_sim_id,
            bus_location=bus_location,
            connecting_bus=connecting_bus,
            size=size,
            sbase=self.sbase,
            scaling_factor=scaling_factor,
            # TODO potenital issue where PSSE is using gen_id as the gen identifer and that's limited to 2 chars. so hard cap at 9 farms in this code rn
        )
        self.wec_farms.append(wec_farm)

        for modeler in [self.psse, self.pypsa]:
            if modeler is not None:
                modeler.add_wec_farm(wec_farm)
                wec_farm.gen_name = (
                    modeler.grid.gen.loc[
                        modeler.grid.gen.bus == wec_farm.bus_location, "gen_name"
                    ].iloc[0]
                    if (modeler.grid.gen.bus == wec_farm.bus_location).any()
                    else None
                )
        print("WEC Farm added:", wec_farm.farm_name)

    def generate_load_curves(
        self,
        morning_peak_hour: float = 8.0,
        evening_peak_hour: float = 18.0,
        morning_sigma_h: float = 2.0,
        evening_sigma_h: float = 3.0,
        amplitude: float = 0.05,  # ±30% swing around mean
        min_multiplier: float = 0.50,  # floor/ceiling clamp
        amp_overrides: Optional[Dict[int, float]] = None,
    ) -> pd.DataFrame:
        """Generate realistic time-varying load profiles for power system simulation.

        Creates bus-specific load time series with double-peak daily pattern
        representing typical electrical demand. Scales base case loads with
        configurable peak timing and variability.

        Args:
            morning_peak_hour (float, optional): Morning demand peak time [hours].
                Defaults to 8.0.
            evening_peak_hour (float, optional): Evening demand peak time [hours].
                Defaults to 18.0.
            morning_sigma_h (float, optional): Morning peak width [hours]. Defaults to 2.0.
            evening_sigma_h (float, optional): Evening peak width [hours]. Defaults to 3.0.
            amplitude (float, optional): Maximum variation around base load.
                Defaults to 0.30 (±30%).
            min_multiplier (float, optional): Minimum load multiplier. Defaults to 0.70.
            amp_overrides (Dict[int, float], optional): Per-bus amplitude overrides.

        Returns:
            pd.DataFrame: Time-indexed load profiles [MW]. Index: simulation snapshots,
                Columns: bus numbers, Values: active power demand.

        Raises:
            ValueError: If no power system modeler loaded.

        Example:
            >>> # Generate standard load curves
            >>> profiles = engine.generate_load_curves()
            >>> print(f"Buses: {list(profiles.columns)}")

            >>> # Custom peaks for industrial area
            >>> custom = engine.generate_load_curves(
            ...     morning_peak_hour=6.0,
            ...     evening_peak_hour=22.0,
            ...     amplitude=0.15
            ... )

        Notes:
            - Double-peak pattern: morning and evening demand peaks
            - Short simulations (<6h): flat profile to avoid artificial peaks
            - PSS®E base loads: system MVA base
            - PyPSA base loads: aggregated by bus

        TODO:
            - Add weekly/seasonal variation patterns
        """

        if self.psse is None and self.pypsa is None:
            raise ValueError(
                "No power system modeler loaded. Use `engine.load(...)` first."
            )

            # --- Use PSSE or PyPSA Grid state to get base load ---
        if self.psse is not None:
            base_load = (
                self.psse.grid.load[["bus", "p"]]
                .drop_duplicates("bus")
                .set_index("bus")["p"]
            )
        elif self.pypsa is not None:
            base_load = (
                self.pypsa.grid.load[["bus", "p"]]
                .drop_duplicates("bus")
                .set_index("bus")["p"]
            )
        else:
            raise ValueError("No valid base load could be extracted from modelers.")

        snaps = pd.to_datetime(self.time.snapshots)
        prof = pd.DataFrame(index=snaps)

        # make sure this is a plain ndarray, not a Float64Index
        hours = (
            snaps.hour.values
            + snaps.minute.values / 60.0
            + snaps.second.values / 3600.0
        )

        dur_sec = 0 if len(snaps) < 2 else (snaps.max() - snaps.min()).total_seconds()

        if dur_sec < 6 * 3600:
            z = np.zeros_like(hours, dtype=float)
        else:

            def g(h, mu, sig):
                """Return Gaussian weights for given hours.

                Parameters
                ----------
                h : array-like
                    Hours at which the Gaussian is evaluated. Values are
                    cast to a NumPy array to ensure vectorized
                    operations.
                mu : float
                    Peak hour (mean) of the Gaussian curve.
                sig : float
                    Spread of the curve (standard deviation).

                Returns
                -------
                numpy.ndarray
                Array of Gaussian weights corresponding to ``h``.

                Notes
                -----
                Intended for shaping daily load profiles by combining
                morning and evening peaks.
                """
                h = np.asarray(h, dtype=float)  # <-- belt-and-suspenders
                return np.exp(-0.5 * ((h - mu) / sig) ** 2)

            s = g(hours, morning_peak_hour, morning_sigma_h) + g(
                hours, evening_peak_hour, evening_sigma_h
            )
            s = np.asarray(s, dtype=float)
            z = (s - s.mean()) / (
                s.std() + 1e-12
            )  # or: z = (s - np.mean(s)) / (np.std(s) + 1e-12)

        amp_overrides = (
            {}
            if amp_overrides is None
            else {int(k): float(v) for k, v in amp_overrides.items()}
        )

        for bus, p_base in base_load.items():
            if p_base <= 0:
                continue
            a = amp_overrides.get(int(bus), amplitude)  # per-bus amplitude
            shape_bus = 1.0 + a * z
            shape_bus = np.clip(shape_bus, min_multiplier, 2.0 - min_multiplier)
            prof[int(bus)] = p_base * shape_bus

        prof.index.name = "time"
        return prof

    def simulate(
        self, num_steps: Optional[int] = None, load_curve: bool = False
    ) -> None:
        """Execute time-series power system simulation across loaded backends.

        Args:
            num_steps (int | None): Number of simulation time steps. If ``None``,
                the simulation uses the full available data length, constrained by
                WEC time-series if present.
            load_curve (bool): Enable time-varying load profiles. Defaults to ``False``.
            strict_convergence (bool): Stop simulation on first convergence failure.
                Defaults to ``False`` (robust mode continues and reports failed steps).

        Raises:
            ValueError: If no power system modelers are loaded.

        Example:
            >>> engine.simulate(num_steps=144)
            >>> engine.simulate(load_curve=True)
            >>> engine.simulate(strict_convergence=True)  # Operational mode

        Notes:
            - All backends use identical time snapshots for comparison
            - WEC data length constrains maximum simulation length
            - Load curves use reduced amplitude (10%) for realism
            - Results accessible via ``engine.psse.grid`` and ``engine.pypsa.grid``
            - Strict mode provides traditional power system analysis behavior

        TODO:
            - Address multi-farm data length inconsistencies
            - Implement automatic plotting feature
        """

        # show that if different farms have different wec durations this logic fails
        if self.wec_farms:
            available_len = len(self.wec_farms[0].wec_devices[0].dataframe)

            if num_steps is not None:
                if num_steps > available_len:
                    print(
                        f"[WARNING] Requested num_steps={num_steps} exceeds "
                        f"WEC data length={available_len}. Truncating to {available_len}."
                    )
                final_len = min(num_steps, available_len)
            else:
                final_len = available_len

            if final_len != self.time.snapshots.shape[0]:
                self.time.update(num_steps=final_len)

        else:
            # No WEC farm — just update if num_steps is given
            if num_steps is not None:
                self.time.update(num_steps=num_steps)

        load_curve_df = (
            self.generate_load_curves(amplitude=0.10) if load_curve else None
        )

        for modeler in [self.psse, self.pypsa]:
            if modeler is not None:
                modeler.simulate(load_curve=load_curve_df)

apply_wec(farm_name, size=1, wec_sim_id=1, bus_location=1, connecting_bus=1, scaling_factor=1)

Add a Wave Energy Converter (WEC) farm to the power system.

Parameters:

Name Type Description Default
farm_name str

Human-readable WEC farm identifier.

required
size int

Number of WEC devices in farm. Defaults to 1.

1
wec_sim_id int

Database simulation ID for WEC data. Defaults to 1.

1
bus_location int

Grid bus for WEC connection. Defaults to 1.

1
connecting_bus int

Network topology connection bus. Defaults to 1.

1
scaling_factor int

Multiplier applied to WEC power output [unitless]. Defaults to 1.

1
Example

engine.apply_wec("North Coast Farm", size=20, bus_location=14) print(f"Total farms: {len(engine.wec_farms)}") Total farms: 1

Notes
  • Farm power scales linearly with device count
  • WEC data sourced from database using sim_id
  • Generator IDs are auto-assigned sequentially based on farm order
TODO
  • Fix PSS®E generator ID limitation (max 9 farms)
  • Default connecting_bus should be swing bus
Source code in src/wecgrid/engine.py
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def apply_wec(
    self,
    farm_name: str,
    size: int = 1,
    wec_sim_id: int = 1,
    bus_location: int = 1,
    connecting_bus: int = 1,  # todo this should default to swing bus
    scaling_factor: int = 1,  # used for scaling wec power output
) -> None:
    """Add a Wave Energy Converter (WEC) farm to the power system.

    Args:
        farm_name (str): Human-readable WEC farm identifier.
        size (int, optional): Number of WEC devices in farm. Defaults to 1.
        wec_sim_id (int, optional): Database simulation ID for WEC data. Defaults to 1.
        bus_location (int, optional): Grid bus for WEC connection. Defaults to 1.
        connecting_bus (int, optional): Network topology connection bus. Defaults to 1.
        scaling_factor (int, optional): Multiplier applied to WEC power output
            [unitless]. Defaults to 1.

    Example:
        >>> engine.apply_wec("North Coast Farm", size=20, bus_location=14)
        >>> print(f"Total farms: {len(engine.wec_farms)}")
        Total farms: 1

    Notes:
        - Farm power scales linearly with device count
        - WEC data sourced from database using sim_id
        - Generator IDs are auto-assigned sequentially based on farm order

    TODO:
        - Fix PSS®E generator ID limitation (max 9 farms)
        - Default connecting_bus should be swing bus
    """
    wec_farm: WECFarm = WECFarm(
        farm_name=farm_name,
        farm_id=len(self.wec_farms) + 1,  # Unique farm_id for each farm,
        gen_name="",
        database=self.database,
        time=self.time,
        wec_sim_id=wec_sim_id,
        bus_location=bus_location,
        connecting_bus=connecting_bus,
        size=size,
        sbase=self.sbase,
        scaling_factor=scaling_factor,
        # TODO potenital issue where PSSE is using gen_id as the gen identifer and that's limited to 2 chars. so hard cap at 9 farms in this code rn
    )
    self.wec_farms.append(wec_farm)

    for modeler in [self.psse, self.pypsa]:
        if modeler is not None:
            modeler.add_wec_farm(wec_farm)
            wec_farm.gen_name = (
                modeler.grid.gen.loc[
                    modeler.grid.gen.bus == wec_farm.bus_location, "gen_name"
                ].iloc[0]
                if (modeler.grid.gen.bus == wec_farm.bus_location).any()
                else None
            )
    print("WEC Farm added:", wec_farm.farm_name)

case(case_file)

Specify the power system case file for subsequent loading.

Parameters:

Name Type Description Default
case_file str

Path or identifier for a PSS®E RAW case file. Examples: - Full paths: "/path/to/system.RAW" - Bundled cases: "IEEE_30_bus" - With extension: "IEEE_39_bus.RAW"

required
Example

engine.case("IEEE_30_bus") print(engine.case_name) IEEE 30 bus

Notes

This method only stores the file path and a human-friendly name. It does not verify that the file exists or is loadable. Only PSS®E RAW (.RAW) format is supported.

Source code in src/wecgrid/engine.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def case(self, case_file: str):
    """Specify the power system case file for subsequent loading.

    Args:
        case_file (str): Path or identifier for a PSS®E RAW case file. Examples:
            - Full paths: ``"/path/to/system.RAW"``
            - Bundled cases: ``"IEEE_30_bus"``
            - With extension: ``"IEEE_39_bus.RAW"``

    Example:
        >>> engine.case("IEEE_30_bus")
        >>> print(engine.case_name)
        IEEE 30 bus

    Notes:
        This method only stores the file path and a human-friendly name.
        It does not verify that the file exists or is loadable.
        Only PSS®E RAW (.RAW) format is supported.
    """
    self.case_file = str(case_file)
    self.case_name = Path(case_file).stem.replace("_", " ").replace("-", " ")

generate_load_curves(morning_peak_hour=8.0, evening_peak_hour=18.0, morning_sigma_h=2.0, evening_sigma_h=3.0, amplitude=0.05, min_multiplier=0.5, amp_overrides=None)

Generate realistic time-varying load profiles for power system simulation.

Creates bus-specific load time series with double-peak daily pattern representing typical electrical demand. Scales base case loads with configurable peak timing and variability.

Parameters:

Name Type Description Default
morning_peak_hour float

Morning demand peak time [hours]. Defaults to 8.0.

8.0
evening_peak_hour float

Evening demand peak time [hours]. Defaults to 18.0.

18.0
morning_sigma_h float

Morning peak width [hours]. Defaults to 2.0.

2.0
evening_sigma_h float

Evening peak width [hours]. Defaults to 3.0.

3.0
amplitude float

Maximum variation around base load. Defaults to 0.30 (±30%).

0.05
min_multiplier float

Minimum load multiplier. Defaults to 0.70.

0.5
amp_overrides Dict[int, float]

Per-bus amplitude overrides.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Time-indexed load profiles [MW]. Index: simulation snapshots, Columns: bus numbers, Values: active power demand.

Raises:

Type Description
ValueError

If no power system modeler loaded.

Example

Generate standard load curves

profiles = engine.generate_load_curves() print(f"Buses: {list(profiles.columns)}")

Custom peaks for industrial area

custom = engine.generate_load_curves( ... morning_peak_hour=6.0, ... evening_peak_hour=22.0, ... amplitude=0.15 ... )

Notes
  • Double-peak pattern: morning and evening demand peaks
  • Short simulations (<6h): flat profile to avoid artificial peaks
  • PSS®E base loads: system MVA base
  • PyPSA base loads: aggregated by bus
TODO
  • Add weekly/seasonal variation patterns
Source code in src/wecgrid/engine.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
def generate_load_curves(
    self,
    morning_peak_hour: float = 8.0,
    evening_peak_hour: float = 18.0,
    morning_sigma_h: float = 2.0,
    evening_sigma_h: float = 3.0,
    amplitude: float = 0.05,  # ±30% swing around mean
    min_multiplier: float = 0.50,  # floor/ceiling clamp
    amp_overrides: Optional[Dict[int, float]] = None,
) -> pd.DataFrame:
    """Generate realistic time-varying load profiles for power system simulation.

    Creates bus-specific load time series with double-peak daily pattern
    representing typical electrical demand. Scales base case loads with
    configurable peak timing and variability.

    Args:
        morning_peak_hour (float, optional): Morning demand peak time [hours].
            Defaults to 8.0.
        evening_peak_hour (float, optional): Evening demand peak time [hours].
            Defaults to 18.0.
        morning_sigma_h (float, optional): Morning peak width [hours]. Defaults to 2.0.
        evening_sigma_h (float, optional): Evening peak width [hours]. Defaults to 3.0.
        amplitude (float, optional): Maximum variation around base load.
            Defaults to 0.30 (±30%).
        min_multiplier (float, optional): Minimum load multiplier. Defaults to 0.70.
        amp_overrides (Dict[int, float], optional): Per-bus amplitude overrides.

    Returns:
        pd.DataFrame: Time-indexed load profiles [MW]. Index: simulation snapshots,
            Columns: bus numbers, Values: active power demand.

    Raises:
        ValueError: If no power system modeler loaded.

    Example:
        >>> # Generate standard load curves
        >>> profiles = engine.generate_load_curves()
        >>> print(f"Buses: {list(profiles.columns)}")

        >>> # Custom peaks for industrial area
        >>> custom = engine.generate_load_curves(
        ...     morning_peak_hour=6.0,
        ...     evening_peak_hour=22.0,
        ...     amplitude=0.15
        ... )

    Notes:
        - Double-peak pattern: morning and evening demand peaks
        - Short simulations (<6h): flat profile to avoid artificial peaks
        - PSS®E base loads: system MVA base
        - PyPSA base loads: aggregated by bus

    TODO:
        - Add weekly/seasonal variation patterns
    """

    if self.psse is None and self.pypsa is None:
        raise ValueError(
            "No power system modeler loaded. Use `engine.load(...)` first."
        )

        # --- Use PSSE or PyPSA Grid state to get base load ---
    if self.psse is not None:
        base_load = (
            self.psse.grid.load[["bus", "p"]]
            .drop_duplicates("bus")
            .set_index("bus")["p"]
        )
    elif self.pypsa is not None:
        base_load = (
            self.pypsa.grid.load[["bus", "p"]]
            .drop_duplicates("bus")
            .set_index("bus")["p"]
        )
    else:
        raise ValueError("No valid base load could be extracted from modelers.")

    snaps = pd.to_datetime(self.time.snapshots)
    prof = pd.DataFrame(index=snaps)

    # make sure this is a plain ndarray, not a Float64Index
    hours = (
        snaps.hour.values
        + snaps.minute.values / 60.0
        + snaps.second.values / 3600.0
    )

    dur_sec = 0 if len(snaps) < 2 else (snaps.max() - snaps.min()).total_seconds()

    if dur_sec < 6 * 3600:
        z = np.zeros_like(hours, dtype=float)
    else:

        def g(h, mu, sig):
            """Return Gaussian weights for given hours.

            Parameters
            ----------
            h : array-like
                Hours at which the Gaussian is evaluated. Values are
                cast to a NumPy array to ensure vectorized
                operations.
            mu : float
                Peak hour (mean) of the Gaussian curve.
            sig : float
                Spread of the curve (standard deviation).

            Returns
            -------
            numpy.ndarray
            Array of Gaussian weights corresponding to ``h``.

            Notes
            -----
            Intended for shaping daily load profiles by combining
            morning and evening peaks.
            """
            h = np.asarray(h, dtype=float)  # <-- belt-and-suspenders
            return np.exp(-0.5 * ((h - mu) / sig) ** 2)

        s = g(hours, morning_peak_hour, morning_sigma_h) + g(
            hours, evening_peak_hour, evening_sigma_h
        )
        s = np.asarray(s, dtype=float)
        z = (s - s.mean()) / (
            s.std() + 1e-12
        )  # or: z = (s - np.mean(s)) / (np.std(s) + 1e-12)

    amp_overrides = (
        {}
        if amp_overrides is None
        else {int(k): float(v) for k, v in amp_overrides.items()}
    )

    for bus, p_base in base_load.items():
        if p_base <= 0:
            continue
        a = amp_overrides.get(int(bus), amplitude)  # per-bus amplitude
        shape_bus = 1.0 + a * z
        shape_bus = np.clip(shape_bus, min_multiplier, 2.0 - min_multiplier)
        prof[int(bus)] = p_base * shape_bus

    prof.index.name = "time"
    return prof

load(software)

Initialize power system simulation backends.

Parameters:

Name Type Description Default
software List[str]

Backends to initialize ("psse", "pypsa").

required

Raises:

Type Description
ValueError

If no case file loaded or invalid software name.

RuntimeError

If initialization fails (missing license, etc.).

Example

engine.case("IEEE_30_bus") engine.load(["psse", "pypsa"])

Notes
  • PSS®E requires commercial license; PyPSA is open-source
  • Enables cross-platform validation studies
  • Both backends are independent and can simulate separately
TODO
  • Add error handling for PSS®E license failures
Source code in src/wecgrid/engine.py
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
def load(self, software: List[str]) -> None:
    """Initialize power system simulation backends.

    Args:
        software (List[str]): Backends to initialize ("psse", "pypsa").

    Raises:
        ValueError: If no case file loaded or invalid software name.
        RuntimeError: If initialization fails (missing license, etc.).

    Example:
        >>> engine.case("IEEE_30_bus")
        >>> engine.load(["psse", "pypsa"])

    Notes:
        - PSS®E requires commercial license; PyPSA is open-source
        - Enables cross-platform validation studies
        - Both backends are independent and can simulate separately

    TODO:
        - Add error handling for PSS®E license failures
    """
    if self.case_file is None:
        raise ValueError(
            "No case file set. Use `engine.case('path/to/case.RAW')` first."
        )

    for name in software:
        name = name.lower()
        if name == "psse":
            self.psse = PSSEModeler(self)
            self.psse.init_api()
            self.sbase = self.psse.sbase
            # TODO: check if error is thrown if init fails
        elif name == "pypsa":
            self.pypsa = PyPSAModeler(self)
            self.pypsa.init_api()
            self.sbase = self.pypsa.sbase
            # if self.psse is not None:
            #     self.psse.adjust_reactive_lim()
            # TODO: check if error is thrown if init fails
        else:
            raise ValueError(
                f"Unsupported software: '{name}'. Use 'psse' or 'pypsa'."
            )

simulate(num_steps=None, load_curve=False)

Execute time-series power system simulation across loaded backends.

Parameters:

Name Type Description Default
num_steps int | None

Number of simulation time steps. If None, the simulation uses the full available data length, constrained by WEC time-series if present.

None
load_curve bool

Enable time-varying load profiles. Defaults to False.

False
strict_convergence bool

Stop simulation on first convergence failure. Defaults to False (robust mode continues and reports failed steps).

required

Raises:

Type Description
ValueError

If no power system modelers are loaded.

Example

engine.simulate(num_steps=144) engine.simulate(load_curve=True) engine.simulate(strict_convergence=True) # Operational mode

Notes
  • All backends use identical time snapshots for comparison
  • WEC data length constrains maximum simulation length
  • Load curves use reduced amplitude (10%) for realism
  • Results accessible via engine.psse.grid and engine.pypsa.grid
  • Strict mode provides traditional power system analysis behavior
TODO
  • Address multi-farm data length inconsistencies
  • Implement automatic plotting feature
Source code in src/wecgrid/engine.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
def simulate(
    self, num_steps: Optional[int] = None, load_curve: bool = False
) -> None:
    """Execute time-series power system simulation across loaded backends.

    Args:
        num_steps (int | None): Number of simulation time steps. If ``None``,
            the simulation uses the full available data length, constrained by
            WEC time-series if present.
        load_curve (bool): Enable time-varying load profiles. Defaults to ``False``.
        strict_convergence (bool): Stop simulation on first convergence failure.
            Defaults to ``False`` (robust mode continues and reports failed steps).

    Raises:
        ValueError: If no power system modelers are loaded.

    Example:
        >>> engine.simulate(num_steps=144)
        >>> engine.simulate(load_curve=True)
        >>> engine.simulate(strict_convergence=True)  # Operational mode

    Notes:
        - All backends use identical time snapshots for comparison
        - WEC data length constrains maximum simulation length
        - Load curves use reduced amplitude (10%) for realism
        - Results accessible via ``engine.psse.grid`` and ``engine.pypsa.grid``
        - Strict mode provides traditional power system analysis behavior

    TODO:
        - Address multi-farm data length inconsistencies
        - Implement automatic plotting feature
    """

    # show that if different farms have different wec durations this logic fails
    if self.wec_farms:
        available_len = len(self.wec_farms[0].wec_devices[0].dataframe)

        if num_steps is not None:
            if num_steps > available_len:
                print(
                    f"[WARNING] Requested num_steps={num_steps} exceeds "
                    f"WEC data length={available_len}. Truncating to {available_len}."
                )
            final_len = min(num_steps, available_len)
        else:
            final_len = available_len

        if final_len != self.time.snapshots.shape[0]:
            self.time.update(num_steps=final_len)

    else:
        # No WEC farm — just update if num_steps is given
        if num_steps is not None:
            self.time.update(num_steps=num_steps)

    load_curve_df = (
        self.generate_load_curves(amplitude=0.10) if load_curve else None
    )

    for modeler in [self.psse, self.pypsa]:
        if modeler is not None:
            modeler.simulate(load_curve=load_curve_df)

Database

SQLite database interface for WEC-Grid simulation data management.

Provides database operations for storing WEC simulation results, device configurations, and time series data. Supports both raw SQL queries and pandas DataFrame integration with multi-software backend support.

Database Schema Overview:

Metadata Tables: - grid_simulations: Grid simulation metadata and parameters - wec_simulations: WEC-Sim simulation parameters and wave conditions - wec_integrations: Links WEC farms to grid connection points

PSS®E Results Tables: - psse_bus_results: Bus voltages, power injections [pu on S_base] - psse_generator_results: Generator outputs [pu on S_base] - psse_load_results: Load demands [pu on S_base] - psse_line_results: Line loadings [% of thermal rating]

PyPSA Results Tables
  • pypsa_bus_results: Same schema as PSS®E for cross-platform comparison
  • pypsa_generator_results: Same schema as PSS®E
  • pypsa_load_results: Same schema as PSS®E
  • pypsa_line_results: Same schema as PSS®E
WEC Simulation Data
  • wec_simulations: Metadata including wave spectrum, class, and conditions
  • wec_power_results: High-resolution WEC device power output [Watts]
Key Design Features
  • Software-specific tables enable multi-backend comparisons
  • All grid power values in per-unit on system S_base (MVA)
  • GridState DataFrame schema alignment for direct data mapping
  • Optional storage model - persist only when explicitly requested
  • JSON configuration file for database path management
  • User-guided setup for first-time configuration
  • Support for downloaded or cloned database repositories
Database Location

Configured via database_config.json in the same directory as this module. Users can point to downloaded database file, cloned repository, or create new empty database.

Attributes:

Name Type Description
db_path str

Path to SQLite database file (from JSON configuration).

Example

db = WECGridDB(engine) # Uses path from database_config.json

First run will prompt user to configure database path

with db.connection() as conn: ... results = db.query("SELECT * FROM grid_simulations", return_type="df")

Notes

Database path is configured via JSON file on first use. Users are guided through setup process with clear instructions. All database operations are transaction-safe with automatic rollback on errors.

Source code in src/wecgrid/util/database.py
  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
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
class WECGridDB:
    """SQLite database interface for WEC-Grid simulation data management.

    Provides database operations for storing WEC simulation results, device
    configurations, and time series data. Supports both raw SQL queries and
    pandas DataFrame integration with multi-software backend support.

    Database Schema Overview:
    ------------------------
    Metadata Tables:
        - grid_simulations: Grid simulation metadata and parameters
        - wec_simulations: WEC-Sim simulation parameters and wave conditions
        - wec_integrations: Links WEC farms to grid connection points

    PSS®E Results Tables:
        - psse_bus_results: Bus voltages, power injections [pu on S_base]
        - psse_generator_results: Generator outputs [pu on S_base]
        - psse_load_results: Load demands [pu on S_base]
        - psse_line_results: Line loadings [% of thermal rating]

    PyPSA Results Tables:
        - pypsa_bus_results: Same schema as PSS®E for cross-platform comparison
        - pypsa_generator_results: Same schema as PSS®E
        - pypsa_load_results: Same schema as PSS®E
        - pypsa_line_results: Same schema as PSS®E

    WEC Simulation Data:
        - wec_simulations: Metadata including wave spectrum, class, and conditions
        - wec_power_results: High-resolution WEC device power output [Watts]

    Key Design Features:
        - Software-specific tables enable multi-backend comparisons
        - All grid power values in per-unit on system S_base (MVA)
        - GridState DataFrame schema alignment for direct data mapping
        - Optional storage model - persist only when explicitly requested
        - JSON configuration file for database path management
        - User-guided setup for first-time configuration
        - Support for downloaded or cloned database repositories

    Database Location:
        Configured via database_config.json in the same directory as this module.
        Users can point to downloaded database file, cloned repository, or create new empty database.

    Attributes:
        db_path (str): Path to SQLite database file (from JSON configuration).

    Example:
        >>> db = WECGridDB(engine)  # Uses path from database_config.json
        >>> # First run will prompt user to configure database path
        >>> with db.connection() as conn:
        ...     results = db.query("SELECT * FROM grid_simulations", return_type="df")

    Notes:
        Database path is configured via JSON file on first use.
        Users are guided through setup process with clear instructions.
        All database operations are transaction-safe with automatic rollback on errors.
    """

    def __init__(self, engine):
        """Initialize database handler.

        Args:
            engine: WEC-GRID engine instance
        """
        self.engine = engine

        # Get database path from config
        self.db_path = get_database_config()
        if self.db_path is None:
            _show_database_setup_message()
            print(
                "Warning: Database not configured. Use engine.database.set_database_path() to configure."
            )
            return  # Allow user to continue and set path later

        # print(f"Using database: {self.db_path}")
        self.check_and_initialize()

    def check_and_initialize(self):
        """Check if database exists and has correct schema, initialize if needed.

        Validates that all required tables exist with proper structure.
        Creates database and initializes schema if missing or incomplete.

        Returns:
            bool: True if database was already valid, False if initialization was needed.
        """
        if self.db_path is None:
            print("Warning: Database path not set. Cannot initialize database.")
            return False

        if not os.path.exists(self.db_path):
            # Ensure directory exists
            os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
            self.initialize_database()
            return False

        # Check if all required tables exist
        required_tables = [
            "grid_simulations",
            "wec_simulations",
            "wec_integrations",
            "psse_bus_results",
            "psse_generator_results",
            "psse_load_results",
            "psse_line_results",
            "pypsa_bus_results",
            "pypsa_generator_results",
            "pypsa_load_results",
            "pypsa_line_results",
            "wec_power_results",
        ]

        with self.connection() as conn:
            cursor = conn.cursor()
            cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
            existing_tables = {row[0] for row in cursor.fetchall()}

            missing_tables = set(required_tables) - existing_tables
            if missing_tables:
                self.initialize_database()
                return False

        # Check for missing columns in existing tables and migrate
        self._migrate_schema()

        return True

    def _migrate_schema(self):
        """Migrate database schema to add missing columns."""
        with self.connection() as conn:
            cursor = conn.cursor()

            # Check if wave_spectrum and wave_class columns exist in wec_simulations
            cursor.execute("PRAGMA table_info(wec_simulations)")
            columns = [row[1] for row in cursor.fetchall()]

            migrations_applied = False

            if "wave_spectrum" not in columns:
                cursor.execute(
                    "ALTER TABLE wec_simulations ADD COLUMN wave_spectrum TEXT"
                )
                migrations_applied = True

            if "wave_class" not in columns:
                cursor.execute("ALTER TABLE wec_simulations ADD COLUMN wave_class TEXT")
                migrations_applied = True

            if migrations_applied:
                conn.commit()

    @contextmanager
    def connection(self):
        """Context manager for safe database connections.

        Provides transaction safety with automatic commit on success and
        rollback on exceptions. Connection closed automatically.

        """
        if self.db_path is None:
            raise ValueError(
                "Database path not configured. Please use engine.database.set_database_path() to configure."
            )

        conn = sqlite3.connect(self.db_path)
        try:
            yield conn
            conn.commit()
        except:
            conn.rollback()
            raise
        finally:
            conn.close()

    def initialize_database(self, db_path: Optional[str] = None):
        """Initialize database schema with WEC-Grid tables and indexes.

        Args:
            db_path (str, optional): Path where database should be created.
                If provided, creates new database at this location and updates
                the current instance to use it. If None, uses existing database path.

        Creates all required tables according to the finalized WEC-Grid schema:
        - Metadata tables for simulation parameters
        - Software-specific result tables (PSS®E, PyPSA)
        - WEC time-series data tables
        - Performance indexes for efficient queries

        All existing data is preserved if tables already exist.

        Example:
            >>> # Create new database when none is configured
            >>> engine.database.initialize_database("/path/to/new_database.db")

            >>> # Initialize schema on existing configured database
            >>> engine.database.initialize_database()
        """
        if db_path:
            # Convert to absolute path
            db_path = str(Path(db_path).absolute())

            # Ensure directory exists
            os.makedirs(os.path.dirname(db_path), exist_ok=True)

            # Update the instance to use this new database path
            save_database_config(db_path)
            self.db_path = db_path

            # Create the database file if it doesn't exist
            if not os.path.exists(db_path):
                # Touch the file to create it
                conn = sqlite3.connect(db_path)
                conn.close()

        # Verify we have a database path to work with
        if self.db_path is None:
            raise ValueError(
                "No database path configured. Please provide db_path parameter or "
                "use engine.database.set_database_path() to configure a database path first."
            )

        with self.connection() as conn:
            cursor = conn.cursor()

            # ================================================================
            # METADATA TABLES
            # ================================================================

            # Grid simulation metadata
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS grid_simulations (
                    grid_sim_id INTEGER PRIMARY KEY AUTOINCREMENT,
                    sim_name TEXT,
                    case_name TEXT NOT NULL,
                    psse BOOLEAN DEFAULT FALSE,
                    pypsa BOOLEAN DEFAULT FALSE,
                    sbase_mva REAL NOT NULL,
                    sim_start_time TEXT NOT NULL,
                    sim_end_time TEXT,
                    delta_time INTEGER,
                    notes TEXT,
                    created_at TEXT DEFAULT CURRENT_TIMESTAMP
                )
            """
            )

            # WEC simulation parameters
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS wec_simulations (
                    wec_sim_id INTEGER PRIMARY KEY AUTOINCREMENT,
                    model_type TEXT NOT NULL,
                    sim_duration_sec REAL NOT NULL,
                    delta_time REAL NOT NULL,
                    wave_height_m REAL,
                    wave_period_sec REAL,
                    wave_spectrum TEXT,
                    wave_class TEXT,
                    wave_seed INTEGER,
                    simulation_hash TEXT,
                    created_at TEXT DEFAULT CURRENT_TIMESTAMP
                )
            """
            )

            # WEC-Grid integration mapping
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS wec_integrations (
                    integration_id INTEGER PRIMARY KEY AUTOINCREMENT,
                    grid_sim_id INTEGER NOT NULL,
                    wec_sim_id INTEGER NOT NULL,
                    farm_name TEXT NOT NULL,
                    bus_location INTEGER NOT NULL,
                    num_devices INTEGER NOT NULL,
                    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE,
                    FOREIGN KEY (wec_sim_id) REFERENCES wec_simulations(wec_sim_id) ON DELETE CASCADE
                )
            """
            )

            # ================================================================
            # PSS®E-SPECIFIC TABLES (GridState Schema Alignment)
            # ================================================================

            # PSS®E Bus Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS psse_bus_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    bus INTEGER NOT NULL,
                    bus_name TEXT,
                    type TEXT,
                    p REAL,
                    q REAL,
                    v_mag REAL,
                    angle_deg REAL,
                    vbase REAL,
                    PRIMARY KEY (grid_sim_id, timestamp, bus),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PSS®E Generator Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS psse_generator_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    gen INTEGER NOT NULL,
                    gen_name TEXT,
                    bus INTEGER NOT NULL,
                    p REAL,
                    q REAL,
                    mbase REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, gen),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PSS®E Load Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS psse_load_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    load INTEGER NOT NULL,
                    load_name TEXT,
                    bus INTEGER NOT NULL,
                    p REAL,
                    q REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, load),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PSS®E Line Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS psse_line_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    line INTEGER NOT NULL,
                    line_name TEXT,
                    ibus INTEGER NOT NULL,
                    jbus INTEGER NOT NULL,
                    line_pct REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, line),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # ================================================================
            # PyPSA-SPECIFIC TABLES (Identical to PSS®E for Cross-Platform Comparison)
            # ================================================================

            # PyPSA Bus Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS pypsa_bus_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    bus INTEGER NOT NULL,
                    bus_name TEXT,
                    type TEXT,
                    p REAL,
                    q REAL,
                    v_mag REAL,
                    angle_deg REAL,
                    vbase REAL,
                    PRIMARY KEY (grid_sim_id, timestamp, bus),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PyPSA Generator Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS pypsa_generator_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    gen INTEGER NOT NULL,
                    gen_name TEXT,
                    bus INTEGER NOT NULL,
                    p REAL,
                    q REAL,
                    mbase REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, gen),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PyPSA Load Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS pypsa_load_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    load INTEGER NOT NULL,
                    load_name TEXT,
                    bus INTEGER NOT NULL,
                    p REAL,
                    q REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, load),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # PyPSA Line Results
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS pypsa_line_results (
                    grid_sim_id INTEGER NOT NULL,
                    timestamp TEXT NOT NULL,
                    line INTEGER NOT NULL,
                    line_name TEXT,
                    ibus INTEGER NOT NULL,
                    jbus INTEGER NOT NULL,
                    line_pct REAL,
                    status INTEGER,
                    PRIMARY KEY (grid_sim_id, timestamp, line),
                    FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
                )
            """
            )

            # ================================================================
            # WEC TIME-SERIES DATA
            # ================================================================

            # WEC Power Results (High-Resolution Time Series)
            cursor.execute(
                """
                CREATE TABLE IF NOT EXISTS wec_power_results (
                    wec_sim_id INTEGER NOT NULL,
                    time_sec REAL NOT NULL,
                    device_index INTEGER NOT NULL,
                    p_w REAL,
                    q_var REAL,
                    wave_elevation_m REAL,
                    PRIMARY KEY (wec_sim_id, time_sec, device_index),
                    FOREIGN KEY (wec_sim_id) REFERENCES wec_simulations(wec_sim_id) ON DELETE CASCADE
                )
            """
            )

            # ================================================================
            # PERFORMANCE INDEXES
            # ================================================================

            # Grid simulation indexes
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_grid_sim_time ON grid_simulations(sim_start_time)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_grid_sim_case ON grid_simulations(case_name)"
            )

            # PSS®E result indexes
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_psse_bus_time ON psse_bus_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_psse_gen_time ON psse_generator_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_psse_load_time ON psse_load_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_psse_line_time ON psse_line_results(grid_sim_id, timestamp)"
            )

            # PyPSA result indexes
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_pypsa_bus_time ON pypsa_bus_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_pypsa_gen_time ON pypsa_generator_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_pypsa_load_time ON pypsa_load_results(grid_sim_id, timestamp)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_pypsa_line_time ON pypsa_line_results(grid_sim_id, timestamp)"
            )

            # WEC time-series indexes
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_wec_power_time ON wec_power_results(wec_sim_id, time_sec)"
            )
            cursor.execute(
                "CREATE INDEX IF NOT EXISTS idx_wec_integration ON wec_integrations(grid_sim_id, wec_sim_id)"
            )

    def clean_database(self):
        """Delete the current database and reinitialize with fresh schema.

        WARNING: This will permanently delete all stored simulation data.
        Use with caution - all existing data will be lost.

        Returns:
            bool: True if database was successfully cleaned and reinitialized.

        Notes:
            Wasn't working if my Jupyter Kernal was still going, need to restart then call
        Example:
            >>> engine.database.clean_database()
            WARNING: This will delete all data in the database!
            Database cleaned and reinitialized successfully.
        """
        print("WARNING: This will delete all data in the database!")

        # Close any existing connections by creating a temporary one and closing it
        try:
            conn = sqlite3.connect(self.db_path)
            conn.close()
        except:
            pass

        # Delete the database file if it exists
        if os.path.exists(self.db_path):
            try:
                os.remove(self.db_path)
            except OSError as e:
                print(f"Error deleting database file: {e}")
                return False

        # Reinitialize with fresh schema
        try:
            self.initialize_database()
            return True
        except Exception as e:
            print(f"Error reinitializing database: {e}")
            return False

    def query(self, sql: str, params: tuple = None, return_type: str = "raw"):
        """Execute SQL query with flexible result formatting.

        Args:
            sql (str): SQL query string.
            params (tuple, optional): Query parameters for safe substitution.
            return_type (str): Format for results - 'raw', 'df', or 'dict'.

        Returns:
            Results in specified format:
            - 'raw': List of tuples (default SQLite format)
            - 'df': pandas DataFrame with column names
            - 'dict': List of dictionaries with column names as keys

        Example:
            >>> db.query("SELECT * FROM grid_simulations WHERE case_name = ?",
            ...           params=("IEEE_14_bus",), return_type="df")
        """
        with self.connection() as conn:
            cursor = conn.cursor()
            cursor.execute(sql, params or ())
            result = cursor.fetchall()

            if return_type == "df":
                columns = [desc[0] for desc in cursor.description]
                return pd.DataFrame(result, columns=columns)
            elif return_type == "dict":
                columns = [desc[0] for desc in cursor.description]
                return [dict(zip(columns, row)) for row in result]
            elif return_type == "raw":
                return result
            else:
                raise ValueError(
                    f"Invalid return_type '{return_type}'. Must be 'raw', 'df', or 'dict'."
                )

    def save_sim(self, sim_name: str, notes: str = None) -> int:
        """Save simulation data for all available software backends in the engine.

        Automatically detects and stores data from all active software backends
        (PSS®E, PyPSA) and WEC farms present in the engine object.

        Always creates a new simulation entry - no duplicate checking.
        Users can manage simulation names as needed.

        Args:
            sim_name (str): User-friendly simulation name.
            notes (str, optional): Simulation notes.

        Returns:
            int: grid_sim_id of the created simulation.

        Example:
            >>> sim_id = engine.database.save_sim(
            ...     sim_name="IEEE 30 test",
            ...     notes="testing the database"
            ... )
        """
        # Gather all available software objects from engine
        softwares = []

        # Check for PSS®E
        if hasattr(self.engine, "psse") and hasattr(self.engine.psse, "grid"):
            softwares.append(self.engine.psse.grid)

        # Check for PyPSA
        if hasattr(self.engine, "pypsa") and hasattr(self.engine.pypsa, "grid"):
            softwares.append(self.engine.pypsa.grid)

        if not softwares:
            raise ValueError(
                "No software backends found in engine. Ensure PSS®E or PyPSA models are loaded."
            )

        # Get case name from engine
        case_name = getattr(self.engine, "case_name", "Unknown_Case")

        # Get time manager from engine
        timeManager = getattr(self.engine, "time", None)
        if timeManager is None:
            raise ValueError(
                "No time manager found in engine. Ensure engine.time is properly initialized."
            )

        # Extract software flags and determine sbase
        psse_used = False
        pypsa_used = False
        sbase_mva = None

        for i, software_obj in enumerate(softwares):
            software_name = getattr(software_obj, "software", "")

            software_name = software_name.lower() if software_name else ""

            if software_name == "psse":
                psse_used = True
            elif software_name == "pypsa":
                pypsa_used = True
            else:
                continue  # Skip this software object instead of processing it

            # Get sbase from the first software object
            if sbase_mva is None:
                if hasattr(software_obj, "sbase"):
                    sbase_mva = software_obj.sbase
                else:
                    # Try to get from parent object
                    parent = getattr(software_obj, "_parent", None)
                    if parent and hasattr(parent, "sbase"):
                        sbase_mva = parent.sbase
                    else:
                        sbase_mva = 100.0  # Default fallback

        # Get time information from simulation
        sim_start_time = timeManager.start_time.isoformat()
        sim_end_time = getattr(timeManager, "sim_stop", None)
        if sim_end_time:
            sim_end_time = sim_end_time.isoformat()
        delta_time = timeManager.delta_time

        # Create new grid simulation record (always create new entry)
        with self.connection() as conn:
            cursor = conn.cursor()

            # Insert new simulation - always create new entry
            cursor.execute(
                """
                INSERT INTO grid_simulations 
                (sim_name, case_name, psse, pypsa, sbase_mva, sim_start_time, 
                 sim_end_time, delta_time, notes)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
                (
                    sim_name,
                    case_name,
                    psse_used,
                    pypsa_used,
                    sbase_mva,
                    sim_start_time,
                    sim_end_time,
                    delta_time,
                    notes,
                ),
            )

            grid_sim_id = cursor.lastrowid

        # Store data for each valid software
        valid_softwares = []
        for software_obj in softwares:
            software_name = getattr(software_obj, "software", "").lower()

            # Only process valid software names
            if software_name in ["psse", "pypsa"]:
                valid_softwares.append((software_obj, software_name))

        for software_obj, software_name in valid_softwares:
            # Store all time-series data from GridState
            self._store_all_gridstate_timeseries(
                grid_sim_id, software_obj, software_name, timeManager
            )

        # Store WEC farm data if available
        if hasattr(self.engine, "wec_farms") and self.engine.wec_farms:
            self._store_wec_farm_data(grid_sim_id)

        # Create summary of used software
        used_software = []
        if psse_used:
            used_software.append("PSS®E")
        if pypsa_used:
            used_software.append("PyPSA")

        print(f"Simulation saved: ID {grid_sim_id} - {sim_name}")

        return grid_sim_id

    def _store_all_gridstate_timeseries(
        self, grid_sim_id: int, grid_state_obj, software: str, timeManager
    ):
        """Store all time-series data from GridState object.

        Args:
            grid_sim_id (int): Grid simulation ID.
            grid_state_obj: GridState object with time-series data.
            software (str): Software name ("psse" or "pypsa").
            timeManager: WECGridTime object.
        """
        # Validate software name
        if software not in ["psse", "pypsa"]:
            raise ValueError(
                f"Invalid software name: '{software}'. Must be 'psse' or 'pypsa'."
            )

        table_prefix = f"{software}_"
        snapshots = timeManager.snapshots

        with self.connection() as conn:
            cursor = conn.cursor()

            # Store bus time-series data
            if hasattr(grid_state_obj, "bus_t") and grid_state_obj.bus_t:
                for timestamp in snapshots:
                    timestamp_str = timestamp.isoformat()

                    # Create bus data for this timestamp
                    if hasattr(grid_state_obj, "bus") and not grid_state_obj.bus.empty:
                        for idx, row in grid_state_obj.bus.iterrows():
                            cursor.execute(
                                f"""
                                INSERT OR REPLACE INTO {table_prefix}bus_results 
                                (grid_sim_id, timestamp, bus, bus_name, type, p, q, v_mag, angle_deg, vbase)
                                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                            """,
                                (
                                    grid_sim_id,
                                    timestamp_str,
                                    row.get("bus"),
                                    row.get("bus_name"),
                                    row.get("type"),
                                    self._get_timeseries_value(
                                        grid_state_obj.bus_t,
                                        "p",
                                        row.get("bus"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.bus_t,
                                        "q",
                                        row.get("bus"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.bus_t,
                                        "v_mag",
                                        row.get("bus"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.bus_t,
                                        "angle_deg",
                                        row.get("bus"),
                                        timestamp,
                                    ),
                                    row.get("vbase"),
                                ),
                            )

            # Store generator time-series data
            if hasattr(grid_state_obj, "gen_t") and grid_state_obj.gen_t:
                for timestamp in snapshots:
                    timestamp_str = timestamp.isoformat()

                    if hasattr(grid_state_obj, "gen") and not grid_state_obj.gen.empty:
                        for idx, row in grid_state_obj.gen.iterrows():
                            cursor.execute(
                                f"""
                                INSERT OR REPLACE INTO {table_prefix}generator_results 
                                (grid_sim_id, timestamp, gen, gen_name, bus, p, q, mbase, status)
                                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                            """,
                                (
                                    grid_sim_id,
                                    timestamp_str,
                                    row.get("gen"),
                                    row.get("gen_name"),
                                    row.get("bus"),
                                    self._get_timeseries_value(
                                        grid_state_obj.gen_t,
                                        "p",
                                        row.get("gen"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.gen_t,
                                        "q",
                                        row.get("gen"),
                                        timestamp,
                                    ),
                                    row.get("Mbase"),
                                    self._get_timeseries_value(
                                        grid_state_obj.gen_t,
                                        "status",
                                        row.get("gen"),
                                        timestamp,
                                    ),
                                ),
                            )

            # Store load time-series data
            if hasattr(grid_state_obj, "load_t") and grid_state_obj.load_t:
                for timestamp in snapshots:
                    timestamp_str = timestamp.isoformat()

                    if (
                        hasattr(grid_state_obj, "load")
                        and not grid_state_obj.load.empty
                    ):
                        for idx, row in grid_state_obj.load.iterrows():
                            cursor.execute(
                                f"""
                                INSERT OR REPLACE INTO {table_prefix}load_results 
                                (grid_sim_id, timestamp, load, load_name, bus, p, q, status)
                                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                            """,
                                (
                                    grid_sim_id,
                                    timestamp_str,
                                    row.get("load"),
                                    row.get("load_name"),
                                    row.get("bus"),
                                    self._get_timeseries_value(
                                        grid_state_obj.load_t,
                                        "p",
                                        row.get("load"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.load_t,
                                        "q",
                                        row.get("load"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.load_t,
                                        "status",
                                        row.get("load"),
                                        timestamp,
                                    ),
                                ),
                            )

            # Store line time-series data
            if hasattr(grid_state_obj, "line_t") and grid_state_obj.line_t:
                for timestamp in snapshots:
                    timestamp_str = timestamp.isoformat()

                    if (
                        hasattr(grid_state_obj, "line")
                        and not grid_state_obj.line.empty
                    ):
                        for idx, row in grid_state_obj.line.iterrows():
                            cursor.execute(
                                f"""
                                INSERT OR REPLACE INTO {table_prefix}line_results 
                                (grid_sim_id, timestamp, line, line_name, ibus, jbus, line_pct, status)
                                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                            """,
                                (
                                    grid_sim_id,
                                    timestamp_str,
                                    row.get("line"),
                                    row.get("line_name"),
                                    row.get("ibus"),
                                    row.get("jbus"),
                                    self._get_timeseries_value(
                                        grid_state_obj.line_t,
                                        "line_pct",
                                        row.get("line"),
                                        timestamp,
                                    ),
                                    self._get_timeseries_value(
                                        grid_state_obj.line_t,
                                        "status",
                                        row.get("line"),
                                        timestamp,
                                    ),
                                ),
                            )

    def _store_wec_farm_data(self, grid_sim_id: int):
        """Store WEC farm data if available in the engine.

        Args:
            grid_sim_id (int): Grid simulation ID to link WEC data to.
        """
        with self.connection() as conn:
            cursor = conn.cursor()

            for farm in self.engine.wec_farms:
                # Store wec_integrations record linking farm to grid simulation
                cursor.execute(
                    """
                    INSERT OR REPLACE INTO wec_integrations 
                    (grid_sim_id, wec_sim_id, farm_name, bus_location, num_devices)
                    VALUES (?, ?, ?, ?, ?)
                """,
                    (
                        grid_sim_id,
                        farm.wec_sim_id,
                        farm.farm_name,
                        farm.bus_location,
                        farm.size,
                    ),
                )

    def _get_timeseries_value(
        self, timeseries_dict, parameter: str, component_id: int, timestamp
    ):
        """Extract time-series value for specific component and timestamp.

        Args:
            timeseries_dict: AttrDict containing time-series DataFrames.
            parameter (str): Parameter name (e.g., 'p', 'q', 'v_mag').
            component_id (int): Component ID.
            timestamp: Timestamp to extract.

        Returns:
            Value at the specified timestamp or None if not available.
        """
        try:
            if parameter in timeseries_dict:
                df = timeseries_dict[parameter]

                # GridState stores time-series with component names as columns, not IDs
                # Try both component_id directly and component name patterns
                possible_columns = [
                    component_id,  # Try direct ID first (fallback case)
                    str(component_id),  # String version of ID
                    f"Bus_{component_id}",  # Bus name pattern
                    f"Gen_{component_id}",  # Generator name pattern
                    f"Line_{component_id}",  # Line name pattern
                    f"Load_{component_id}",  # Load name pattern
                ]

                for col in possible_columns:
                    if col in df.columns and timestamp in df.index:
                        value = df.loc[timestamp, col]
                        # Debug: Print if value is None/NaN for slack bus
                        # if component_id == 1 and parameter in ['p', 'q', 'v_mag', 'angle_deg'] and (pd.isna(value) or value is None):
                        #     print(f"DEBUG: Slack bus {component_id} {parameter} = {value} (column: {col})")
                        #     print(f"Available columns: {list(df.columns)}")
                        #     print(f"Available timestamps: {list(df.index)}")
                        return value

        except (KeyError, AttributeError) as e:
            # Debug: Print error for slack bus
            if component_id == 1:
                print(f"DEBUG: Error getting slack bus {component_id} {parameter}: {e}")
            pass
        return None

    def store_gridstate_data(
        self, grid_sim_id: int, timestamp: str, grid_state, software: str
    ):
        """Store GridState data to appropriate software-specific tables.

        Args:
            grid_sim_id (int): Grid simulation ID.
            timestamp (str): ISO datetime string for this snapshot.
            grid_state: GridState object with bus, gen, load, line DataFrames.
            software (str): Software backend - "PSSE" or "PyPSA".

        Example:
            >>> db.store_gridstate_data(
            ...     grid_sim_id=123,
            ...     timestamp="2025-08-14T10:05:00",
            ...     grid_state=my_grid_state,
            ...     software="PSSE"
            ... )
        """
        software = software.lower()
        table_prefix = f"{software}_"

        with self.connection() as conn:
            cursor = conn.cursor()

            # Store bus results
            if not grid_state.bus.empty:
                for bus_id, row in grid_state.bus.iterrows():
                    cursor.execute(
                        f"""
                        INSERT OR REPLACE INTO {table_prefix}bus_results 
                        (grid_sim_id, timestamp, bus, bus_name, type, p, q, v_mag, angle_deg, vbase)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                        (
                            grid_sim_id,
                            timestamp,
                            bus_id,
                            row.get("bus_name"),
                            row.get("type"),
                            row.get("p"),
                            row.get("q"),
                            row.get("v_mag"),
                            row.get("angle_deg"),
                            row.get("vbase"),
                        ),
                    )

            # Store generator results
            if not grid_state.gen.empty:
                for gen_id, row in grid_state.gen.iterrows():
                    cursor.execute(
                        f"""
                        INSERT OR REPLACE INTO {table_prefix}generator_results 
                        (grid_sim_id, timestamp, gen, gen_name, bus, p, q, mbase, status)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                        (
                            grid_sim_id,
                            timestamp,
                            gen_id,
                            row.get("gen_name"),
                            row.get("bus"),
                            row.get("p"),
                            row.get("q"),
                            row.get("Mbase"),
                            row.get("status"),
                        ),
                    )

            # Store load results
            if not grid_state.load.empty:
                for load_id, row in grid_state.load.iterrows():
                    cursor.execute(
                        f"""
                        INSERT OR REPLACE INTO {table_prefix}load_results 
                        (grid_sim_id, timestamp, load, load_name, bus, p, q, status)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                        (
                            grid_sim_id,
                            timestamp,
                            load_id,
                            row.get("load_name"),
                            row.get("bus"),
                            row.get("p"),
                            row.get("q"),
                            row.get("status"),
                        ),
                    )

            # Store line results
            if not grid_state.line.empty:
                for line_id, row in grid_state.line.iterrows():
                    cursor.execute(
                        f"""
                        INSERT OR REPLACE INTO {table_prefix}line_results 
                        (grid_sim_id, timestamp, line, line_name, ibus, jbus, line_pct, status)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                        (
                            grid_sim_id,
                            timestamp,
                            line_id,
                            row.get("line_name"),
                            row.get("ibus"),
                            row.get("jbus"),
                            row.get("line_pct"),
                            row.get("status"),
                        ),
                    )

    def get_simulation_info(self, grid_sim_id: int = None) -> pd.DataFrame:
        """Get grid simulation information.

        Args:
            grid_sim_id (int, optional): Specific simulation ID. If None, returns all.

        Returns:
            pd.DataFrame: Simulation metadata.
        """
        if grid_sim_id:
            return self.query(
                "SELECT * FROM grid_simulations WHERE grid_sim_id = ?",
                params=(grid_sim_id,),
                return_type="df",
            )
        else:
            return self.query(
                "SELECT * FROM grid_simulations ORDER BY created_at DESC",
                return_type="df",
            )

    def grid_sims(self) -> pd.DataFrame:
        """Get all grid simulation metadata in a user-friendly format.

        Returns:
            pd.DataFrame: Grid simulations with key metadata columns.

        Example:
            >>> engine.database.grid_sims()
               grid_sim_id     sim_name      case_name  psse  pypsa  sbase_mva  ...
            0           1     Test Run   IEEE_14_bus  True  False      100.0  ...
        """
        return self.query(
            """
            SELECT grid_sim_id, sim_name, case_name, psse, pypsa, sbase_mva,
                   sim_start_time, sim_end_time, delta_time, notes, created_at
            FROM grid_simulations 
            ORDER BY created_at DESC
        """,
            return_type="df",
        )

    def wecsim_runs(self) -> pd.DataFrame:
        """Get all WEC simulation metadata with enhanced wave parameters.

        Returns:
            pd.DataFrame: WEC simulations with parameters and wave conditions including
                wave spectrum type, wave class, and all simulation parameters.

        Example:
            >>> engine.database.wecsim_runs()
               wec_sim_id model_type  sim_duration_sec  delta_time  wave_spectrum  wave_class  ...
            0          1       RM3             600.0        0.1             PM   irregular  ...
        """
        return self.query(
            """
            SELECT wec_sim_id, model_type, sim_duration_sec, delta_time,
                   wave_height_m, wave_period_sec, wave_spectrum, wave_class, wave_seed,
                   simulation_hash, created_at
            FROM wec_simulations 
            ORDER BY created_at DESC
        """,
            return_type="df",
        )

    def pull_sim(self, grid_sim_id: int, software: str = None):
        """Pull simulation data from database and reconstruct GridState object.

        Retrieves all time-series data for a specific simulation and recreates
        the GridState object with both snapshot data and time-series history.

        Args:
            grid_sim_id (int): Grid simulation ID to retrieve.
            software (str, optional): Software backend to pull data for ("psse" or "pypsa").
                If None, automatically detects which software was used based on
                grid_simulations table flags.

        Returns:
            GridState: Reconstructed GridState object with time-series data.

        Raises:
            ValueError: If grid_sim_id not found or software not available for this simulation.

        Example:
            >>> # Pull PSS®E simulation data
            >>> grid_state = engine.database.pull_sim(grid_sim_id=123, software="psse")
            >>> print(f"Buses: {len(grid_state.bus)}")
            >>> print(f"Time series data: {list(grid_state.bus_t.keys())}")

            >>> # Auto-detect software and pull data
            >>> grid_state = engine.database.pull_sim(grid_sim_id=123)
        """
        # Import here to avoid circular import
        from ..modelers.power_system.base import GridState, AttrDict

        # Get simulation metadata
        sim_info = self.query(
            "SELECT * FROM grid_simulations WHERE grid_sim_id = ?",
            params=(grid_sim_id,),
            return_type="df",
        )

        if sim_info.empty:
            raise ValueError(f"Simulation with ID {grid_sim_id} not found in database")

        sim_row = sim_info.iloc[0]

        # Auto-detect software if not specified
        if software is None:
            if sim_row["psse"]:
                software = "psse"
            elif sim_row["pypsa"]:
                software = "pypsa"
            else:
                raise ValueError(
                    f"No software backend data found for simulation {grid_sim_id}"
                )

        # Validate software choice
        if software not in ["psse", "pypsa"]:
            raise ValueError(
                f"Invalid software: '{software}'. Must be 'psse' or 'pypsa'."
            )

        if software == "psse" and not sim_row["psse"]:
            raise ValueError(f"PSS®E data not available for simulation {grid_sim_id}")
        if software == "pypsa" and not sim_row["pypsa"]:
            raise ValueError(f"PyPSA data not available for simulation {grid_sim_id}")

        # Create GridState object
        grid_state = GridState(software=software)

        # Set case name from database metadata
        grid_state.case = sim_row["case_name"]

        # Table prefix for this software
        table_prefix = f"{software}_"
        table_prefix = f"{software}_"

        # Pull bus data
        bus_data = self.query(
            f"""
            SELECT * FROM {table_prefix}bus_results 
            WHERE grid_sim_id = ? 
            ORDER BY timestamp, bus
        """,
            params=(grid_sim_id,),
            return_type="df",
        )

        # Pull generator data
        gen_data = self.query(
            f"""
            SELECT * FROM {table_prefix}generator_results 
            WHERE grid_sim_id = ? 
            ORDER BY timestamp, gen
        """,
            params=(grid_sim_id,),
            return_type="df",
        )

        # Pull load data
        load_data = self.query(
            f"""
            SELECT * FROM {table_prefix}load_results 
            WHERE grid_sim_id = ? 
            ORDER BY timestamp, load
        """,
            params=(grid_sim_id,),
            return_type="df",
        )

        # Pull line data
        line_data = self.query(
            f"""
            SELECT * FROM {table_prefix}line_results 
            WHERE grid_sim_id = ? 
            ORDER BY timestamp, line
        """,
            params=(grid_sim_id,),
            return_type="df",
        )

        # Convert timestamp strings to pandas timestamps
        if not bus_data.empty:
            bus_data["timestamp"] = pd.to_datetime(bus_data["timestamp"])
        if not gen_data.empty:
            gen_data["timestamp"] = pd.to_datetime(gen_data["timestamp"])
        if not load_data.empty:
            load_data["timestamp"] = pd.to_datetime(load_data["timestamp"])
        if not line_data.empty:
            line_data["timestamp"] = pd.to_datetime(line_data["timestamp"])

        # Reconstruct current snapshot data (use latest timestamp)
        if not bus_data.empty:
            latest_time = bus_data["timestamp"].max()
            latest_bus = bus_data[bus_data["timestamp"] == latest_time].copy()
            latest_bus.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
            latest_bus.reset_index(drop=True, inplace=True)
            # Ensure clean column headers
            latest_bus.columns.name = None
            latest_bus.index.name = None
            latest_bus.attrs["df_type"] = "BUS"
            grid_state.bus = latest_bus

        if not gen_data.empty:
            latest_time = gen_data["timestamp"].max()
            latest_gen = gen_data[gen_data["timestamp"] == latest_time].copy()
            latest_gen.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
            latest_gen.reset_index(drop=True, inplace=True)
            # Ensure clean column headers
            latest_gen.columns.name = None
            latest_gen.index.name = None
            latest_gen.attrs["df_type"] = "GEN"
            grid_state.gen = latest_gen

        if not load_data.empty:
            latest_time = load_data["timestamp"].max()
            latest_load = load_data[load_data["timestamp"] == latest_time].copy()
            latest_load.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
            latest_load.reset_index(drop=True, inplace=True)
            # Ensure clean column headers
            latest_load.columns.name = None
            latest_load.index.name = None
            latest_load.attrs["df_type"] = "LOAD"
            grid_state.load = latest_load

        if not line_data.empty:
            latest_time = line_data["timestamp"].max()
            latest_line = line_data[line_data["timestamp"] == latest_time].copy()
            latest_line.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
            latest_line.reset_index(drop=True, inplace=True)
            # Ensure clean column headers
            latest_line.columns.name = None
            latest_line.index.name = None
            latest_line.attrs["df_type"] = "LINE"
            grid_state.line = latest_line

        # Reconstruct time-series data
        def _reconstruct_timeseries(data_df, id_col, component_type):
            """Helper function to reconstruct time-series data for a component type."""
            if data_df.empty:
                return AttrDict()

            ts_data = AttrDict()

            # Get all variable columns (exclude metadata columns)
            exclude_cols = {
                "grid_sim_id",
                "timestamp",
                id_col,
                f"{component_type}_name",
            }
            if component_type == "bus":
                exclude_cols.update(
                    {"bus_name", "vbase"}
                )  # Updated to include bus_name
            elif component_type == "gen":
                exclude_cols.update(
                    {"gen_name", "mbase"}
                )  # Updated to include gen_name
            elif component_type == "line":
                exclude_cols.update(
                    {"line_name", "ibus", "jbus"}
                )  # Updated to include line_name
            elif component_type == "load":
                exclude_cols.add("load_name")  # Added load_name

            var_cols = [col for col in data_df.columns if col not in exclude_cols]

            # For each variable, create a time-series DataFrame
            for var in var_cols:
                if f"{component_type}_name" not in data_df.columns:
                    # Fallback: use component IDs as column names
                    pivot_data = data_df.pivot(
                        index="timestamp", columns=id_col, values=var
                    )
                else:
                    # Pivot data to have timestamps as rows and component names as columns
                    pivot_data = data_df.pivot(
                        index="timestamp", columns=f"{component_type}_name", values=var
                    )

                # Clean up column headers
                pivot_data.columns.name = None
                pivot_data.index.name = None
                ts_data[var] = pivot_data

            return ts_data

        # Reconstruct time-series for each component type
        grid_state.bus_t = _reconstruct_timeseries(bus_data, "bus", "bus")
        grid_state.gen_t = _reconstruct_timeseries(gen_data, "gen", "gen")
        grid_state.load_t = _reconstruct_timeseries(load_data, "load", "load")
        grid_state.line_t = _reconstruct_timeseries(line_data, "line", "line")

        print(
            f"GridState reconstructed: {sim_row['case_name']} ({software.upper()}) - "
            f"{len(grid_state.bus)} buses, {len(grid_state.gen)} generators"
        )

        return grid_state

    def set_database_path(self, db_path):
        """Set database path and reinitialize connection.

        Args:
            db_path (str): Path to WEC-GRID database file.

        Example:
            >>> engine.database.set_database_path("/path/to/wecgrid-database/WEC-GRID.db")
        """
        if not os.path.exists(db_path):
            raise FileNotFoundError(f"Database file not found: {db_path}")

        # Save to config
        save_database_config(db_path)

        # Update current instance
        self.db_path = str(Path(db_path).absolute())

        # Reinitialize
        self.check_and_initialize()

        return self.db_path

check_and_initialize()

Check if database exists and has correct schema, initialize if needed.

Validates that all required tables exist with proper structure. Creates database and initializes schema if missing or incomplete.

Returns:

Name Type Description
bool

True if database was already valid, False if initialization was needed.

Source code in src/wecgrid/util/database.py
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
188
189
190
191
192
193
194
195
196
197
198
199
def check_and_initialize(self):
    """Check if database exists and has correct schema, initialize if needed.

    Validates that all required tables exist with proper structure.
    Creates database and initializes schema if missing or incomplete.

    Returns:
        bool: True if database was already valid, False if initialization was needed.
    """
    if self.db_path is None:
        print("Warning: Database path not set. Cannot initialize database.")
        return False

    if not os.path.exists(self.db_path):
        # Ensure directory exists
        os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
        self.initialize_database()
        return False

    # Check if all required tables exist
    required_tables = [
        "grid_simulations",
        "wec_simulations",
        "wec_integrations",
        "psse_bus_results",
        "psse_generator_results",
        "psse_load_results",
        "psse_line_results",
        "pypsa_bus_results",
        "pypsa_generator_results",
        "pypsa_load_results",
        "pypsa_line_results",
        "wec_power_results",
    ]

    with self.connection() as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
        existing_tables = {row[0] for row in cursor.fetchall()}

        missing_tables = set(required_tables) - existing_tables
        if missing_tables:
            self.initialize_database()
            return False

    # Check for missing columns in existing tables and migrate
    self._migrate_schema()

    return True

clean_database()

Delete the current database and reinitialize with fresh schema.

WARNING: This will permanently delete all stored simulation data. Use with caution - all existing data will be lost.

Returns:

Name Type Description
bool

True if database was successfully cleaned and reinitialized.

Notes

Wasn't working if my Jupyter Kernal was still going, need to restart then call

Example: >>> engine.database.clean_database() WARNING: This will delete all data in the database! Database cleaned and reinitialized successfully.

Source code in src/wecgrid/util/database.py
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
def clean_database(self):
    """Delete the current database and reinitialize with fresh schema.

    WARNING: This will permanently delete all stored simulation data.
    Use with caution - all existing data will be lost.

    Returns:
        bool: True if database was successfully cleaned and reinitialized.

    Notes:
        Wasn't working if my Jupyter Kernal was still going, need to restart then call
    Example:
        >>> engine.database.clean_database()
        WARNING: This will delete all data in the database!
        Database cleaned and reinitialized successfully.
    """
    print("WARNING: This will delete all data in the database!")

    # Close any existing connections by creating a temporary one and closing it
    try:
        conn = sqlite3.connect(self.db_path)
        conn.close()
    except:
        pass

    # Delete the database file if it exists
    if os.path.exists(self.db_path):
        try:
            os.remove(self.db_path)
        except OSError as e:
            print(f"Error deleting database file: {e}")
            return False

    # Reinitialize with fresh schema
    try:
        self.initialize_database()
        return True
    except Exception as e:
        print(f"Error reinitializing database: {e}")
        return False

connection()

Context manager for safe database connections.

Provides transaction safety with automatic commit on success and rollback on exceptions. Connection closed automatically.

Source code in src/wecgrid/util/database.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
@contextmanager
def connection(self):
    """Context manager for safe database connections.

    Provides transaction safety with automatic commit on success and
    rollback on exceptions. Connection closed automatically.

    """
    if self.db_path is None:
        raise ValueError(
            "Database path not configured. Please use engine.database.set_database_path() to configure."
        )

    conn = sqlite3.connect(self.db_path)
    try:
        yield conn
        conn.commit()
    except:
        conn.rollback()
        raise
    finally:
        conn.close()

get_simulation_info(grid_sim_id=None)

Get grid simulation information.

Parameters:

Name Type Description Default
grid_sim_id int

Specific simulation ID. If None, returns all.

None

Returns:

Type Description
DataFrame

pd.DataFrame: Simulation metadata.

Source code in src/wecgrid/util/database.py
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
def get_simulation_info(self, grid_sim_id: int = None) -> pd.DataFrame:
    """Get grid simulation information.

    Args:
        grid_sim_id (int, optional): Specific simulation ID. If None, returns all.

    Returns:
        pd.DataFrame: Simulation metadata.
    """
    if grid_sim_id:
        return self.query(
            "SELECT * FROM grid_simulations WHERE grid_sim_id = ?",
            params=(grid_sim_id,),
            return_type="df",
        )
    else:
        return self.query(
            "SELECT * FROM grid_simulations ORDER BY created_at DESC",
            return_type="df",
        )

grid_sims()

Get all grid simulation metadata in a user-friendly format.

Returns:

Type Description
DataFrame

pd.DataFrame: Grid simulations with key metadata columns.

Example

engine.database.grid_sims() grid_sim_id sim_name case_name psse pypsa sbase_mva ... 0 1 Test Run IEEE_14_bus True False 100.0 ...

Source code in src/wecgrid/util/database.py
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
def grid_sims(self) -> pd.DataFrame:
    """Get all grid simulation metadata in a user-friendly format.

    Returns:
        pd.DataFrame: Grid simulations with key metadata columns.

    Example:
        >>> engine.database.grid_sims()
           grid_sim_id     sim_name      case_name  psse  pypsa  sbase_mva  ...
        0           1     Test Run   IEEE_14_bus  True  False      100.0  ...
    """
    return self.query(
        """
        SELECT grid_sim_id, sim_name, case_name, psse, pypsa, sbase_mva,
               sim_start_time, sim_end_time, delta_time, notes, created_at
        FROM grid_simulations 
        ORDER BY created_at DESC
    """,
        return_type="df",
    )

initialize_database(db_path=None)

Initialize database schema with WEC-Grid tables and indexes.

Parameters:

Name Type Description Default
db_path str

Path where database should be created. If provided, creates new database at this location and updates the current instance to use it. If None, uses existing database path.

None

Creates all required tables according to the finalized WEC-Grid schema: - Metadata tables for simulation parameters - Software-specific result tables (PSS®E, PyPSA) - WEC time-series data tables - Performance indexes for efficient queries

All existing data is preserved if tables already exist.

Example

Create new database when none is configured

engine.database.initialize_database("/path/to/new_database.db")

Initialize schema on existing configured database

engine.database.initialize_database()

Source code in src/wecgrid/util/database.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
def initialize_database(self, db_path: Optional[str] = None):
    """Initialize database schema with WEC-Grid tables and indexes.

    Args:
        db_path (str, optional): Path where database should be created.
            If provided, creates new database at this location and updates
            the current instance to use it. If None, uses existing database path.

    Creates all required tables according to the finalized WEC-Grid schema:
    - Metadata tables for simulation parameters
    - Software-specific result tables (PSS®E, PyPSA)
    - WEC time-series data tables
    - Performance indexes for efficient queries

    All existing data is preserved if tables already exist.

    Example:
        >>> # Create new database when none is configured
        >>> engine.database.initialize_database("/path/to/new_database.db")

        >>> # Initialize schema on existing configured database
        >>> engine.database.initialize_database()
    """
    if db_path:
        # Convert to absolute path
        db_path = str(Path(db_path).absolute())

        # Ensure directory exists
        os.makedirs(os.path.dirname(db_path), exist_ok=True)

        # Update the instance to use this new database path
        save_database_config(db_path)
        self.db_path = db_path

        # Create the database file if it doesn't exist
        if not os.path.exists(db_path):
            # Touch the file to create it
            conn = sqlite3.connect(db_path)
            conn.close()

    # Verify we have a database path to work with
    if self.db_path is None:
        raise ValueError(
            "No database path configured. Please provide db_path parameter or "
            "use engine.database.set_database_path() to configure a database path first."
        )

    with self.connection() as conn:
        cursor = conn.cursor()

        # ================================================================
        # METADATA TABLES
        # ================================================================

        # Grid simulation metadata
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS grid_simulations (
                grid_sim_id INTEGER PRIMARY KEY AUTOINCREMENT,
                sim_name TEXT,
                case_name TEXT NOT NULL,
                psse BOOLEAN DEFAULT FALSE,
                pypsa BOOLEAN DEFAULT FALSE,
                sbase_mva REAL NOT NULL,
                sim_start_time TEXT NOT NULL,
                sim_end_time TEXT,
                delta_time INTEGER,
                notes TEXT,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
        """
        )

        # WEC simulation parameters
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS wec_simulations (
                wec_sim_id INTEGER PRIMARY KEY AUTOINCREMENT,
                model_type TEXT NOT NULL,
                sim_duration_sec REAL NOT NULL,
                delta_time REAL NOT NULL,
                wave_height_m REAL,
                wave_period_sec REAL,
                wave_spectrum TEXT,
                wave_class TEXT,
                wave_seed INTEGER,
                simulation_hash TEXT,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP
            )
        """
        )

        # WEC-Grid integration mapping
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS wec_integrations (
                integration_id INTEGER PRIMARY KEY AUTOINCREMENT,
                grid_sim_id INTEGER NOT NULL,
                wec_sim_id INTEGER NOT NULL,
                farm_name TEXT NOT NULL,
                bus_location INTEGER NOT NULL,
                num_devices INTEGER NOT NULL,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE,
                FOREIGN KEY (wec_sim_id) REFERENCES wec_simulations(wec_sim_id) ON DELETE CASCADE
            )
        """
        )

        # ================================================================
        # PSS®E-SPECIFIC TABLES (GridState Schema Alignment)
        # ================================================================

        # PSS®E Bus Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS psse_bus_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                bus INTEGER NOT NULL,
                bus_name TEXT,
                type TEXT,
                p REAL,
                q REAL,
                v_mag REAL,
                angle_deg REAL,
                vbase REAL,
                PRIMARY KEY (grid_sim_id, timestamp, bus),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PSS®E Generator Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS psse_generator_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                gen INTEGER NOT NULL,
                gen_name TEXT,
                bus INTEGER NOT NULL,
                p REAL,
                q REAL,
                mbase REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, gen),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PSS®E Load Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS psse_load_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                load INTEGER NOT NULL,
                load_name TEXT,
                bus INTEGER NOT NULL,
                p REAL,
                q REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, load),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PSS®E Line Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS psse_line_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                line INTEGER NOT NULL,
                line_name TEXT,
                ibus INTEGER NOT NULL,
                jbus INTEGER NOT NULL,
                line_pct REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, line),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # ================================================================
        # PyPSA-SPECIFIC TABLES (Identical to PSS®E for Cross-Platform Comparison)
        # ================================================================

        # PyPSA Bus Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS pypsa_bus_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                bus INTEGER NOT NULL,
                bus_name TEXT,
                type TEXT,
                p REAL,
                q REAL,
                v_mag REAL,
                angle_deg REAL,
                vbase REAL,
                PRIMARY KEY (grid_sim_id, timestamp, bus),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PyPSA Generator Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS pypsa_generator_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                gen INTEGER NOT NULL,
                gen_name TEXT,
                bus INTEGER NOT NULL,
                p REAL,
                q REAL,
                mbase REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, gen),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PyPSA Load Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS pypsa_load_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                load INTEGER NOT NULL,
                load_name TEXT,
                bus INTEGER NOT NULL,
                p REAL,
                q REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, load),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # PyPSA Line Results
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS pypsa_line_results (
                grid_sim_id INTEGER NOT NULL,
                timestamp TEXT NOT NULL,
                line INTEGER NOT NULL,
                line_name TEXT,
                ibus INTEGER NOT NULL,
                jbus INTEGER NOT NULL,
                line_pct REAL,
                status INTEGER,
                PRIMARY KEY (grid_sim_id, timestamp, line),
                FOREIGN KEY (grid_sim_id) REFERENCES grid_simulations(grid_sim_id) ON DELETE CASCADE
            )
        """
        )

        # ================================================================
        # WEC TIME-SERIES DATA
        # ================================================================

        # WEC Power Results (High-Resolution Time Series)
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS wec_power_results (
                wec_sim_id INTEGER NOT NULL,
                time_sec REAL NOT NULL,
                device_index INTEGER NOT NULL,
                p_w REAL,
                q_var REAL,
                wave_elevation_m REAL,
                PRIMARY KEY (wec_sim_id, time_sec, device_index),
                FOREIGN KEY (wec_sim_id) REFERENCES wec_simulations(wec_sim_id) ON DELETE CASCADE
            )
        """
        )

        # ================================================================
        # PERFORMANCE INDEXES
        # ================================================================

        # Grid simulation indexes
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_grid_sim_time ON grid_simulations(sim_start_time)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_grid_sim_case ON grid_simulations(case_name)"
        )

        # PSS®E result indexes
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_psse_bus_time ON psse_bus_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_psse_gen_time ON psse_generator_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_psse_load_time ON psse_load_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_psse_line_time ON psse_line_results(grid_sim_id, timestamp)"
        )

        # PyPSA result indexes
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_pypsa_bus_time ON pypsa_bus_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_pypsa_gen_time ON pypsa_generator_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_pypsa_load_time ON pypsa_load_results(grid_sim_id, timestamp)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_pypsa_line_time ON pypsa_line_results(grid_sim_id, timestamp)"
        )

        # WEC time-series indexes
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_wec_power_time ON wec_power_results(wec_sim_id, time_sec)"
        )
        cursor.execute(
            "CREATE INDEX IF NOT EXISTS idx_wec_integration ON wec_integrations(grid_sim_id, wec_sim_id)"
        )

pull_sim(grid_sim_id, software=None)

Pull simulation data from database and reconstruct GridState object.

Retrieves all time-series data for a specific simulation and recreates the GridState object with both snapshot data and time-series history.

Parameters:

Name Type Description Default
grid_sim_id int

Grid simulation ID to retrieve.

required
software str

Software backend to pull data for ("psse" or "pypsa"). If None, automatically detects which software was used based on grid_simulations table flags.

None

Returns:

Name Type Description
GridState

Reconstructed GridState object with time-series data.

Raises:

Type Description
ValueError

If grid_sim_id not found or software not available for this simulation.

Example

Pull PSS®E simulation data

grid_state = engine.database.pull_sim(grid_sim_id=123, software="psse") print(f"Buses: {len(grid_state.bus)}") print(f"Time series data: {list(grid_state.bus_t.keys())}")

Auto-detect software and pull data

grid_state = engine.database.pull_sim(grid_sim_id=123)

Source code in src/wecgrid/util/database.py
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
def pull_sim(self, grid_sim_id: int, software: str = None):
    """Pull simulation data from database and reconstruct GridState object.

    Retrieves all time-series data for a specific simulation and recreates
    the GridState object with both snapshot data and time-series history.

    Args:
        grid_sim_id (int): Grid simulation ID to retrieve.
        software (str, optional): Software backend to pull data for ("psse" or "pypsa").
            If None, automatically detects which software was used based on
            grid_simulations table flags.

    Returns:
        GridState: Reconstructed GridState object with time-series data.

    Raises:
        ValueError: If grid_sim_id not found or software not available for this simulation.

    Example:
        >>> # Pull PSS®E simulation data
        >>> grid_state = engine.database.pull_sim(grid_sim_id=123, software="psse")
        >>> print(f"Buses: {len(grid_state.bus)}")
        >>> print(f"Time series data: {list(grid_state.bus_t.keys())}")

        >>> # Auto-detect software and pull data
        >>> grid_state = engine.database.pull_sim(grid_sim_id=123)
    """
    # Import here to avoid circular import
    from ..modelers.power_system.base import GridState, AttrDict

    # Get simulation metadata
    sim_info = self.query(
        "SELECT * FROM grid_simulations WHERE grid_sim_id = ?",
        params=(grid_sim_id,),
        return_type="df",
    )

    if sim_info.empty:
        raise ValueError(f"Simulation with ID {grid_sim_id} not found in database")

    sim_row = sim_info.iloc[0]

    # Auto-detect software if not specified
    if software is None:
        if sim_row["psse"]:
            software = "psse"
        elif sim_row["pypsa"]:
            software = "pypsa"
        else:
            raise ValueError(
                f"No software backend data found for simulation {grid_sim_id}"
            )

    # Validate software choice
    if software not in ["psse", "pypsa"]:
        raise ValueError(
            f"Invalid software: '{software}'. Must be 'psse' or 'pypsa'."
        )

    if software == "psse" and not sim_row["psse"]:
        raise ValueError(f"PSS®E data not available for simulation {grid_sim_id}")
    if software == "pypsa" and not sim_row["pypsa"]:
        raise ValueError(f"PyPSA data not available for simulation {grid_sim_id}")

    # Create GridState object
    grid_state = GridState(software=software)

    # Set case name from database metadata
    grid_state.case = sim_row["case_name"]

    # Table prefix for this software
    table_prefix = f"{software}_"
    table_prefix = f"{software}_"

    # Pull bus data
    bus_data = self.query(
        f"""
        SELECT * FROM {table_prefix}bus_results 
        WHERE grid_sim_id = ? 
        ORDER BY timestamp, bus
    """,
        params=(grid_sim_id,),
        return_type="df",
    )

    # Pull generator data
    gen_data = self.query(
        f"""
        SELECT * FROM {table_prefix}generator_results 
        WHERE grid_sim_id = ? 
        ORDER BY timestamp, gen
    """,
        params=(grid_sim_id,),
        return_type="df",
    )

    # Pull load data
    load_data = self.query(
        f"""
        SELECT * FROM {table_prefix}load_results 
        WHERE grid_sim_id = ? 
        ORDER BY timestamp, load
    """,
        params=(grid_sim_id,),
        return_type="df",
    )

    # Pull line data
    line_data = self.query(
        f"""
        SELECT * FROM {table_prefix}line_results 
        WHERE grid_sim_id = ? 
        ORDER BY timestamp, line
    """,
        params=(grid_sim_id,),
        return_type="df",
    )

    # Convert timestamp strings to pandas timestamps
    if not bus_data.empty:
        bus_data["timestamp"] = pd.to_datetime(bus_data["timestamp"])
    if not gen_data.empty:
        gen_data["timestamp"] = pd.to_datetime(gen_data["timestamp"])
    if not load_data.empty:
        load_data["timestamp"] = pd.to_datetime(load_data["timestamp"])
    if not line_data.empty:
        line_data["timestamp"] = pd.to_datetime(line_data["timestamp"])

    # Reconstruct current snapshot data (use latest timestamp)
    if not bus_data.empty:
        latest_time = bus_data["timestamp"].max()
        latest_bus = bus_data[bus_data["timestamp"] == latest_time].copy()
        latest_bus.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
        latest_bus.reset_index(drop=True, inplace=True)
        # Ensure clean column headers
        latest_bus.columns.name = None
        latest_bus.index.name = None
        latest_bus.attrs["df_type"] = "BUS"
        grid_state.bus = latest_bus

    if not gen_data.empty:
        latest_time = gen_data["timestamp"].max()
        latest_gen = gen_data[gen_data["timestamp"] == latest_time].copy()
        latest_gen.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
        latest_gen.reset_index(drop=True, inplace=True)
        # Ensure clean column headers
        latest_gen.columns.name = None
        latest_gen.index.name = None
        latest_gen.attrs["df_type"] = "GEN"
        grid_state.gen = latest_gen

    if not load_data.empty:
        latest_time = load_data["timestamp"].max()
        latest_load = load_data[load_data["timestamp"] == latest_time].copy()
        latest_load.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
        latest_load.reset_index(drop=True, inplace=True)
        # Ensure clean column headers
        latest_load.columns.name = None
        latest_load.index.name = None
        latest_load.attrs["df_type"] = "LOAD"
        grid_state.load = latest_load

    if not line_data.empty:
        latest_time = line_data["timestamp"].max()
        latest_line = line_data[line_data["timestamp"] == latest_time].copy()
        latest_line.drop(columns=["grid_sim_id", "timestamp"], inplace=True)
        latest_line.reset_index(drop=True, inplace=True)
        # Ensure clean column headers
        latest_line.columns.name = None
        latest_line.index.name = None
        latest_line.attrs["df_type"] = "LINE"
        grid_state.line = latest_line

    # Reconstruct time-series data
    def _reconstruct_timeseries(data_df, id_col, component_type):
        """Helper function to reconstruct time-series data for a component type."""
        if data_df.empty:
            return AttrDict()

        ts_data = AttrDict()

        # Get all variable columns (exclude metadata columns)
        exclude_cols = {
            "grid_sim_id",
            "timestamp",
            id_col,
            f"{component_type}_name",
        }
        if component_type == "bus":
            exclude_cols.update(
                {"bus_name", "vbase"}
            )  # Updated to include bus_name
        elif component_type == "gen":
            exclude_cols.update(
                {"gen_name", "mbase"}
            )  # Updated to include gen_name
        elif component_type == "line":
            exclude_cols.update(
                {"line_name", "ibus", "jbus"}
            )  # Updated to include line_name
        elif component_type == "load":
            exclude_cols.add("load_name")  # Added load_name

        var_cols = [col for col in data_df.columns if col not in exclude_cols]

        # For each variable, create a time-series DataFrame
        for var in var_cols:
            if f"{component_type}_name" not in data_df.columns:
                # Fallback: use component IDs as column names
                pivot_data = data_df.pivot(
                    index="timestamp", columns=id_col, values=var
                )
            else:
                # Pivot data to have timestamps as rows and component names as columns
                pivot_data = data_df.pivot(
                    index="timestamp", columns=f"{component_type}_name", values=var
                )

            # Clean up column headers
            pivot_data.columns.name = None
            pivot_data.index.name = None
            ts_data[var] = pivot_data

        return ts_data

    # Reconstruct time-series for each component type
    grid_state.bus_t = _reconstruct_timeseries(bus_data, "bus", "bus")
    grid_state.gen_t = _reconstruct_timeseries(gen_data, "gen", "gen")
    grid_state.load_t = _reconstruct_timeseries(load_data, "load", "load")
    grid_state.line_t = _reconstruct_timeseries(line_data, "line", "line")

    print(
        f"GridState reconstructed: {sim_row['case_name']} ({software.upper()}) - "
        f"{len(grid_state.bus)} buses, {len(grid_state.gen)} generators"
    )

    return grid_state

query(sql, params=None, return_type='raw')

Execute SQL query with flexible result formatting.

Parameters:

Name Type Description Default
sql str

SQL query string.

required
params tuple

Query parameters for safe substitution.

None
return_type str

Format for results - 'raw', 'df', or 'dict'.

'raw'

Returns:

Type Description

Results in specified format:

  • 'raw': List of tuples (default SQLite format)
  • 'df': pandas DataFrame with column names
  • 'dict': List of dictionaries with column names as keys
Example

db.query("SELECT * FROM grid_simulations WHERE case_name = ?", ... params=("IEEE_14_bus",), return_type="df")

Source code in src/wecgrid/util/database.py
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
def query(self, sql: str, params: tuple = None, return_type: str = "raw"):
    """Execute SQL query with flexible result formatting.

    Args:
        sql (str): SQL query string.
        params (tuple, optional): Query parameters for safe substitution.
        return_type (str): Format for results - 'raw', 'df', or 'dict'.

    Returns:
        Results in specified format:
        - 'raw': List of tuples (default SQLite format)
        - 'df': pandas DataFrame with column names
        - 'dict': List of dictionaries with column names as keys

    Example:
        >>> db.query("SELECT * FROM grid_simulations WHERE case_name = ?",
        ...           params=("IEEE_14_bus",), return_type="df")
    """
    with self.connection() as conn:
        cursor = conn.cursor()
        cursor.execute(sql, params or ())
        result = cursor.fetchall()

        if return_type == "df":
            columns = [desc[0] for desc in cursor.description]
            return pd.DataFrame(result, columns=columns)
        elif return_type == "dict":
            columns = [desc[0] for desc in cursor.description]
            return [dict(zip(columns, row)) for row in result]
        elif return_type == "raw":
            return result
        else:
            raise ValueError(
                f"Invalid return_type '{return_type}'. Must be 'raw', 'df', or 'dict'."
            )

save_sim(sim_name, notes=None)

Save simulation data for all available software backends in the engine.

Automatically detects and stores data from all active software backends (PSS®E, PyPSA) and WEC farms present in the engine object.

Always creates a new simulation entry - no duplicate checking. Users can manage simulation names as needed.

Parameters:

Name Type Description Default
sim_name str

User-friendly simulation name.

required
notes str

Simulation notes.

None

Returns:

Name Type Description
int int

grid_sim_id of the created simulation.

Example

sim_id = engine.database.save_sim( ... sim_name="IEEE 30 test", ... notes="testing the database" ... )

Source code in src/wecgrid/util/database.py
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
def save_sim(self, sim_name: str, notes: str = None) -> int:
    """Save simulation data for all available software backends in the engine.

    Automatically detects and stores data from all active software backends
    (PSS®E, PyPSA) and WEC farms present in the engine object.

    Always creates a new simulation entry - no duplicate checking.
    Users can manage simulation names as needed.

    Args:
        sim_name (str): User-friendly simulation name.
        notes (str, optional): Simulation notes.

    Returns:
        int: grid_sim_id of the created simulation.

    Example:
        >>> sim_id = engine.database.save_sim(
        ...     sim_name="IEEE 30 test",
        ...     notes="testing the database"
        ... )
    """
    # Gather all available software objects from engine
    softwares = []

    # Check for PSS®E
    if hasattr(self.engine, "psse") and hasattr(self.engine.psse, "grid"):
        softwares.append(self.engine.psse.grid)

    # Check for PyPSA
    if hasattr(self.engine, "pypsa") and hasattr(self.engine.pypsa, "grid"):
        softwares.append(self.engine.pypsa.grid)

    if not softwares:
        raise ValueError(
            "No software backends found in engine. Ensure PSS®E or PyPSA models are loaded."
        )

    # Get case name from engine
    case_name = getattr(self.engine, "case_name", "Unknown_Case")

    # Get time manager from engine
    timeManager = getattr(self.engine, "time", None)
    if timeManager is None:
        raise ValueError(
            "No time manager found in engine. Ensure engine.time is properly initialized."
        )

    # Extract software flags and determine sbase
    psse_used = False
    pypsa_used = False
    sbase_mva = None

    for i, software_obj in enumerate(softwares):
        software_name = getattr(software_obj, "software", "")

        software_name = software_name.lower() if software_name else ""

        if software_name == "psse":
            psse_used = True
        elif software_name == "pypsa":
            pypsa_used = True
        else:
            continue  # Skip this software object instead of processing it

        # Get sbase from the first software object
        if sbase_mva is None:
            if hasattr(software_obj, "sbase"):
                sbase_mva = software_obj.sbase
            else:
                # Try to get from parent object
                parent = getattr(software_obj, "_parent", None)
                if parent and hasattr(parent, "sbase"):
                    sbase_mva = parent.sbase
                else:
                    sbase_mva = 100.0  # Default fallback

    # Get time information from simulation
    sim_start_time = timeManager.start_time.isoformat()
    sim_end_time = getattr(timeManager, "sim_stop", None)
    if sim_end_time:
        sim_end_time = sim_end_time.isoformat()
    delta_time = timeManager.delta_time

    # Create new grid simulation record (always create new entry)
    with self.connection() as conn:
        cursor = conn.cursor()

        # Insert new simulation - always create new entry
        cursor.execute(
            """
            INSERT INTO grid_simulations 
            (sim_name, case_name, psse, pypsa, sbase_mva, sim_start_time, 
             sim_end_time, delta_time, notes)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
            (
                sim_name,
                case_name,
                psse_used,
                pypsa_used,
                sbase_mva,
                sim_start_time,
                sim_end_time,
                delta_time,
                notes,
            ),
        )

        grid_sim_id = cursor.lastrowid

    # Store data for each valid software
    valid_softwares = []
    for software_obj in softwares:
        software_name = getattr(software_obj, "software", "").lower()

        # Only process valid software names
        if software_name in ["psse", "pypsa"]:
            valid_softwares.append((software_obj, software_name))

    for software_obj, software_name in valid_softwares:
        # Store all time-series data from GridState
        self._store_all_gridstate_timeseries(
            grid_sim_id, software_obj, software_name, timeManager
        )

    # Store WEC farm data if available
    if hasattr(self.engine, "wec_farms") and self.engine.wec_farms:
        self._store_wec_farm_data(grid_sim_id)

    # Create summary of used software
    used_software = []
    if psse_used:
        used_software.append("PSS®E")
    if pypsa_used:
        used_software.append("PyPSA")

    print(f"Simulation saved: ID {grid_sim_id} - {sim_name}")

    return grid_sim_id

set_database_path(db_path)

Set database path and reinitialize connection.

Parameters:

Name Type Description Default
db_path str

Path to WEC-GRID database file.

required
Example

engine.database.set_database_path("/path/to/wecgrid-database/WEC-GRID.db")

Source code in src/wecgrid/util/database.py
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
def set_database_path(self, db_path):
    """Set database path and reinitialize connection.

    Args:
        db_path (str): Path to WEC-GRID database file.

    Example:
        >>> engine.database.set_database_path("/path/to/wecgrid-database/WEC-GRID.db")
    """
    if not os.path.exists(db_path):
        raise FileNotFoundError(f"Database file not found: {db_path}")

    # Save to config
    save_database_config(db_path)

    # Update current instance
    self.db_path = str(Path(db_path).absolute())

    # Reinitialize
    self.check_and_initialize()

    return self.db_path

store_gridstate_data(grid_sim_id, timestamp, grid_state, software)

Store GridState data to appropriate software-specific tables.

Parameters:

Name Type Description Default
grid_sim_id int

Grid simulation ID.

required
timestamp str

ISO datetime string for this snapshot.

required
grid_state

GridState object with bus, gen, load, line DataFrames.

required
software str

Software backend - "PSSE" or "PyPSA".

required
Example

db.store_gridstate_data( ... grid_sim_id=123, ... timestamp="2025-08-14T10:05:00", ... grid_state=my_grid_state, ... software="PSSE" ... )

Source code in src/wecgrid/util/database.py
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
def store_gridstate_data(
    self, grid_sim_id: int, timestamp: str, grid_state, software: str
):
    """Store GridState data to appropriate software-specific tables.

    Args:
        grid_sim_id (int): Grid simulation ID.
        timestamp (str): ISO datetime string for this snapshot.
        grid_state: GridState object with bus, gen, load, line DataFrames.
        software (str): Software backend - "PSSE" or "PyPSA".

    Example:
        >>> db.store_gridstate_data(
        ...     grid_sim_id=123,
        ...     timestamp="2025-08-14T10:05:00",
        ...     grid_state=my_grid_state,
        ...     software="PSSE"
        ... )
    """
    software = software.lower()
    table_prefix = f"{software}_"

    with self.connection() as conn:
        cursor = conn.cursor()

        # Store bus results
        if not grid_state.bus.empty:
            for bus_id, row in grid_state.bus.iterrows():
                cursor.execute(
                    f"""
                    INSERT OR REPLACE INTO {table_prefix}bus_results 
                    (grid_sim_id, timestamp, bus, bus_name, type, p, q, v_mag, angle_deg, vbase)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                    (
                        grid_sim_id,
                        timestamp,
                        bus_id,
                        row.get("bus_name"),
                        row.get("type"),
                        row.get("p"),
                        row.get("q"),
                        row.get("v_mag"),
                        row.get("angle_deg"),
                        row.get("vbase"),
                    ),
                )

        # Store generator results
        if not grid_state.gen.empty:
            for gen_id, row in grid_state.gen.iterrows():
                cursor.execute(
                    f"""
                    INSERT OR REPLACE INTO {table_prefix}generator_results 
                    (grid_sim_id, timestamp, gen, gen_name, bus, p, q, mbase, status)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                    (
                        grid_sim_id,
                        timestamp,
                        gen_id,
                        row.get("gen_name"),
                        row.get("bus"),
                        row.get("p"),
                        row.get("q"),
                        row.get("Mbase"),
                        row.get("status"),
                    ),
                )

        # Store load results
        if not grid_state.load.empty:
            for load_id, row in grid_state.load.iterrows():
                cursor.execute(
                    f"""
                    INSERT OR REPLACE INTO {table_prefix}load_results 
                    (grid_sim_id, timestamp, load, load_name, bus, p, q, status)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                    (
                        grid_sim_id,
                        timestamp,
                        load_id,
                        row.get("load_name"),
                        row.get("bus"),
                        row.get("p"),
                        row.get("q"),
                        row.get("status"),
                    ),
                )

        # Store line results
        if not grid_state.line.empty:
            for line_id, row in grid_state.line.iterrows():
                cursor.execute(
                    f"""
                    INSERT OR REPLACE INTO {table_prefix}line_results 
                    (grid_sim_id, timestamp, line, line_name, ibus, jbus, line_pct, status)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                    (
                        grid_sim_id,
                        timestamp,
                        line_id,
                        row.get("line_name"),
                        row.get("ibus"),
                        row.get("jbus"),
                        row.get("line_pct"),
                        row.get("status"),
                    ),
                )

wecsim_runs()

Get all WEC simulation metadata with enhanced wave parameters.

Returns:

Type Description
DataFrame

pd.DataFrame: WEC simulations with parameters and wave conditions including wave spectrum type, wave class, and all simulation parameters.

Example

engine.database.wecsim_runs() wec_sim_id model_type sim_duration_sec delta_time wave_spectrum wave_class ... 0 1 RM3 600.0 0.1 PM irregular ...

Source code in src/wecgrid/util/database.py
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
def wecsim_runs(self) -> pd.DataFrame:
    """Get all WEC simulation metadata with enhanced wave parameters.

    Returns:
        pd.DataFrame: WEC simulations with parameters and wave conditions including
            wave spectrum type, wave class, and all simulation parameters.

    Example:
        >>> engine.database.wecsim_runs()
           wec_sim_id model_type  sim_duration_sec  delta_time  wave_spectrum  wave_class  ...
        0          1       RM3             600.0        0.1             PM   irregular  ...
    """
    return self.query(
        """
        SELECT wec_sim_id, model_type, sim_duration_sec, delta_time,
               wave_height_m, wave_period_sec, wave_spectrum, wave_class, wave_seed,
               simulation_hash, created_at
        FROM wec_simulations 
        ORDER BY created_at DESC
    """,
        return_type="df",
    )

Time

Centralized time coordination for WEC-Grid simulations.

Coordinates temporal aspects across power system modeling (PSS®E, PyPSA), WEC simulations (WEC-Sim), and visualization components. Manages simulation time windows, sampling intervals, and ensures cross-platform alignment.

Attributes:

Name Type Description
start_time datetime

Simulation start timestamp. Defaults to current date at midnight.

num_steps int

Number of simulation time steps. Defaults to 288 (24 hours at 5-minute intervals).

freq str

Pandas frequency string for time intervals. Defaults to "5min" (5-minute intervals).

sim_stop datetime

Calculated simulation end timestamp. Automatically computed from start_time, sim_length, and freq. Updated whenever simulation parameters change.

Example

Default 24-hour simulation at 5-minute intervals

time_mgr = WECGridTime() print(f"Duration: {time_mgr.num_steps} steps") print(f"Interval: {time_mgr.freq}") Duration: 288 steps Interval: 5T

Custom simulation period

from datetime import datetime time_mgr = WECGridTime( ... start_time=datetime(2023, 6, 15, 0, 0, 0), ... sim_length=144, # 12 hours ... freq="5min" ... ) print(f"Start: {time_mgr.start_time}")

Source code in src/wecgrid/util/time.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
@dataclass
class WECGridTime:
    """Centralized time coordination for WEC-Grid simulations.

    Coordinates temporal aspects across power system modeling (PSS®E, PyPSA),
    WEC simulations (WEC-Sim), and visualization components. Manages simulation
    time windows, sampling intervals, and ensures cross-platform alignment.

    Attributes:
        start_time (datetime): Simulation start timestamp. Defaults to current
            date at midnight.
        num_steps (int): Number of simulation time steps. Defaults to 288
            (24 hours at 5-minute intervals).
        freq (str): Pandas frequency string for time intervals. Defaults to "5min"
            (5-minute intervals).

        sim_stop (datetime): Calculated simulation end timestamp.
            Automatically computed from start_time, sim_length, and freq.
            Updated whenever simulation parameters change.

    Example:
        >>> # Default 24-hour simulation at 5-minute intervals
        >>> time_mgr = WECGridTime()
        >>> print(f"Duration: {time_mgr.num_steps} steps")
        >>> print(f"Interval: {time_mgr.freq}")
        Duration: 288 steps
        Interval: 5T

        >>> # Custom simulation period
        >>> from datetime import datetime
        >>> time_mgr = WECGridTime(
        ...     start_time=datetime(2023, 6, 15, 0, 0, 0),
        ...     sim_length=144,  # 12 hours
        ...     freq="5min"
        ... )
        >>> print(f"Start: {time_mgr.start_time}")
    """

    start_time: datetime = field(
        default_factory=lambda: datetime.now().replace(
            hour=0, minute=0, second=0, microsecond=0
        )
    )
    # sim_length: int = 288
    num_steps: int = 288
    freq: str = "5min"
    delta_time: int = 300  # seconds

    def __post_init__(self):
        """Initialize derived simulation parameters after dataclass construction."""
        self._update_sim_stop()

    def _update_sim_stop(self):
        """Update simulation stop time based on current parameters."""
        self.sim_stop = self.snapshots[-1] if self.num_steps > 0 else self.start_time

    @property
    def snapshots(self) -> pd.DatetimeIndex:
        """Generate time snapshots for simulation time series.

        Returns:
            pd.DatetimeIndex: Simulation timestamps from start_time with length sim_length.
        """
        return pd.date_range(
            start=self.start_time,
            periods=self.num_steps,
            freq=self.freq,
        )

    def update(
        self, *, start_time: datetime = None, num_steps: int = None, freq: str = None
    ):
        """Update simulation time parameters with automatic recalculation.

        Args:
            start_time (datetime, optional): New simulation start timestamp.
            num_steps (int, optional): New number of simulation time steps.
            freq (str, optional): New pandas frequency string for time intervals.
        """
        if start_time is not None:
            self.start_time = start_time
        if num_steps is not None:
            self.num_steps = num_steps
        if freq is not None:
            self.freq = freq
        self._update_sim_stop()

    def set_end_time(self, end_time: datetime):
        """Set simulation duration by specifying the desired end time.

        Args:
            end_time (datetime): Desired simulation end timestamp.
                Must be later than current start_time.

        Raises:
            ValueError: If end_time is earlier than or equal to start_time.
        """
        self.num_steps = len(
            pd.date_range(start=self.start_time, end=end_time, freq=self.freq)
        )
        self.sim_stop = end_time

    def __repr__(self) -> str:
        """Return concise string representation of the time configuration."""
        return (
            f"WECGridTime:\n"
            f"├─ start_time: {self.start_time}\n"
            f"├─ sim_stop:   {self.sim_stop}\n"
            f"├─ num_steps: {self.num_steps} steps\n"
            f"└─ frequency:  {self.freq}"
        )

snapshots property

Generate time snapshots for simulation time series.

Returns:

Type Description
DatetimeIndex

pd.DatetimeIndex: Simulation timestamps from start_time with length sim_length.

set_end_time(end_time)

Set simulation duration by specifying the desired end time.

Parameters:

Name Type Description Default
end_time datetime

Desired simulation end timestamp. Must be later than current start_time.

required

Raises:

Type Description
ValueError

If end_time is earlier than or equal to start_time.

Source code in src/wecgrid/util/time.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def set_end_time(self, end_time: datetime):
    """Set simulation duration by specifying the desired end time.

    Args:
        end_time (datetime): Desired simulation end timestamp.
            Must be later than current start_time.

    Raises:
        ValueError: If end_time is earlier than or equal to start_time.
    """
    self.num_steps = len(
        pd.date_range(start=self.start_time, end=end_time, freq=self.freq)
    )
    self.sim_stop = end_time

update(*, start_time=None, num_steps=None, freq=None)

Update simulation time parameters with automatic recalculation.

Parameters:

Name Type Description Default
start_time datetime

New simulation start timestamp.

None
num_steps int

New number of simulation time steps.

None
freq str

New pandas frequency string for time intervals.

None
Source code in src/wecgrid/util/time.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def update(
    self, *, start_time: datetime = None, num_steps: int = None, freq: str = None
):
    """Update simulation time parameters with automatic recalculation.

    Args:
        start_time (datetime, optional): New simulation start timestamp.
        num_steps (int, optional): New number of simulation time steps.
        freq (str, optional): New pandas frequency string for time intervals.
    """
    if start_time is not None:
        self.start_time = start_time
    if num_steps is not None:
        self.num_steps = num_steps
    if freq is not None:
        self.freq = freq
    self._update_sim_stop()

Plot

A focused plotting interface for WEC-GRID simulation visualization.

This class provides methods to plot time-series data for various grid components, create single-line diagrams, and compare results from different modeling backends (PSS®E and PyPSA). Can work with live engine data or standalone GridState objects from database pulls.

Source code in src/wecgrid/util/plot.py
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
class WECGridPlot:
    """
    A focused plotting interface for WEC-GRID simulation visualization.

    This class provides methods to plot time-series data for various grid
    components, create single-line diagrams, and compare results from different
    modeling backends (PSS®E and PyPSA). Can work with live engine data or
    standalone GridState objects from database pulls.
    """

    def __init__(self, engine: Any = None):
        """
        Initialize the plotter with a WEC-GRID Engine instance or for standalone use.

        Args:
            engine: The WEC-GRID Engine containing simulation data. Can be None for
                   standalone usage with GridState objects.
        """
        self.engine = engine
        self._standalone_grids = {}  # Store GridState objects for standalone usage

    def add_grid(self, software: str, grid_state):
        """Add a GridState object for standalone plotting.

        Allows plotting of simulation data without requiring the original modeling
        software to be installed. Useful for analyzing database-pulled simulations.

        Args:
            software (str): Software identifier ("psse", "pypsa", etc.)
            grid_state: GridState object containing simulation data

        Example:
            >>> plotter = WECGridPlot()
            >>> psse_grid = engine.database.pull_sim(grid_sim_id=1, software='psse')
            >>> plotter.add_grid('psse', psse_grid)
            >>> plotter.gen(software='psse', parameter='p')
        """
        self._standalone_grids[software] = grid_state

    @classmethod
    def from_database(cls, database, grid_sim_id: int, software: str = None):
        """Create a standalone plotter from database simulation data.

        Convenience method to create a plotter with GridState data pulled from
        the database, without requiring the original modeling software.

        Args:
            database: WECGridDB instance
            grid_sim_id (int): Grid simulation ID to retrieve
            software (str, optional): Software backend ("psse" or "pypsa").
                If None, auto-detects from database.

        Returns:
            WECGridPlot: Plotter instance with GridState data loaded

        Example:
            >>> plotter = WECGridPlot.from_database(
            ...     engine.database, grid_sim_id=1, software='psse'
            ... )
            >>> plotter.gen(software='psse', parameter='p')
        """
        plotter = cls(engine=None)
        grid_state = database.pull_sim(grid_sim_id, software)
        plotter.add_grid(grid_state.software, grid_state)
        return plotter

    def _get_grid_obj(self, software: str):
        """Get GridState object from engine or standalone storage.

        Args:
            software (str): Software identifier ("psse", "pypsa", etc.)

        Returns:
            GridState object or None if not found
        """
        # First try standalone grids
        if software in self._standalone_grids:
            return self._standalone_grids[software]

        # Then try engine
        if self.engine and hasattr(self.engine, software):
            modeler = getattr(self.engine, software)
            if modeler and hasattr(modeler, "grid"):
                return modeler.grid

        return None

    def _plot_time_series(
        self,
        software: str,
        component_type: str,
        parameter: str,
        components: Optional[List[str]] = None,
        title: str = "",
        ax: Optional[plt.Axes] = None,
        ylabel: str = "",
        xlabel: str = "Time",
    ):
        """Internal helper to plot time-series data for any component.

        Args:
            software (str):
                Modeling backend identifier (e.g., ``"psse"`` or ``"pypsa"``).
                Can reference engine modelers or standalone GridState objects.
            component_type (str):
                Grid component group to plot (``"gen"``, ``"bus"``,
                ``"load"``, ``"line"``, etc.).
            parameter (str):
                Name of the time-series parameter to visualize. This must
                exist within ``<component_type>_t``.
            components (Optional[List[str]]):
                Specific components to include. If ``None``, all available
                components are plotted.
            title (str):
                Plot title. When empty, a default title is generated from the
                ``software``, ``component_type`` and ``parameter`` values.
            ax (Optional[plt.Axes]):
                Matplotlib axes on which to draw the plot. A new figure and
                axes are created when ``None``.
            ylabel (str):
                Label for the y-axis. Defaults to ``parameter`` when empty.
            xlabel (str):
                Label for the x-axis. Defaults to ``"Time"``.

        Returns:
            Tuple[plt.Figure, plt.Axes] | Tuple[None, None]:
                A tuple containing the Matplotlib ``Figure`` and ``Axes`` for
                the generated plot. Returns ``(None, None)`` when the required
                data are missing or none of the requested components are
                available.
        """
        grid_obj = self._get_grid_obj(software)

        if grid_obj is None:
            print(
                f"Error: No grid data found for software '{software}'. "
                f"Use add_grid() for standalone GridState objects or ensure "
                f"the engine has '{software}' loaded."
            )
            return None, None
        component_data_t = getattr(grid_obj, f"{component_type}_t", None)

        if component_data_t is None or parameter not in component_data_t:
            print(
                f"Error: Parameter '{parameter}' not found for '{component_type}' in '{software}'."
            )
            return None, None

        data = component_data_t[parameter]

        if components:
            # Ensure components is a list
            if isinstance(components, str):
                components = [components]

            # Filter columns that exist in the dataframe
            available_components = [c for c in components if c in data.columns]
            if not available_components:
                print(
                    f"Warning: None of the specified components {components} found in data for {parameter}."
                )
                return None, None
            data = data[available_components]

        if ax is None:
            fig, ax = plt.subplots(figsize=(12, 6))
        else:
            fig = ax.get_figure()

        data.plot(ax=ax, legend=True)
        ax.set_title(
            title
            or f"{software.upper()}: {component_type.capitalize()} {parameter.capitalize()}"
        )
        ax.set_ylabel(ylabel or parameter)
        ax.set_xlabel(xlabel)
        ax.grid(True)

        # Truncate legend if it's too long
        if len(data.columns) > 10:
            ax.legend().set_visible(False)

        return fig, ax

    def gen(
        self,
        software: str = "pypsa",
        parameter: str = "p",
        gen: Optional[List[str]] = None,
    ):
        """Plot a generator parameter.

        Args:
            software: The modeling software to use (``"psse"`` or ``"pypsa"``).
            parameter: Generator parameter to plot (e.g., ``"p"``, ``"q"``).
            gen: A list of generator names to plot. If ``None``, all generators are shown.

        Returns:
            tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
            figure and axes for further customization.
        """
        if parameter == "p":
            title = f"{software.upper()}: Generator Active Power"
            ylabel = "Active Power [pu]"
        elif parameter == "q":
            title = f"{software.upper()}: Generator Reactive Power"
            ylabel = "Reactive Power [pu]"
        else:
            print("not a valid parameter")
            return None, None

        fig, ax = self._plot_time_series(
            software, "gen", parameter, components=gen, title=title, ylabel=ylabel
        )
        plt.show()
        return fig, ax

    def bus(
        self,
        software: str = "pypsa",
        parameter: str = "p",
        bus: Optional[List[str]] = None,
    ):
        """Plot a bus parameter.

        Args:
            software: The modeling software to use (``"psse"`` or ``"pypsa"``).
            parameter: Bus parameter to plot (e.g., ``"v_mag"``, ``"angle_deg"``).
            bus: A list of bus names to plot. If ``None``, all buses are shown.

        Returns:
            tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
            figure and axes for further customization.
        """
        if parameter == "p":
            title = f"{software.upper()}: Bus Active Power (net)"
            ylabel = "Active Power [pu]"
        elif parameter == "q":
            title = f"{software.upper()}: Bus Reactive Power (net)"
            ylabel = "Reactive Power [pu]"
        elif parameter == "v_mag":
            title = f"{software.upper()}: Bus Voltage Magnitude"
            ylabel = "Voltage (pu)"
        elif parameter == "angle_deg":
            title = f"{software.upper()}: Bus Voltage Angle"
            ylabel = "Angle (degrees)"
        else:
            print("not a valid parameter")
            return None, None

        fig, ax = self._plot_time_series(
            software, "bus", parameter, components=bus, title=title, ylabel=ylabel
        )
        plt.show()
        return fig, ax

    def load(
        self,
        software: str = "pypsa",
        parameter: str = "p",
        load: Optional[List[str]] = None,
    ):
        """Plot a load parameter.

        Args:
            software: The modeling software to use (``"psse"`` or ``"pypsa"``).
            parameter: Load parameter to plot (e.g., ``"p"``, ``"q"``).
            load: A list of load names to plot. If ``None``, all loads are shown.

        Returns:
            tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
            figure and axes for further customization.
        """
        if parameter == "p":
            title = f"{software.upper()}: Load Active Power"
            ylabel = "Active Power [pu]"
        elif parameter == "q":
            title = f"{software.upper()}: Load Reactive Power"
            ylabel = "Reactive Power [pu]"
        else:
            print("not a valid parameter")
            return None, None

        fig, ax = self._plot_time_series(
            software, "load", parameter, components=load, title=title, ylabel=ylabel
        )
        plt.show()
        return fig, ax

    def line(
        self,
        software: str = "pypsa",
        parameter: str = "line_pct",
        line: Optional[List[str]] = None,
    ):
        """Plot a line parameter.

        Args:
            software: The modeling software to use (``"psse"`` or ``"pypsa"``).
            parameter: Line parameter to plot. Defaults to ``"line_pct"``.
            line: A list of line names to plot. If ``None``, all lines are shown.

        Returns:
            tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
            figure and axes for further customization.
        """
        if parameter == "line_pct":
            title = f"{software.upper()}: Line Percent Loading"
            ylabel = "Percent Loading [%]"
        else:
            print("not a valid parameter")
            return None, None

        fig, ax = self._plot_time_series(
            software, "line", parameter, components=line, title=title, ylabel=ylabel
        )
        plt.show()
        return fig, ax

    def sld(
        self, software: str = "pypsa", figsize=(14, 10), title=None, save_path=None
    ):
        """Generate single-line diagram using GridState data.

        Creates a single-line diagram visualization using the standardized GridState
        component data. Works with both PSS®E and PyPSA backends by using the unified
        data schema from GridState snapshots.

        Args:
            software (str): Backend software ("psse" or "pypsa")
            figsize (tuple): Figure size as (width, height)
            title (str, optional): Custom title for the diagram
            save_path (str, optional): Path to save the figure
            show (bool): Whether to display the figure (default: False)

        Returns:
            matplotlib.figure.Figure: The generated SLD figure

        Notes:
            Uses NetworkX for automatic layout calculation since GridState doesn't
            include geographical bus positions. The diagram includes:

            - Buses: Colored rectangles based on type (Slack=red, PV=green, PQ=gray)
            - Lines: Black dashed lines connecting buses
            - Generators: Circles above buses with generators
            - Loads: Downward arrows on buses with loads

            Limitations:
            - No transformer identification (would need additional data)
            - Layout is algorithmic, not geographical
            - No shunt devices (not in GridState schema)
        """
        # Get the appropriate grid object
        grid_obj = self._get_grid_obj(software)

        if grid_obj is None:
            raise ValueError(
                f"No grid data found for software '{software}'. "
                f"Use add_grid() for standalone GridState objects or ensure "
                f"the engine has '{software}' loaded."
            )

        # Extract data from GridState
        bus_df = grid_obj.bus.copy()
        line_df = grid_obj.line.copy()
        gen_df = grid_obj.gen.copy()
        load_df = grid_obj.load.copy()

        if bus_df.empty:
            raise ValueError("No bus data available for SLD generation")

        print(f"SLD Data Summary:")
        print(f"  Buses: {len(bus_df)}")
        print(f"  Lines: {len(line_df)}")
        print(f"  Generators: {len(gen_df)}")
        print(f"  Loads: {len(load_df)}")

        # Check if required columns exist
        if "bus" not in bus_df.columns and bus_df.index.name != "bus":
            print(f"  ERROR: 'bus' column missing from bus DataFrame")
            print(f"  Available columns: {list(bus_df.columns)}")
            print(f"  Index name: {bus_df.index.name}")
            print(f"  Bus DataFrame head:\n{bus_df.head()}")

            # Check if bus numbers are in the index
            if bus_df.index.name == "bus" or "bus" in str(bus_df.index.name).lower():
                print("  Bus numbers found in DataFrame index, will use index values")
            else:
                raise ValueError("Bus DataFrame missing required 'bus' column or index")

        # Create network graph for layout
        G = nx.Graph()

        # Add buses as nodes - handle index vs column
        if "bus" in bus_df.columns:
            bus_numbers = bus_df["bus"]
        else:
            # Bus numbers are in the index
            bus_numbers = bus_df.index

        for bus_num in bus_numbers:
            G.add_node(bus_num)

        # Add lines as edges - handle potential column name variations
        ibus_col = "ibus" if "ibus" in line_df.columns else "from_bus"
        jbus_col = "jbus" if "jbus" in line_df.columns else "to_bus"
        status_col = "status" if "status" in line_df.columns else None

        for _, line_row in line_df.iterrows():
            if status_col is None or line_row[status_col] == 1:  # Only active lines
                if ibus_col in line_df.columns and jbus_col in line_df.columns:
                    G.add_edge(line_row[ibus_col], line_row[jbus_col])

        # Calculate layout using NetworkX
        try:
            pos = nx.kamada_kawai_layout(G)
        except:
            # Fallback to spring layout if kamada_kawai fails
            pos = nx.spring_layout(G, seed=42)

        # Normalize positions for better visualization
        if pos:
            pos_values = np.array(list(pos.values()))
            x_vals, y_vals = pos_values[:, 0], pos_values[:, 1]
            x_min, x_max = np.min(x_vals), np.max(x_vals)
            y_min, y_max = np.min(y_vals), np.max(y_vals)

            # Normalize to reasonable plotting range
            for node in pos:
                pos[node] = (
                    2 * (pos[node][0] - x_min) / (x_max - x_min) - 1,
                    1.5 * (pos[node][1] - y_min) / (y_max - y_min) - 0.5,
                )

        # Create figure
        fig, ax = plt.subplots(figsize=figsize)

        # Bus visualization parameters
        node_width, node_height = 0.12, 0.04

        # Bus type color mapping
        bus_colors = {
            "Slack": "#FF4500",  # Red-orange
            "PV": "#32CD32",  # Green
            "PQ": "#A9A9A9",  # Gray
        }

        # Draw transmission lines first (so they appear behind buses)
        for _, line_row in line_df.iterrows():
            if status_col is None or line_row[status_col] == 1:  # Only active lines
                if ibus_col in line_df.columns and jbus_col in line_df.columns:
                    ibus, jbus = line_row[ibus_col], line_row[jbus_col]
                    if ibus in pos and jbus in pos:
                        x1, y1 = pos[ibus]
                        x2, y2 = pos[jbus]
                        ax.plot([x1, x2], [y1, y2], "k-", linewidth=1.5, alpha=0.7)

        # Identify buses with generators and loads - handle column variations
        gen_bus_col = "bus" if "bus" in gen_df.columns else "connected_bus"
        load_bus_col = "bus" if "bus" in load_df.columns else "connected_bus"
        gen_status_col = "status" if "status" in gen_df.columns else None
        load_status_col = "status" if "status" in load_df.columns else None

        # Get active generators and loads
        if gen_status_col:
            gen_buses = set(gen_df[gen_df[gen_status_col] == 1][gen_bus_col])
        else:
            gen_buses = set(gen_df[gen_bus_col])

        if load_status_col:
            load_buses = set(load_df[load_df[load_status_col] == 1][load_bus_col])
        else:
            load_buses = set(load_df[load_bus_col])

        # Draw buses
        bus_type_col = "type" if "type" in bus_df.columns else "control"
        # Determine bus column name
        if "bus" in bus_df.columns:
            bus_col = "bus"
        else:
            # Bus numbers are in the index
            bus_col = None

        for _, bus_row in bus_df.iterrows():
            if bus_col:
                bus_num = bus_row[bus_col]
            else:
                bus_num = bus_row.name  # Use index value
            if bus_num not in pos:
                continue

            x, y = pos[bus_num]
            bus_type = bus_row[bus_type_col] if bus_type_col in bus_df.columns else "PQ"
            bus_color = bus_colors.get(bus_type, "#D3D3D3")  # Default light gray

            # Draw bus rectangle
            rect = Rectangle(
                (x - node_width / 2, y - node_height / 2),
                node_width,
                node_height,
                linewidth=1.5,
                edgecolor="black",
                facecolor=bus_color,
            )
            ax.add_patch(rect)

            # Add bus number label
            ax.text(
                x,
                y,
                str(bus_num),
                fontsize=8,
                fontweight="bold",
                ha="center",
                va="center",
            )

            # Draw generators (circles above bus)
            if bus_num in gen_buses:
                gen_x = x
                gen_y = y + node_height / 2 + 0.05
                gen_size = 0.02
                # Connection line from bus to generator
                ax.plot(
                    [x, gen_x],
                    [y + node_height / 2, gen_y - gen_size],
                    color="black",
                    linewidth=2,
                )
                # Generator circle
                ax.add_patch(
                    Circle(
                        (gen_x, gen_y),
                        gen_size,
                        color="none",
                        ec="black",
                        linewidth=1.5,
                    )
                )
                # Generator symbol 'G'
                ax.text(
                    gen_x,
                    gen_y,
                    "G",
                    fontsize=6,
                    fontweight="bold",
                    ha="center",
                    va="center",
                )

            # Draw loads (downward arrows)
            if bus_num in load_buses:
                load_x = x + node_width / 2 - 0.02
                load_y = y - node_height / 2
                ax.arrow(
                    load_x,
                    load_y,
                    0,
                    -0.04,
                    head_width=0.015,
                    head_length=0.015,
                    fc="black",
                    ec="black",
                )

        # Set up the plot
        ax.set_aspect("equal", adjustable="datalim")
        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_frame_on(False)

        # Set title
        if title is None:
            case_name = getattr(self.engine, "case_name", "Power System")
            title = f"Single-Line Diagram - {case_name} ({software.upper()})"
        ax.set_title(title, fontsize=14, fontweight="bold")

        # Create legend
        legend_elements = [
            Line2D(
                [0],
                [0],
                marker="o",
                color="black",
                markersize=8,
                label="Generator",
                markerfacecolor="none",
                markeredgecolor="black",
                linewidth=0,
            ),
            Line2D(
                [0],
                [0],
                marker="^",
                color="black",
                markersize=8,
                label="Load",
                markerfacecolor="black",
                linewidth=0,
            ),
            Line2D(
                [0],
                [0],
                marker="s",
                color="#FF4500",
                markersize=8,
                label="Slack Bus",
                markerfacecolor="#FF4500",
                linewidth=0,
            ),
            Line2D(
                [0],
                [0],
                marker="s",
                color="#32CD32",
                markersize=8,
                label="PV Bus",
                markerfacecolor="#32CD32",
                linewidth=0,
            ),
            Line2D(
                [0],
                [0],
                marker="s",
                color="#A9A9A9",
                markersize=8,
                label="PQ Bus",
                markerfacecolor="#A9A9A9",
                linewidth=0,
            ),
            Line2D([0], [0], color="black", linewidth=1.5, label="Transmission Line"),
        ]

        ax.legend(
            handles=legend_elements,
            loc="upper left",
            fontsize=10,
            frameon=True,
            edgecolor="black",
            title="Legend",
        )

        # Save if requested
        if save_path:
            plt.savefig(save_path, dpi=300, bbox_inches="tight")
            print(f"SLD saved to: {save_path}")

        plt.tight_layout()
        plt.show()
        # return fig, ax

    def wec_analysis(self, farms: Optional[List[str]] = None, software: str = "pypsa"):
        """
        Creates a 1x3 figure analyzing WEC farm performance.

        Args:
            farms (Optional[List[str]]): A list of farm names to analyze. If None, all farms are analyzed.
            software (str): The modeling software to use. Defaults to 'pypsa'.
        """
        grid_obj = self._get_grid_obj(software)

        if grid_obj is None:
            print(
                f"Error: No grid data found for software '{software}'. "
                f"Use add_grid() for standalone GridState objects or ensure "
                f"the engine has '{software}' loaded."
            )
            return

        if not self.engine or not self.engine.wec_farms:
            print(
                f"Error: No WEC farms are defined in the engine. WEC analysis requires "
                f"engine with WEC farm data."
            )
            return

        target_farms = self.engine.wec_farms
        if farms:
            target_farms = [f for f in self.engine.wec_farms if f.farm_name in farms]

        if not target_farms:
            print("No matching WEC farms found.")
            return

        fig, axes = plt.subplots(1, 3, figsize=(20, 6))
        fig.suptitle("WEC Farm Analysis", fontsize=16)

        # 1. Active Power for each WEC farm
        wec_gen_names = [f.gen_name for f in target_farms]
        wec_power_df = grid_obj.gen_t.p[wec_gen_names]
        wec_power_df.plot(ax=axes[0])
        axes[0].set_title("WEC Farm Active Power Output")
        axes[0].set_ylabel("Active Power (pu)")
        axes[0].grid(True)

        # 2. WEC Farm total Contribution Percentage
        total_wec_power = wec_power_df.sum(axis=1)
        total_load_power = grid_obj.load_t.p.sum(axis=1)
        contribution_pct = (total_wec_power / total_load_power * 100).dropna()
        contribution_pct.plot(ax=axes[1])
        axes[1].set_title("WEC Power Contribution")
        axes[1].set_ylabel("Contribution to Total Load (%)")
        axes[1].grid(True)

        # 3. WEC-Farm Bus Voltage
        wec_bus_names = [f"Bus_{f.bus_location}" for f in target_farms]
        wec_bus_voltages = grid_obj.bus_t.v_mag[wec_bus_names]
        wec_bus_voltages.plot(ax=axes[2])
        axes[2].set_title("WEC Farm Bus Voltage")
        axes[2].set_ylabel("Voltage (pu)")
        axes[2].grid(True)

        plt.tight_layout(rect=[0, 0, 1, 0.96])
        plt.show()

    def compare_modelers(self, grid_component: str, name: List[str], parameter: str):
        """
        Compares a parameter for a specific component between PSS®E and PyPSA.

        Works with both live engine data and standalone GridState objects added
        via add_grid().

        Args:
            grid_component (str): The type of component ('bus', 'gen', 'load', 'line').
            name (List[str]): The name(s) of the component(s) to compare.
            parameter (str): The parameter to compare.
        """
        # Check for available software data
        available_software = []
        for software in ["psse", "pypsa"]:
            if self._get_grid_obj(software) is not None:
                available_software.append(software)

        if len(available_software) < 2:
            print(
                f"Error: Need at least 2 software backends for comparison. "
                f"Available: {available_software}. Use add_grid() to add GridState objects "
                f"or ensure both 'psse' and 'pypsa' are loaded in the engine."
            )
            return

        fig, ax = plt.subplots(figsize=(12, 6))

        for software in available_software:
            grid_obj = self._get_grid_obj(software)
            component_data_t = getattr(grid_obj, f"{grid_component}_t", None)

            if component_data_t is None or parameter not in component_data_t:
                print(
                    f"Error: Parameter '{parameter}' not found for '{grid_component}' in '{software}'."
                )
                continue

            data = component_data_t[parameter]

            # Ensure name is a list
            if isinstance(name, str):
                name = [name]

            # Try to find components by name first, then by ID
            available_components = []
            component_df = getattr(grid_obj, grid_component, None)

            for comp_name in name:
                # First try direct column match (for live engine data)
                if comp_name in data.columns:
                    available_components.append(comp_name)
                # Then try to find by name->ID mapping (for pulled GridState data)
                elif component_df is not None:
                    # Try to find the component ID by name
                    name_col = f"{grid_component}_name"
                    id_col = grid_component

                    if (
                        name_col in component_df.columns
                        and id_col in component_df.columns
                    ):
                        # Find the ID for this name
                        matching_rows = component_df[
                            component_df[name_col] == comp_name
                        ]
                        if not matching_rows.empty:
                            comp_id = matching_rows.iloc[0][id_col]
                            # Check if this ID exists as a column in the time series
                            if comp_id in data.columns:
                                available_components.append(comp_id)
                            elif str(comp_id) in data.columns:
                                available_components.append(str(comp_id))

                    # Also try treating the name as an ID directly
                    elif comp_name in data.columns:
                        available_components.append(comp_name)
                    elif str(comp_name) in data.columns:
                        available_components.append(str(comp_name))

            if not available_components:
                print(f"Warning: Component(s) {name} not found in {software} data.")
                print(
                    f"  Available columns: {list(data.columns)[:10]}..."
                )  # Show first 10 columns
                continue

            df_to_plot = data[available_components].copy()

            # Convert index to time-of-day format (ignore dates, keep time)
            if hasattr(df_to_plot.index, "time"):
                # Extract time-of-day and create a new index with step numbers
                time_of_day = df_to_plot.index.time
                # Convert to datetime with common base date and time info
                import datetime

                base_date = datetime.date(2000, 1, 1)  # Common base date
                new_index = [
                    datetime.datetime.combine(base_date, t) for t in time_of_day
                ]
                df_to_plot.index = pd.DatetimeIndex(new_index)
            elif hasattr(df_to_plot.index, "hour"):
                # If already datetime, normalize to same base date
                import datetime

                base_date = datetime.date(2000, 1, 1)
                new_index = []
                for dt in df_to_plot.index:
                    time_part = dt.time()
                    new_dt = datetime.datetime.combine(base_date, time_part)
                    new_index.append(new_dt)
                df_to_plot.index = pd.DatetimeIndex(new_index)
            else:
                # If index is not datetime, use step numbers
                df_to_plot.index = range(len(df_to_plot))

            # Create meaningful column names for the legend
            renamed_cols = []
            for col in df_to_plot.columns:
                # Try to get the component name from the component DataFrame
                if component_df is not None:
                    name_col = f"{grid_component}_name"
                    id_col = grid_component

                    if (
                        name_col in component_df.columns
                        and id_col in component_df.columns
                    ):
                        # Find the name for this ID
                        if id_col in component_df.columns:
                            matching_rows = component_df[component_df[id_col] == col]
                            if (
                                not matching_rows.empty
                                and name_col in component_df.columns
                            ):
                                comp_name = matching_rows.iloc[0][name_col]
                                renamed_cols.append(f"{comp_name}_{software.upper()}")
                            else:
                                renamed_cols.append(f"{col}_{software.upper()}")
                        else:
                            renamed_cols.append(f"{col}_{software.upper()}")
                    else:
                        renamed_cols.append(f"{col}_{software.upper()}")
                else:
                    renamed_cols.append(f"{col}_{software.upper()}")

            # Rename columns for legend
            df_to_plot.columns = renamed_cols

            df_to_plot.plot(ax=ax, linestyle="--" if software == "psse" else "-")

        ax.set_title(
            f"Comparison for {grid_component.capitalize()} {name}: {parameter.capitalize()}"
        )
        ax.set_ylabel(parameter)
        ax.set_xlabel("Time of Day")
        ax.grid(True)
        ax.legend()

        # Format x-axis for better time display if datetime index
        try:
            if hasattr(ax.get_lines()[0].get_xdata(), "__iter__"):
                # Check if we have datetime data
                first_data = None
                for line in ax.get_lines():
                    if len(line.get_xdata()) > 0:
                        first_data = line.get_xdata()[0]
                        break

                if first_data is not None and hasattr(first_data, "hour"):
                    # Format x-axis to show time nicely
                    ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
                    ax.xaxis.set_major_locator(mdates.HourLocator(interval=2))
                    plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
        except:
            pass  # Fall back to default formatting if anything goes wrong

        plt.tight_layout()
        plt.show()

add_grid(software, grid_state)

Add a GridState object for standalone plotting.

Allows plotting of simulation data without requiring the original modeling software to be installed. Useful for analyzing database-pulled simulations.

Parameters:

Name Type Description Default
software str

Software identifier ("psse", "pypsa", etc.)

required
grid_state

GridState object containing simulation data

required
Example

plotter = WECGridPlot() psse_grid = engine.database.pull_sim(grid_sim_id=1, software='psse') plotter.add_grid('psse', psse_grid) plotter.gen(software='psse', parameter='p')

Source code in src/wecgrid/util/plot.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def add_grid(self, software: str, grid_state):
    """Add a GridState object for standalone plotting.

    Allows plotting of simulation data without requiring the original modeling
    software to be installed. Useful for analyzing database-pulled simulations.

    Args:
        software (str): Software identifier ("psse", "pypsa", etc.)
        grid_state: GridState object containing simulation data

    Example:
        >>> plotter = WECGridPlot()
        >>> psse_grid = engine.database.pull_sim(grid_sim_id=1, software='psse')
        >>> plotter.add_grid('psse', psse_grid)
        >>> plotter.gen(software='psse', parameter='p')
    """
    self._standalone_grids[software] = grid_state

bus(software='pypsa', parameter='p', bus=None)

Plot a bus parameter.

Parameters:

Name Type Description Default
software str

The modeling software to use ("psse" or "pypsa").

'pypsa'
parameter str

Bus parameter to plot (e.g., "v_mag", "angle_deg").

'p'
bus Optional[List[str]]

A list of bus names to plot. If None, all buses are shown.

None

Returns:

Type Description

tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed

figure and axes for further customization.

Source code in src/wecgrid/util/plot.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def bus(
    self,
    software: str = "pypsa",
    parameter: str = "p",
    bus: Optional[List[str]] = None,
):
    """Plot a bus parameter.

    Args:
        software: The modeling software to use (``"psse"`` or ``"pypsa"``).
        parameter: Bus parameter to plot (e.g., ``"v_mag"``, ``"angle_deg"``).
        bus: A list of bus names to plot. If ``None``, all buses are shown.

    Returns:
        tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
        figure and axes for further customization.
    """
    if parameter == "p":
        title = f"{software.upper()}: Bus Active Power (net)"
        ylabel = "Active Power [pu]"
    elif parameter == "q":
        title = f"{software.upper()}: Bus Reactive Power (net)"
        ylabel = "Reactive Power [pu]"
    elif parameter == "v_mag":
        title = f"{software.upper()}: Bus Voltage Magnitude"
        ylabel = "Voltage (pu)"
    elif parameter == "angle_deg":
        title = f"{software.upper()}: Bus Voltage Angle"
        ylabel = "Angle (degrees)"
    else:
        print("not a valid parameter")
        return None, None

    fig, ax = self._plot_time_series(
        software, "bus", parameter, components=bus, title=title, ylabel=ylabel
    )
    plt.show()
    return fig, ax

compare_modelers(grid_component, name, parameter)

Compares a parameter for a specific component between PSS®E and PyPSA.

Works with both live engine data and standalone GridState objects added via add_grid().

Parameters:

Name Type Description Default
grid_component str

The type of component ('bus', 'gen', 'load', 'line').

required
name List[str]

The name(s) of the component(s) to compare.

required
parameter str

The parameter to compare.

required
Source code in src/wecgrid/util/plot.py
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
def compare_modelers(self, grid_component: str, name: List[str], parameter: str):
    """
    Compares a parameter for a specific component between PSS®E and PyPSA.

    Works with both live engine data and standalone GridState objects added
    via add_grid().

    Args:
        grid_component (str): The type of component ('bus', 'gen', 'load', 'line').
        name (List[str]): The name(s) of the component(s) to compare.
        parameter (str): The parameter to compare.
    """
    # Check for available software data
    available_software = []
    for software in ["psse", "pypsa"]:
        if self._get_grid_obj(software) is not None:
            available_software.append(software)

    if len(available_software) < 2:
        print(
            f"Error: Need at least 2 software backends for comparison. "
            f"Available: {available_software}. Use add_grid() to add GridState objects "
            f"or ensure both 'psse' and 'pypsa' are loaded in the engine."
        )
        return

    fig, ax = plt.subplots(figsize=(12, 6))

    for software in available_software:
        grid_obj = self._get_grid_obj(software)
        component_data_t = getattr(grid_obj, f"{grid_component}_t", None)

        if component_data_t is None or parameter not in component_data_t:
            print(
                f"Error: Parameter '{parameter}' not found for '{grid_component}' in '{software}'."
            )
            continue

        data = component_data_t[parameter]

        # Ensure name is a list
        if isinstance(name, str):
            name = [name]

        # Try to find components by name first, then by ID
        available_components = []
        component_df = getattr(grid_obj, grid_component, None)

        for comp_name in name:
            # First try direct column match (for live engine data)
            if comp_name in data.columns:
                available_components.append(comp_name)
            # Then try to find by name->ID mapping (for pulled GridState data)
            elif component_df is not None:
                # Try to find the component ID by name
                name_col = f"{grid_component}_name"
                id_col = grid_component

                if (
                    name_col in component_df.columns
                    and id_col in component_df.columns
                ):
                    # Find the ID for this name
                    matching_rows = component_df[
                        component_df[name_col] == comp_name
                    ]
                    if not matching_rows.empty:
                        comp_id = matching_rows.iloc[0][id_col]
                        # Check if this ID exists as a column in the time series
                        if comp_id in data.columns:
                            available_components.append(comp_id)
                        elif str(comp_id) in data.columns:
                            available_components.append(str(comp_id))

                # Also try treating the name as an ID directly
                elif comp_name in data.columns:
                    available_components.append(comp_name)
                elif str(comp_name) in data.columns:
                    available_components.append(str(comp_name))

        if not available_components:
            print(f"Warning: Component(s) {name} not found in {software} data.")
            print(
                f"  Available columns: {list(data.columns)[:10]}..."
            )  # Show first 10 columns
            continue

        df_to_plot = data[available_components].copy()

        # Convert index to time-of-day format (ignore dates, keep time)
        if hasattr(df_to_plot.index, "time"):
            # Extract time-of-day and create a new index with step numbers
            time_of_day = df_to_plot.index.time
            # Convert to datetime with common base date and time info
            import datetime

            base_date = datetime.date(2000, 1, 1)  # Common base date
            new_index = [
                datetime.datetime.combine(base_date, t) for t in time_of_day
            ]
            df_to_plot.index = pd.DatetimeIndex(new_index)
        elif hasattr(df_to_plot.index, "hour"):
            # If already datetime, normalize to same base date
            import datetime

            base_date = datetime.date(2000, 1, 1)
            new_index = []
            for dt in df_to_plot.index:
                time_part = dt.time()
                new_dt = datetime.datetime.combine(base_date, time_part)
                new_index.append(new_dt)
            df_to_plot.index = pd.DatetimeIndex(new_index)
        else:
            # If index is not datetime, use step numbers
            df_to_plot.index = range(len(df_to_plot))

        # Create meaningful column names for the legend
        renamed_cols = []
        for col in df_to_plot.columns:
            # Try to get the component name from the component DataFrame
            if component_df is not None:
                name_col = f"{grid_component}_name"
                id_col = grid_component

                if (
                    name_col in component_df.columns
                    and id_col in component_df.columns
                ):
                    # Find the name for this ID
                    if id_col in component_df.columns:
                        matching_rows = component_df[component_df[id_col] == col]
                        if (
                            not matching_rows.empty
                            and name_col in component_df.columns
                        ):
                            comp_name = matching_rows.iloc[0][name_col]
                            renamed_cols.append(f"{comp_name}_{software.upper()}")
                        else:
                            renamed_cols.append(f"{col}_{software.upper()}")
                    else:
                        renamed_cols.append(f"{col}_{software.upper()}")
                else:
                    renamed_cols.append(f"{col}_{software.upper()}")
            else:
                renamed_cols.append(f"{col}_{software.upper()}")

        # Rename columns for legend
        df_to_plot.columns = renamed_cols

        df_to_plot.plot(ax=ax, linestyle="--" if software == "psse" else "-")

    ax.set_title(
        f"Comparison for {grid_component.capitalize()} {name}: {parameter.capitalize()}"
    )
    ax.set_ylabel(parameter)
    ax.set_xlabel("Time of Day")
    ax.grid(True)
    ax.legend()

    # Format x-axis for better time display if datetime index
    try:
        if hasattr(ax.get_lines()[0].get_xdata(), "__iter__"):
            # Check if we have datetime data
            first_data = None
            for line in ax.get_lines():
                if len(line.get_xdata()) > 0:
                    first_data = line.get_xdata()[0]
                    break

            if first_data is not None and hasattr(first_data, "hour"):
                # Format x-axis to show time nicely
                ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
                ax.xaxis.set_major_locator(mdates.HourLocator(interval=2))
                plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)
    except:
        pass  # Fall back to default formatting if anything goes wrong

    plt.tight_layout()
    plt.show()

from_database(database, grid_sim_id, software=None) classmethod

Create a standalone plotter from database simulation data.

Convenience method to create a plotter with GridState data pulled from the database, without requiring the original modeling software.

Parameters:

Name Type Description Default
database

WECGridDB instance

required
grid_sim_id int

Grid simulation ID to retrieve

required
software str

Software backend ("psse" or "pypsa"). If None, auto-detects from database.

None

Returns:

Name Type Description
WECGridPlot

Plotter instance with GridState data loaded

Example

plotter = WECGridPlot.from_database( ... engine.database, grid_sim_id=1, software='psse' ... ) plotter.gen(software='psse', parameter='p')

Source code in src/wecgrid/util/plot.py
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
@classmethod
def from_database(cls, database, grid_sim_id: int, software: str = None):
    """Create a standalone plotter from database simulation data.

    Convenience method to create a plotter with GridState data pulled from
    the database, without requiring the original modeling software.

    Args:
        database: WECGridDB instance
        grid_sim_id (int): Grid simulation ID to retrieve
        software (str, optional): Software backend ("psse" or "pypsa").
            If None, auto-detects from database.

    Returns:
        WECGridPlot: Plotter instance with GridState data loaded

    Example:
        >>> plotter = WECGridPlot.from_database(
        ...     engine.database, grid_sim_id=1, software='psse'
        ... )
        >>> plotter.gen(software='psse', parameter='p')
    """
    plotter = cls(engine=None)
    grid_state = database.pull_sim(grid_sim_id, software)
    plotter.add_grid(grid_state.software, grid_state)
    return plotter

gen(software='pypsa', parameter='p', gen=None)

Plot a generator parameter.

Parameters:

Name Type Description Default
software str

The modeling software to use ("psse" or "pypsa").

'pypsa'
parameter str

Generator parameter to plot (e.g., "p", "q").

'p'
gen Optional[List[str]]

A list of generator names to plot. If None, all generators are shown.

None

Returns:

Type Description

tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed

figure and axes for further customization.

Source code in src/wecgrid/util/plot.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def gen(
    self,
    software: str = "pypsa",
    parameter: str = "p",
    gen: Optional[List[str]] = None,
):
    """Plot a generator parameter.

    Args:
        software: The modeling software to use (``"psse"`` or ``"pypsa"``).
        parameter: Generator parameter to plot (e.g., ``"p"``, ``"q"``).
        gen: A list of generator names to plot. If ``None``, all generators are shown.

    Returns:
        tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
        figure and axes for further customization.
    """
    if parameter == "p":
        title = f"{software.upper()}: Generator Active Power"
        ylabel = "Active Power [pu]"
    elif parameter == "q":
        title = f"{software.upper()}: Generator Reactive Power"
        ylabel = "Reactive Power [pu]"
    else:
        print("not a valid parameter")
        return None, None

    fig, ax = self._plot_time_series(
        software, "gen", parameter, components=gen, title=title, ylabel=ylabel
    )
    plt.show()
    return fig, ax

line(software='pypsa', parameter='line_pct', line=None)

Plot a line parameter.

Parameters:

Name Type Description Default
software str

The modeling software to use ("psse" or "pypsa").

'pypsa'
parameter str

Line parameter to plot. Defaults to "line_pct".

'line_pct'
line Optional[List[str]]

A list of line names to plot. If None, all lines are shown.

None

Returns:

Type Description

tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed

figure and axes for further customization.

Source code in src/wecgrid/util/plot.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def line(
    self,
    software: str = "pypsa",
    parameter: str = "line_pct",
    line: Optional[List[str]] = None,
):
    """Plot a line parameter.

    Args:
        software: The modeling software to use (``"psse"`` or ``"pypsa"``).
        parameter: Line parameter to plot. Defaults to ``"line_pct"``.
        line: A list of line names to plot. If ``None``, all lines are shown.

    Returns:
        tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
        figure and axes for further customization.
    """
    if parameter == "line_pct":
        title = f"{software.upper()}: Line Percent Loading"
        ylabel = "Percent Loading [%]"
    else:
        print("not a valid parameter")
        return None, None

    fig, ax = self._plot_time_series(
        software, "line", parameter, components=line, title=title, ylabel=ylabel
    )
    plt.show()
    return fig, ax

load(software='pypsa', parameter='p', load=None)

Plot a load parameter.

Parameters:

Name Type Description Default
software str

The modeling software to use ("psse" or "pypsa").

'pypsa'
parameter str

Load parameter to plot (e.g., "p", "q").

'p'
load Optional[List[str]]

A list of load names to plot. If None, all loads are shown.

None

Returns:

Type Description

tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed

figure and axes for further customization.

Source code in src/wecgrid/util/plot.py
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def load(
    self,
    software: str = "pypsa",
    parameter: str = "p",
    load: Optional[List[str]] = None,
):
    """Plot a load parameter.

    Args:
        software: The modeling software to use (``"psse"`` or ``"pypsa"``).
        parameter: Load parameter to plot (e.g., ``"p"``, ``"q"``).
        load: A list of load names to plot. If ``None``, all loads are shown.

    Returns:
        tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: The displayed
        figure and axes for further customization.
    """
    if parameter == "p":
        title = f"{software.upper()}: Load Active Power"
        ylabel = "Active Power [pu]"
    elif parameter == "q":
        title = f"{software.upper()}: Load Reactive Power"
        ylabel = "Reactive Power [pu]"
    else:
        print("not a valid parameter")
        return None, None

    fig, ax = self._plot_time_series(
        software, "load", parameter, components=load, title=title, ylabel=ylabel
    )
    plt.show()
    return fig, ax

sld(software='pypsa', figsize=(14, 10), title=None, save_path=None)

Generate single-line diagram using GridState data.

Creates a single-line diagram visualization using the standardized GridState component data. Works with both PSS®E and PyPSA backends by using the unified data schema from GridState snapshots.

Parameters:

Name Type Description Default
software str

Backend software ("psse" or "pypsa")

'pypsa'
figsize tuple

Figure size as (width, height)

(14, 10)
title str

Custom title for the diagram

None
save_path str

Path to save the figure

None
show bool

Whether to display the figure (default: False)

required

Returns:

Type Description

matplotlib.figure.Figure: The generated SLD figure

Notes

Uses NetworkX for automatic layout calculation since GridState doesn't include geographical bus positions. The diagram includes:

  • Buses: Colored rectangles based on type (Slack=red, PV=green, PQ=gray)
  • Lines: Black dashed lines connecting buses
  • Generators: Circles above buses with generators
  • Loads: Downward arrows on buses with loads

Limitations: - No transformer identification (would need additional data) - Layout is algorithmic, not geographical - No shunt devices (not in GridState schema)

Source code in src/wecgrid/util/plot.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
def sld(
    self, software: str = "pypsa", figsize=(14, 10), title=None, save_path=None
):
    """Generate single-line diagram using GridState data.

    Creates a single-line diagram visualization using the standardized GridState
    component data. Works with both PSS®E and PyPSA backends by using the unified
    data schema from GridState snapshots.

    Args:
        software (str): Backend software ("psse" or "pypsa")
        figsize (tuple): Figure size as (width, height)
        title (str, optional): Custom title for the diagram
        save_path (str, optional): Path to save the figure
        show (bool): Whether to display the figure (default: False)

    Returns:
        matplotlib.figure.Figure: The generated SLD figure

    Notes:
        Uses NetworkX for automatic layout calculation since GridState doesn't
        include geographical bus positions. The diagram includes:

        - Buses: Colored rectangles based on type (Slack=red, PV=green, PQ=gray)
        - Lines: Black dashed lines connecting buses
        - Generators: Circles above buses with generators
        - Loads: Downward arrows on buses with loads

        Limitations:
        - No transformer identification (would need additional data)
        - Layout is algorithmic, not geographical
        - No shunt devices (not in GridState schema)
    """
    # Get the appropriate grid object
    grid_obj = self._get_grid_obj(software)

    if grid_obj is None:
        raise ValueError(
            f"No grid data found for software '{software}'. "
            f"Use add_grid() for standalone GridState objects or ensure "
            f"the engine has '{software}' loaded."
        )

    # Extract data from GridState
    bus_df = grid_obj.bus.copy()
    line_df = grid_obj.line.copy()
    gen_df = grid_obj.gen.copy()
    load_df = grid_obj.load.copy()

    if bus_df.empty:
        raise ValueError("No bus data available for SLD generation")

    print(f"SLD Data Summary:")
    print(f"  Buses: {len(bus_df)}")
    print(f"  Lines: {len(line_df)}")
    print(f"  Generators: {len(gen_df)}")
    print(f"  Loads: {len(load_df)}")

    # Check if required columns exist
    if "bus" not in bus_df.columns and bus_df.index.name != "bus":
        print(f"  ERROR: 'bus' column missing from bus DataFrame")
        print(f"  Available columns: {list(bus_df.columns)}")
        print(f"  Index name: {bus_df.index.name}")
        print(f"  Bus DataFrame head:\n{bus_df.head()}")

        # Check if bus numbers are in the index
        if bus_df.index.name == "bus" or "bus" in str(bus_df.index.name).lower():
            print("  Bus numbers found in DataFrame index, will use index values")
        else:
            raise ValueError("Bus DataFrame missing required 'bus' column or index")

    # Create network graph for layout
    G = nx.Graph()

    # Add buses as nodes - handle index vs column
    if "bus" in bus_df.columns:
        bus_numbers = bus_df["bus"]
    else:
        # Bus numbers are in the index
        bus_numbers = bus_df.index

    for bus_num in bus_numbers:
        G.add_node(bus_num)

    # Add lines as edges - handle potential column name variations
    ibus_col = "ibus" if "ibus" in line_df.columns else "from_bus"
    jbus_col = "jbus" if "jbus" in line_df.columns else "to_bus"
    status_col = "status" if "status" in line_df.columns else None

    for _, line_row in line_df.iterrows():
        if status_col is None or line_row[status_col] == 1:  # Only active lines
            if ibus_col in line_df.columns and jbus_col in line_df.columns:
                G.add_edge(line_row[ibus_col], line_row[jbus_col])

    # Calculate layout using NetworkX
    try:
        pos = nx.kamada_kawai_layout(G)
    except:
        # Fallback to spring layout if kamada_kawai fails
        pos = nx.spring_layout(G, seed=42)

    # Normalize positions for better visualization
    if pos:
        pos_values = np.array(list(pos.values()))
        x_vals, y_vals = pos_values[:, 0], pos_values[:, 1]
        x_min, x_max = np.min(x_vals), np.max(x_vals)
        y_min, y_max = np.min(y_vals), np.max(y_vals)

        # Normalize to reasonable plotting range
        for node in pos:
            pos[node] = (
                2 * (pos[node][0] - x_min) / (x_max - x_min) - 1,
                1.5 * (pos[node][1] - y_min) / (y_max - y_min) - 0.5,
            )

    # Create figure
    fig, ax = plt.subplots(figsize=figsize)

    # Bus visualization parameters
    node_width, node_height = 0.12, 0.04

    # Bus type color mapping
    bus_colors = {
        "Slack": "#FF4500",  # Red-orange
        "PV": "#32CD32",  # Green
        "PQ": "#A9A9A9",  # Gray
    }

    # Draw transmission lines first (so they appear behind buses)
    for _, line_row in line_df.iterrows():
        if status_col is None or line_row[status_col] == 1:  # Only active lines
            if ibus_col in line_df.columns and jbus_col in line_df.columns:
                ibus, jbus = line_row[ibus_col], line_row[jbus_col]
                if ibus in pos and jbus in pos:
                    x1, y1 = pos[ibus]
                    x2, y2 = pos[jbus]
                    ax.plot([x1, x2], [y1, y2], "k-", linewidth=1.5, alpha=0.7)

    # Identify buses with generators and loads - handle column variations
    gen_bus_col = "bus" if "bus" in gen_df.columns else "connected_bus"
    load_bus_col = "bus" if "bus" in load_df.columns else "connected_bus"
    gen_status_col = "status" if "status" in gen_df.columns else None
    load_status_col = "status" if "status" in load_df.columns else None

    # Get active generators and loads
    if gen_status_col:
        gen_buses = set(gen_df[gen_df[gen_status_col] == 1][gen_bus_col])
    else:
        gen_buses = set(gen_df[gen_bus_col])

    if load_status_col:
        load_buses = set(load_df[load_df[load_status_col] == 1][load_bus_col])
    else:
        load_buses = set(load_df[load_bus_col])

    # Draw buses
    bus_type_col = "type" if "type" in bus_df.columns else "control"
    # Determine bus column name
    if "bus" in bus_df.columns:
        bus_col = "bus"
    else:
        # Bus numbers are in the index
        bus_col = None

    for _, bus_row in bus_df.iterrows():
        if bus_col:
            bus_num = bus_row[bus_col]
        else:
            bus_num = bus_row.name  # Use index value
        if bus_num not in pos:
            continue

        x, y = pos[bus_num]
        bus_type = bus_row[bus_type_col] if bus_type_col in bus_df.columns else "PQ"
        bus_color = bus_colors.get(bus_type, "#D3D3D3")  # Default light gray

        # Draw bus rectangle
        rect = Rectangle(
            (x - node_width / 2, y - node_height / 2),
            node_width,
            node_height,
            linewidth=1.5,
            edgecolor="black",
            facecolor=bus_color,
        )
        ax.add_patch(rect)

        # Add bus number label
        ax.text(
            x,
            y,
            str(bus_num),
            fontsize=8,
            fontweight="bold",
            ha="center",
            va="center",
        )

        # Draw generators (circles above bus)
        if bus_num in gen_buses:
            gen_x = x
            gen_y = y + node_height / 2 + 0.05
            gen_size = 0.02
            # Connection line from bus to generator
            ax.plot(
                [x, gen_x],
                [y + node_height / 2, gen_y - gen_size],
                color="black",
                linewidth=2,
            )
            # Generator circle
            ax.add_patch(
                Circle(
                    (gen_x, gen_y),
                    gen_size,
                    color="none",
                    ec="black",
                    linewidth=1.5,
                )
            )
            # Generator symbol 'G'
            ax.text(
                gen_x,
                gen_y,
                "G",
                fontsize=6,
                fontweight="bold",
                ha="center",
                va="center",
            )

        # Draw loads (downward arrows)
        if bus_num in load_buses:
            load_x = x + node_width / 2 - 0.02
            load_y = y - node_height / 2
            ax.arrow(
                load_x,
                load_y,
                0,
                -0.04,
                head_width=0.015,
                head_length=0.015,
                fc="black",
                ec="black",
            )

    # Set up the plot
    ax.set_aspect("equal", adjustable="datalim")
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_frame_on(False)

    # Set title
    if title is None:
        case_name = getattr(self.engine, "case_name", "Power System")
        title = f"Single-Line Diagram - {case_name} ({software.upper()})"
    ax.set_title(title, fontsize=14, fontweight="bold")

    # Create legend
    legend_elements = [
        Line2D(
            [0],
            [0],
            marker="o",
            color="black",
            markersize=8,
            label="Generator",
            markerfacecolor="none",
            markeredgecolor="black",
            linewidth=0,
        ),
        Line2D(
            [0],
            [0],
            marker="^",
            color="black",
            markersize=8,
            label="Load",
            markerfacecolor="black",
            linewidth=0,
        ),
        Line2D(
            [0],
            [0],
            marker="s",
            color="#FF4500",
            markersize=8,
            label="Slack Bus",
            markerfacecolor="#FF4500",
            linewidth=0,
        ),
        Line2D(
            [0],
            [0],
            marker="s",
            color="#32CD32",
            markersize=8,
            label="PV Bus",
            markerfacecolor="#32CD32",
            linewidth=0,
        ),
        Line2D(
            [0],
            [0],
            marker="s",
            color="#A9A9A9",
            markersize=8,
            label="PQ Bus",
            markerfacecolor="#A9A9A9",
            linewidth=0,
        ),
        Line2D([0], [0], color="black", linewidth=1.5, label="Transmission Line"),
    ]

    ax.legend(
        handles=legend_elements,
        loc="upper left",
        fontsize=10,
        frameon=True,
        edgecolor="black",
        title="Legend",
    )

    # Save if requested
    if save_path:
        plt.savefig(save_path, dpi=300, bbox_inches="tight")
        print(f"SLD saved to: {save_path}")

    plt.tight_layout()
    plt.show()

wec_analysis(farms=None, software='pypsa')

Creates a 1x3 figure analyzing WEC farm performance.

Parameters:

Name Type Description Default
farms Optional[List[str]]

A list of farm names to analyze. If None, all farms are analyzed.

None
software str

The modeling software to use. Defaults to 'pypsa'.

'pypsa'
Source code in src/wecgrid/util/plot.py
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
def wec_analysis(self, farms: Optional[List[str]] = None, software: str = "pypsa"):
    """
    Creates a 1x3 figure analyzing WEC farm performance.

    Args:
        farms (Optional[List[str]]): A list of farm names to analyze. If None, all farms are analyzed.
        software (str): The modeling software to use. Defaults to 'pypsa'.
    """
    grid_obj = self._get_grid_obj(software)

    if grid_obj is None:
        print(
            f"Error: No grid data found for software '{software}'. "
            f"Use add_grid() for standalone GridState objects or ensure "
            f"the engine has '{software}' loaded."
        )
        return

    if not self.engine or not self.engine.wec_farms:
        print(
            f"Error: No WEC farms are defined in the engine. WEC analysis requires "
            f"engine with WEC farm data."
        )
        return

    target_farms = self.engine.wec_farms
    if farms:
        target_farms = [f for f in self.engine.wec_farms if f.farm_name in farms]

    if not target_farms:
        print("No matching WEC farms found.")
        return

    fig, axes = plt.subplots(1, 3, figsize=(20, 6))
    fig.suptitle("WEC Farm Analysis", fontsize=16)

    # 1. Active Power for each WEC farm
    wec_gen_names = [f.gen_name for f in target_farms]
    wec_power_df = grid_obj.gen_t.p[wec_gen_names]
    wec_power_df.plot(ax=axes[0])
    axes[0].set_title("WEC Farm Active Power Output")
    axes[0].set_ylabel("Active Power (pu)")
    axes[0].grid(True)

    # 2. WEC Farm total Contribution Percentage
    total_wec_power = wec_power_df.sum(axis=1)
    total_load_power = grid_obj.load_t.p.sum(axis=1)
    contribution_pct = (total_wec_power / total_load_power * 100).dropna()
    contribution_pct.plot(ax=axes[1])
    axes[1].set_title("WEC Power Contribution")
    axes[1].set_ylabel("Contribution to Total Load (%)")
    axes[1].grid(True)

    # 3. WEC-Farm Bus Voltage
    wec_bus_names = [f"Bus_{f.bus_location}" for f in target_farms]
    wec_bus_voltages = grid_obj.bus_t.v_mag[wec_bus_names]
    wec_bus_voltages.plot(ax=axes[2])
    axes[2].set_title("WEC Farm Bus Voltage")
    axes[2].set_ylabel("Voltage (pu)")
    axes[2].grid(True)

    plt.tight_layout(rect=[0, 0, 1, 0.96])
    plt.show()

Modelers

Power System

Base Classes

Bases: ABC

Abstract base class for power system modeling backends.

Defines standardized interface for PSS®E, PyPSA, and other power system tools in WEC-GRID framework. Provides grid analysis, WEC integration, and time-series simulation capabilities through common API.

Parameters:

Name Type Description Default
engine Any

WEC-GRID Engine with case_file, time, and wec_farms attributes.

required

Attributes:

Name Type Description
engine

Reference to simulation engine.

grid GridState

Time-series data for buses, generators, lines, loads.

sbase float

System base power [MVA].

Example

from wecgrid.modelers import PSSEModeler, PyPSAModeler psse_model = PSSEModeler(engine) pypsa_model = PyPSAModeler(engine)

Notes
  • Abstract class - use concrete implementations (PSSEModeler, PyPSAModeler)
  • Grid state data follows standardized schema for cross-platform comparison
  • All abstract methods must be implemented by subclasses
Source code in src/wecgrid/modelers/power_system/base.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
class PowerSystemModeler(ABC):
    """Abstract base class for power system modeling backends.

    Defines standardized interface for PSS®E, PyPSA, and other power system tools
    in WEC-GRID framework. Provides grid analysis, WEC integration, and time-series
    simulation capabilities through common API.

    Args:
        engine: WEC-GRID Engine with case_file, time, and wec_farms attributes.

    Attributes:
        engine: Reference to simulation engine.
        grid (GridState): Time-series data for buses, generators, lines, loads.
        sbase (float, optional): System base power [MVA].

    Example:
        >>> from wecgrid.modelers import PSSEModeler, PyPSAModeler
        >>> psse_model = PSSEModeler(engine)
        >>> pypsa_model = PyPSAModeler(engine)

    Notes:
        - Abstract class - use concrete implementations (PSSEModeler, PyPSAModeler)
        - Grid state data follows standardized schema for cross-platform comparison
        - All abstract methods must be implemented by subclasses
    """

    def __init__(self, engine: Any):
        """Initialize PowerSystemModeler with simulation engine.

        Args:
            engine: WEC-GRID Engine with case_file, time, and wec_farms attributes.

        Note:
            Call init_api() after construction to initialize backend tool.
        """
        self.engine = engine
        self.grid = GridState()
        self.report = SolveReport()
        self.grid.case = engine.case_name
        self.report.case = engine.case_name

        self.sbase: Optional[float] = None

    def __repr__(self) -> str:
        """Return a formatted string representation of the PowerSystemModeler.

        Provides summary of modeler state, case information, and grid statistics.

        Returns:
            str: Multi-line string representation showing modeler configuration and status.

        Example:
            >>> print(modeler)
            PSSEModeler:
            ├─ Case: IEEE_30_bus.raw (100.0 MVA base)
            ├─ Grid Components: 30 buses, 6 generators, 21 loads, 41 lines
            ├─ Time Configuration: 2025-08-23 10:00:00 → 2025-08-23 12:00:00 (5 min steps)
            ├─ WEC Farms: 2 farms, 15 total devices
            └─ Status: ✓ Initialized, ✓ Power flow converged
        """
        # Get class name (e.g., "PSSEModeler", "PyPSAModeler")
        class_name = self.__class__.__name__

        # Case information
        case_name = getattr(self.engine, "case_name", "No case loaded")
        if hasattr(self.engine, "case_file") and self.engine.case_file:
            case_file = (
                str(self.engine.case_file).split("\\")[-1].split("/")[-1]
            )  # Get filename
            case_name = case_file

        sbase_info = f" ({self.sbase} MVA base)" if self.sbase else ""
        case_line = f"├─ Case: {case_name}{sbase_info}"

        # Grid component counts
        grid_line = (
            f"├─ Grid Components: {len(self.grid.bus)} buses, "
            f"{len(self.grid.gen)} generators, {len(self.grid.load)} loads, "
            f"{len(self.grid.line)} lines"
        )

        # Time configuration
        time_line = "├─ Time Configuration: Not configured"
        if hasattr(self.engine, "time") and self.engine.time:
            time_mgr = self.engine.time
            if hasattr(time_mgr, "start_time") and hasattr(time_mgr, "delta_time"):
                start = getattr(time_mgr, "start_time", "Unknown")
                end = getattr(time_mgr, "sim_stop", "Unknown")
                delta = getattr(time_mgr, "delta_time", "Unknown")

                if start != "Unknown" and end != "Unknown":
                    time_line = (
                        f"├─ Time Configuration: {start}{end} ({delta} min steps)"
                    )
                elif start != "Unknown":
                    time_line = (
                        f"├─ Time Configuration: Starting {start} ({delta} min steps)"
                    )

        # WEC farm information
        wec_line = "├─ WEC Farms: None"
        if hasattr(self.engine, "wec_farms") and self.engine.wec_farms:
            num_farms = len(self.engine.wec_farms)
            total_devices = sum(
                len(farm.devices) for farm in self.engine.wec_farms.values()
            )
            wec_line = f"├─ WEC Farms: {num_farms} farms, {total_devices} total devices"

        # Status indicators (this would be implemented by subclasses with more specific info)
        status_line = "└─ Status: ⚠ Not initialized"

        return (
            f"{class_name}:\n"
            f"{case_line}\n"
            f"{grid_line}\n"
            f"{time_line}\n"
            f"{wec_line}\n"
            f"{status_line}"
        )

    @abstractmethod
    def init_api(self) -> bool:
        """Initialize backend power system tool and load case file.

        Returns:
            bool: True if initialization successful, False otherwise.

        Raises:
            ImportError: If backend tool not found or configured.
            ValueError: If case file invalid or cannot be loaded.

        Notes:
            Implementation should:

            - Initialize backend API/environment
            - Load case file (.sav, .raw, etc.)
            - Set system base MVA (self.sbase)
            - Perform initial power flow solution
            - Take initial grid state snapshot

        Example:
            >>> if modeler.init_api():
            ...     print("Backend initialized successfully")
        """
        pass

    @abstractmethod
    def solve_powerflow(self) -> bool:
        """Run power flow solution using backend solver.

        Returns:
            bool: True if power flow converged, False otherwise.

        Notes:
            Implementation should:

            - Call backend's power flow solver
            - Check convergence status
            - Handle solver-specific parameters
            - Suppress verbose output if needed

        Example:
            >>> if modeler.solve_powerflow():
            ...     print("Power flow converged")
        """
        pass

    @abstractmethod
    def add_wec_farm(self, farm: WECFarm) -> bool:
        """Add WEC farm to power system model.

        Args:
            farm (WECFarm): WEC farm with connection details and power characteristics.

        Returns:
            bool: True if farm added successfully, False otherwise.

        Raises:
            ValueError: If WEC farm parameters invalid.

        Notes:
            Implementation should:

            - Create new bus for WEC connection
            - Add WEC generator with power characteristics
            - Create transmission line to existing grid
            - Update grid state after modifications
            - Solve power flow to validate changes

        Example:
            >>> if modeler.add_wec_farm(wec_farm):
            ...     print("WEC farm added successfully")
        """
        pass

    @abstractmethod
    def simulate(self, load_curve: Optional[pd.DataFrame] = None) -> bool:
        """Run time-series simulation with WEC and load updates.

        Args:
            load_curve (pd.DataFrame, optional): Load values for each bus at each snapshot.
                Index: snapshots, columns: bus IDs. If None, loads remain constant.

        Returns:
            bool: True if simulation completes successfully, False otherwise.

        Raises:
            Exception: If error updating components or solving power flow.

        Notes:
            Implementation should:

            - Iterate through all time snapshots from engine.time
            - Update WEC generator power outputs [MW] from farm data
            - Update bus loads [MW] if load_curve provided
            - Solve power flow at each time step
            - Capture grid state snapshots for analysis
            - Handle convergence failures gracefully

        Example:
            >>> # Constant loads
            >>> modeler.simulate()
            >>>
            >>> # Time-varying loads
            >>> modeler.simulate(load_curve=load_df)
        """
        pass

    @abstractmethod
    def take_snapshot(self, timestamp: datetime) -> None:
        """Capture current grid state at specified timestamp.

        Args:
            timestamp (datetime): Timestamp for the snapshot.

        Notes:
            Implementation should:

            - Extract bus data: voltages [p.u.], [degrees], power [MW], [MVAr]
            - Extract generator data: power outputs [MW], [MVAr], status
            - Extract line data: power flows [MW], [MVAr], loading [%]
            - Extract load data: power consumption [MW], [MVAr]
            - Convert to standardized WEC-GRID schema
            - Store in self.grid with timestamp indexing

        Example:
            >>> modeler.take_snapshot(datetime.now())
        """
        pass

    # Convenience accessors
    @property
    def bus(self) -> Optional[pd.DataFrame]:
        """Current bus state with columns: bus, bus_name, type, p, q, v_mag, angle_deg, base.

        Returns:
            pd.DataFrame: Bus state data [p.u. on system MVA base] or None if no snapshots.
        """
        return self.grid.bus

    @property
    def gen(self) -> Optional[pd.DataFrame]:
        """Current generator state with columns: gen, bus, p, q, base, status.

        Returns:
            pd.DataFrame: Generator state data [p.u. on generator MVA base] or None if no snapshots.
        """
        return self.grid.gen

    @property
    def load(self) -> Optional[pd.DataFrame]:
        """Current load state with columns: load, bus, p, q, base, status.

        Returns:
            pd.DataFrame: Load state data [p.u. on system MVA base] or None if no snapshots.
        """
        return self.grid.load

    @property
    def line(self) -> Optional[pd.DataFrame]:
        """Current line state with columns: line, ibus, jbus, line_pct, status.

        Returns:
            pd.DataFrame: Line state data [line_pct as % of thermal rating] or None if no snapshots.
        """
        return self.grid.line

    @property
    def bus_t(self) -> Dict[str, pd.DataFrame]:
        """Time-series bus data for all snapshots.

        Returns:
            Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: bus state DataFrames.
        """
        return self.grid.bus_t

    @property
    def gen_t(self) -> Dict[str, pd.DataFrame]:
        """Time-series generator data for all snapshots.

        Returns:
            Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: generator state DataFrames.
        """
        return self.grid.gen_t

    @property
    def load_t(self) -> Dict[str, pd.DataFrame]:
        """Time-series load data for all snapshots.

        Returns:
            Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: load state DataFrames.
        """
        return self.grid.load_t

    @property
    def line_t(self) -> Dict[str, pd.DataFrame]:
        """Time-series line data for all snapshots.

        Returns:
            Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: line state DataFrames.
        """
        return self.grid.line_t

bus property

Current bus state with columns: bus, bus_name, type, p, q, v_mag, angle_deg, base.

Returns:

Type Description
Optional[DataFrame]

pd.DataFrame: Bus state data [p.u. on system MVA base] or None if no snapshots.

bus_t property

Time-series bus data for all snapshots.

Returns:

Type Description
Dict[str, DataFrame]

Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: bus state DataFrames.

gen property

Current generator state with columns: gen, bus, p, q, base, status.

Returns:

Type Description
Optional[DataFrame]

pd.DataFrame: Generator state data [p.u. on generator MVA base] or None if no snapshots.

gen_t property

Time-series generator data for all snapshots.

Returns:

Type Description
Dict[str, DataFrame]

Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: generator state DataFrames.

line property

Current line state with columns: line, ibus, jbus, line_pct, status.

Returns:

Type Description
Optional[DataFrame]

pd.DataFrame: Line state data [line_pct as % of thermal rating] or None if no snapshots.

line_t property

Time-series line data for all snapshots.

Returns:

Type Description
Dict[str, DataFrame]

Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: line state DataFrames.

load property

Current load state with columns: load, bus, p, q, base, status.

Returns:

Type Description
Optional[DataFrame]

pd.DataFrame: Load state data [p.u. on system MVA base] or None if no snapshots.

load_t property

Time-series load data for all snapshots.

Returns:

Type Description
Dict[str, DataFrame]

Dict[str, pd.DataFrame]: Keys: timestamp strings, Values: load state DataFrames.

add_wec_farm(farm) abstractmethod

Add WEC farm to power system model.

Parameters:

Name Type Description Default
farm WECFarm

WEC farm with connection details and power characteristics.

required

Returns:

Name Type Description
bool bool

True if farm added successfully, False otherwise.

Raises:

Type Description
ValueError

If WEC farm parameters invalid.

Notes

Implementation should:

  • Create new bus for WEC connection
  • Add WEC generator with power characteristics
  • Create transmission line to existing grid
  • Update grid state after modifications
  • Solve power flow to validate changes
Example

if modeler.add_wec_farm(wec_farm): ... print("WEC farm added successfully")

Source code in src/wecgrid/modelers/power_system/base.py
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
@abstractmethod
def add_wec_farm(self, farm: WECFarm) -> bool:
    """Add WEC farm to power system model.

    Args:
        farm (WECFarm): WEC farm with connection details and power characteristics.

    Returns:
        bool: True if farm added successfully, False otherwise.

    Raises:
        ValueError: If WEC farm parameters invalid.

    Notes:
        Implementation should:

        - Create new bus for WEC connection
        - Add WEC generator with power characteristics
        - Create transmission line to existing grid
        - Update grid state after modifications
        - Solve power flow to validate changes

    Example:
        >>> if modeler.add_wec_farm(wec_farm):
        ...     print("WEC farm added successfully")
    """
    pass

init_api() abstractmethod

Initialize backend power system tool and load case file.

Returns:

Name Type Description
bool bool

True if initialization successful, False otherwise.

Raises:

Type Description
ImportError

If backend tool not found or configured.

ValueError

If case file invalid or cannot be loaded.

Notes

Implementation should:

  • Initialize backend API/environment
  • Load case file (.sav, .raw, etc.)
  • Set system base MVA (self.sbase)
  • Perform initial power flow solution
  • Take initial grid state snapshot
Example

if modeler.init_api(): ... print("Backend initialized successfully")

Source code in src/wecgrid/modelers/power_system/base.py
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
@abstractmethod
def init_api(self) -> bool:
    """Initialize backend power system tool and load case file.

    Returns:
        bool: True if initialization successful, False otherwise.

    Raises:
        ImportError: If backend tool not found or configured.
        ValueError: If case file invalid or cannot be loaded.

    Notes:
        Implementation should:

        - Initialize backend API/environment
        - Load case file (.sav, .raw, etc.)
        - Set system base MVA (self.sbase)
        - Perform initial power flow solution
        - Take initial grid state snapshot

    Example:
        >>> if modeler.init_api():
        ...     print("Backend initialized successfully")
    """
    pass

simulate(load_curve=None) abstractmethod

Run time-series simulation with WEC and load updates.

Parameters:

Name Type Description Default
load_curve DataFrame

Load values for each bus at each snapshot. Index: snapshots, columns: bus IDs. If None, loads remain constant.

None

Returns:

Name Type Description
bool bool

True if simulation completes successfully, False otherwise.

Raises:

Type Description
Exception

If error updating components or solving power flow.

Notes

Implementation should:

  • Iterate through all time snapshots from engine.time
  • Update WEC generator power outputs [MW] from farm data
  • Update bus loads [MW] if load_curve provided
  • Solve power flow at each time step
  • Capture grid state snapshots for analysis
  • Handle convergence failures gracefully
Example

Constant loads

modeler.simulate()

Time-varying loads

modeler.simulate(load_curve=load_df)

Source code in src/wecgrid/modelers/power_system/base.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
@abstractmethod
def simulate(self, load_curve: Optional[pd.DataFrame] = None) -> bool:
    """Run time-series simulation with WEC and load updates.

    Args:
        load_curve (pd.DataFrame, optional): Load values for each bus at each snapshot.
            Index: snapshots, columns: bus IDs. If None, loads remain constant.

    Returns:
        bool: True if simulation completes successfully, False otherwise.

    Raises:
        Exception: If error updating components or solving power flow.

    Notes:
        Implementation should:

        - Iterate through all time snapshots from engine.time
        - Update WEC generator power outputs [MW] from farm data
        - Update bus loads [MW] if load_curve provided
        - Solve power flow at each time step
        - Capture grid state snapshots for analysis
        - Handle convergence failures gracefully

    Example:
        >>> # Constant loads
        >>> modeler.simulate()
        >>>
        >>> # Time-varying loads
        >>> modeler.simulate(load_curve=load_df)
    """
    pass

solve_powerflow() abstractmethod

Run power flow solution using backend solver.

Returns:

Name Type Description
bool bool

True if power flow converged, False otherwise.

Notes

Implementation should:

  • Call backend's power flow solver
  • Check convergence status
  • Handle solver-specific parameters
  • Suppress verbose output if needed
Example

if modeler.solve_powerflow(): ... print("Power flow converged")

Source code in src/wecgrid/modelers/power_system/base.py
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
@abstractmethod
def solve_powerflow(self) -> bool:
    """Run power flow solution using backend solver.

    Returns:
        bool: True if power flow converged, False otherwise.

    Notes:
        Implementation should:

        - Call backend's power flow solver
        - Check convergence status
        - Handle solver-specific parameters
        - Suppress verbose output if needed

    Example:
        >>> if modeler.solve_powerflow():
        ...     print("Power flow converged")
    """
    pass

take_snapshot(timestamp) abstractmethod

Capture current grid state at specified timestamp.

Parameters:

Name Type Description Default
timestamp datetime

Timestamp for the snapshot.

required
Notes

Implementation should:

  • Extract bus data: voltages [p.u.], [degrees], power [MW], [MVAr]
  • Extract generator data: power outputs [MW], [MVAr], status
  • Extract line data: power flows [MW], [MVAr], loading [%]
  • Extract load data: power consumption [MW], [MVAr]
  • Convert to standardized WEC-GRID schema
  • Store in self.grid with timestamp indexing
Example

modeler.take_snapshot(datetime.now())

Source code in src/wecgrid/modelers/power_system/base.py
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
@abstractmethod
def take_snapshot(self, timestamp: datetime) -> None:
    """Capture current grid state at specified timestamp.

    Args:
        timestamp (datetime): Timestamp for the snapshot.

    Notes:
        Implementation should:

        - Extract bus data: voltages [p.u.], [degrees], power [MW], [MVAr]
        - Extract generator data: power outputs [MW], [MVAr], status
        - Extract line data: power flows [MW], [MVAr], loading [%]
        - Extract load data: power consumption [MW], [MVAr]
        - Convert to standardized WEC-GRID schema
        - Store in self.grid with timestamp indexing

    Example:
        >>> modeler.take_snapshot(datetime.now())
    """
    pass

Standardized container for power system snapshot and time-series data.

The GridState class provides a unified data structure for storing power system component states across different simulation backends (PSS®E, PyPSA, etc.). It maintains both current snapshot data and historical time-series data for buses, generators, lines, and loads using standardized DataFrame schemas.

This class enables cross-platform validation and comparison between different power system analysis tools by enforcing consistent data formats and units. All electrical quantities are stored in per-unit values based on system MVA.

Attributes:

Name Type Description
software str

Backend software name ("psse", "pypsa", etc.).

bus DataFrame

Current bus state with voltage, power injection data.

gen DataFrame

Current generator state with power output data.

line DataFrame

Current transmission line state with loading data.

load DataFrame

Current load state with power consumption data.

bus_t AttrDict

Time-series bus data organized by variable name.

gen_t AttrDict

Time-series generator data organized by variable name.

line_t AttrDict

Time-series line data organized by variable name.

load_t AttrDict

Time-series load data organized by variable name.

Example

grid = GridState()

Update with current snapshot

grid.update("bus", timestamp, bus_dataframe)

Access current state

print(f"Number of buses: {len(grid.bus)}")

Access time-series data

voltage_history = grid.bus_t.v_mag # All bus voltages over time

Notes
  • All power values are in per-unit on system base MVA
  • Voltage magnitudes are in per-unit, angles in degrees
  • Line loading is expressed as percentage of thermal rating
  • Component IDs must be consistent across all DataFrames
  • Time-series data is automatically maintained when snapshots are updated
DataFrame Schemas

Each component DataFrame follows a standardized schema as documented in the individual update method and property descriptions.

Source code in src/wecgrid/modelers/power_system/base.py
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
@dataclass
class GridState:
    """Standardized container for power system snapshot and time-series data.

    The GridState class provides a unified data structure for storing power system
    component states across different simulation backends (PSS®E, PyPSA, etc.). It
    maintains both current snapshot data and historical time-series data for buses,
    generators, lines, and loads using standardized DataFrame schemas.

    This class enables cross-platform validation and comparison between different
    power system analysis tools by enforcing consistent data formats and units.
    All electrical quantities are stored in per-unit values based on system MVA.

    Attributes:
        software (str): Backend software name ("psse", "pypsa", etc.).
        bus (pd.DataFrame): Current bus state with voltage, power injection data.
        gen (pd.DataFrame): Current generator state with power output data.
        line (pd.DataFrame): Current transmission line state with loading data.
        load (pd.DataFrame): Current load state with power consumption data.
        bus_t (AttrDict): Time-series bus data organized by variable name.
        gen_t (AttrDict): Time-series generator data organized by variable name.
        line_t (AttrDict): Time-series line data organized by variable name.
        load_t (AttrDict): Time-series load data organized by variable name.

    Example:
        >>> grid = GridState()
        >>> # Update with current snapshot
        >>> grid.update("bus", timestamp, bus_dataframe)
        >>> # Access current state
        >>> print(f"Number of buses: {len(grid.bus)}")
        >>> # Access time-series data
        >>> voltage_history = grid.bus_t.v_mag  # All bus voltages over time

    Notes:
        - All power values are in per-unit on system base MVA
        - Voltage magnitudes are in per-unit, angles in degrees
        - Line loading is expressed as percentage of thermal rating
        - Component IDs must be consistent across all DataFrames
        - Time-series data is automatically maintained when snapshots are updated

    DataFrame Schemas:
        Each component DataFrame follows a standardized schema as documented
        in the individual update method and property descriptions.
    """

    software: str = ""
    case: str = ""
    bus: pd.DataFrame = field(default_factory=lambda: pd.DataFrame())
    gen: pd.DataFrame = field(default_factory=lambda: pd.DataFrame())
    line: pd.DataFrame = field(default_factory=lambda: pd.DataFrame())
    load: pd.DataFrame = field(default_factory=lambda: pd.DataFrame())
    bus_t: AttrDict = field(default_factory=AttrDict)
    gen_t: AttrDict = field(default_factory=AttrDict)
    line_t: AttrDict = field(default_factory=AttrDict)
    load_t: AttrDict = field(default_factory=AttrDict)

    # todo: need to add a way to identify WECs on a grid, 'G7' is a wecfarm

    def __repr__(self) -> str:
        """Return a formatted string representation of the GridState.

        Provides a tree-style summary showing the number of components in each
        category and the available time-series variables for each component type.

        Returns:
            str: Multi-line string representation showing component counts and
                available time-series data.

        Example:
            >>> print(grid)
            GridState (psse):
            ├─ Components:
            │   ├─ bus:   14 components
            │   ├─ gen:   5 components
            │   ├─ line:  20 components
            │   └─ load:  11 components
            └─ Backend: PSS®E simulation model
        """

        def ts_info(component_t):
            """Format time-series information with variable count and snapshot count."""
            if not component_t:
                return "none"
            variables = list(component_t.keys())
            if variables:
                # Get snapshot count from first variable's DataFrame
                snapshot_count = (
                    len(component_t[variables[0]])
                    if len(component_t[variables[0]]) > 0
                    else 0
                )
                var_str = ", ".join(variables)
                return f"{var_str} ({snapshot_count} snapshots)"
            return "none"

        def backend_name(software):
            """Convert software code to descriptive name."""
            names = {
                "psse": "PSS®E Modeler",
                "pypsa": "PyPSA Modeler",
                "": "No backend specified",
            }
            return names.get(software.lower(), f"{software} simulation modeler")

        return (
            f"GridState:\n"
            f"├─ Components:\n"
            f"│   ├─ bus:   {len(self.bus)} components\n"
            f"│   ├─ gen:   {len(self.gen)} components\n"
            f"│   ├─ line:  {len(self.line)} components\n"
            f"│   └─ load:  {len(self.load)} components\n"
            f"├─ Case: {self.case}\n"
            f"└─ Modeler: {self.software}"
        )

    def update(self, component: str, timestamp: pd.Timestamp, df: pd.DataFrame):
        """
        Update snapshot and time-series data for a power system component.

        This method updates both the current snapshot DataFrame and the historical
        time-series data for the specified component type. It expects DataFrames
        with standardized WEC-Grid schemas and proper `df.attrs['df_type']` attributes.

        Args:
            component (str):
                Component type ("bus", "gen", "line", "load").
            timestamp (pd.Timestamp):
                Timestamp for this snapshot.
            df (pd.DataFrame):
                Component data with `df.attrs['df_type']` set to one of
                {"BUS", "GEN", "LINE", "LOAD"}.

        Raises:
            ValueError:
                If component is not recognized, `df_type` is invalid, or required
                ID columns are missing.

        ----------------------------------------------------------------------
        DataFrame Schemas
        ----------------------------------------------------------------------
        Component ID:
            for the component attribute the ID will be an incrementing ID number starting from 1 in order of bus number

        Component Names:
            for the component_name attribute the name will be the corresponding component label and ID (e.g., "Bus_1", "Gen_1").

        **Bus DataFrame** (`df_type="BUS"`)

        | Column    | Description                                 | Type   | Units            | Base Used              |
        |-----------|---------------------------------------------|--------|------------------|------------------------|
        | bus       | Bus number (unique identifier)              | int    | —                | —                      |
        | bus_name  | Bus name/label (e.g., "Bus_1", "Bus_2")     | str    | —                | —                      |
        | type      | Bus type: "Slack", "PV", "PQ"               | str    | —                | —                      |
        | p         | Net active power injection (Gen − Load)     | float  | pu               | **S_base** (MVA)       |
        | q         | Net reactive power injection (Gen − Load)   | float  | pu               | **S_base** (MVA)       |
        | v_mag     | Voltage magnitude                           | float  | pu               | **V_base** (kV LL)     |
        | angle_deg | Voltage angle                               | float  | degrees          | —                      |
        | vbase     | Bus nominal voltage (line-to-line)          | float  | kV               | —                      |

        **Generator DataFrame** (`df_type="GEN"`)

        | Column     | Description                                 | Type   | Units            | Base Used              |
        |------------|---------------------------------------------|--------|------------------|------------------------|
        | gen        | Generator ID                                | int    | —                | —                      |
        | gen_name   | Generator name (e.g., "Gen_1")              | str    | —                | —                      |
        | bus        | Connected bus number                        | int    | —                | —                      |
        | p          | Active power output                         | float  | pu               | **S_base** (MVA)       |
        | q          | Reactive power output                       | float  | pu               | **S_base** (MVA)       |
        | Mbase      | Generator nameplate MVA rating              | float  | MVA              | **Mbase** (machine)    |
        | status     | Generator status (1=online, 0=offline)      | int    | —                | —                      |

        **Load DataFrame** (`df_type="LOAD"`)

        | Column     | Description                                 | Type   | Units            | Base Used              |
        |------------|---------------------------------------------|--------|------------------|------------------------|
        | load       | Load ID                                     | int    | —                | —                      |
        | load_name  | Load name (e.g., "Load_1")                  | str    | —                | —                      |
        | bus        | Connected bus number                        | int    | —                | —                      |
        | p          | Active power demand                         | float  | pu               | **S_base** (MVA)       |
        | q          | Reactive power demand                       | float  | pu               | **S_base** (MVA)       |
        | status     | Load status (1=connected, 0=offline)        | int    | —                | —                      |

        **Line DataFrame** (`df_type="LINE"`)

        | Column     | Description                                 | Type   | Units            | Base Used              |
        |------------|---------------------------------------------|--------|------------------|------------------------|
        | line       | Line ID                                     | int    | —                | —                      |
        | line_name  | Line name (e.g., "Line_1_2")                | str    | —                | —                      |
        | ibus       | From bus number                             | int    | —                | —                      |
        | jbus       | To bus number                               | int    | —                | —                      |
        | line_pct   | Percentage of thermal rating in use         | float  | %                | —                      |
        | status     | Line status (1=online, 0=offline)           | int    | —                | —                      |

        ----------------------------------------------------------------------
        Base Usage Summary
        ----------------------------------------------------------------------
        - **S_base (System Power Base):**
        All `p` and `q` values across buses, generators, and loads are in per-unit
        on the single, case-wide power base (e.g., 100 MVA):

        - **V_base (Bus Voltage Base):**
        Each bus has a nominal voltage in kV (line-to-line)

        - **Mbase (Machine Base):**
        Per-generator nameplate MVA rating used for manufacturer parameters.

        Example:
            >>> # Update bus data at current time
            >>> bus_df = create_bus_dataframe()  # with proper schema
            >>> bus_df.attrs['df_type'] = 'BUS'
            >>> grid.update("bus", pd.Timestamp.now(), bus_df)

            >>> # Access updated data
            >>> current_buses = grid.bus
            >>> voltage_timeseries = grid.bus_t.v_mag
        """

        if df is None or df.empty:
            return

        # --- figure out the ID column for this df_type ---
        df_type = df.attrs.get("df_type", None)
        id_map = {"BUS": "bus", "GEN": "gen", "LINE": "line", "LOAD": "load"}
        id_col = id_map.get(df_type)
        if id_col is None:
            raise ValueError(f"Cannot determine ID column from df_type='{df_type}'")

        # --- ensure the ID is a real column and set as the index for alignment ---
        if id_col in df.columns:
            pass
        elif df.index.name == id_col:
            df = df.reset_index()
        else:
            raise ValueError(
                f"'{id_col}' not found in columns or as index for df_type='{df_type}'"
            )

        df = df.copy()
        # df.set_index(id_col, inplace=True)   # now index = IDs (bus #, gen ID, etc.)

        # keep snapshot (indexed by ID)
        if not hasattr(self, component):
            raise ValueError(f"No snapshot attribute for component '{component}'")
        setattr(self, component, df)

        # --- write into the time-series store ---
        t_attr = getattr(self, f"{component}_t", None)
        if t_attr is None:
            raise ValueError(f"No time-series attribute for component '{component}'")

        # for each measured variable, maintain a DataFrame with:
        #   rows    = timestamps
        #   columns = component names (not IDs)
        for var in df.columns:
            series = df[var]  # index = IDs, values = this variable for this snapshot

            if var not in t_attr:
                t_attr[var] = pd.DataFrame()

            tdf = t_attr[var]

            # Use component names as column headers instead of IDs
            name_col = f"{component}_name"
            if name_col in df.columns:
                # Create mapping from ID to name
                id_to_name = dict(zip(df.index, df[name_col]))
                # Convert series index from IDs to names
                series_with_names = series.copy()
                series_with_names.index = [
                    id_to_name.get(idx, str(idx)) for idx in series.index
                ]

                # add any new component names as columns
                missing = series_with_names.index.difference(tdf.columns)
                if len(missing) > 0:
                    for col in missing:
                        tdf[col] = pd.NA

                # set the row for this timestamp, one component at a time to avoid alignment issues
                for comp_name, value in series_with_names.items():
                    tdf.loc[timestamp, comp_name] = value
            else:
                # Fallback to using IDs if no name column available
                # add any new IDs as columns
                missing = series.index.difference(tdf.columns)
                if len(missing) > 0:
                    for col in missing:
                        tdf[col] = pd.NA

                # set the row for this timestamp, one component at a time
                for comp_id, value in series.items():
                    tdf.loc[timestamp, comp_id] = value

            t_attr[var] = tdf

update(component, timestamp, df)

Update snapshot and time-series data for a power system component.

This method updates both the current snapshot DataFrame and the historical time-series data for the specified component type. It expects DataFrames with standardized WEC-Grid schemas and proper df.attrs['df_type'] attributes.

Parameters:

Name Type Description Default
component str

Component type ("bus", "gen", "line", "load").

required
timestamp Timestamp

Timestamp for this snapshot.

required
df DataFrame

Component data with df.attrs['df_type'] set to one of {"BUS", "GEN", "LINE", "LOAD"}.

required

Raises:

Type Description
ValueError

If component is not recognized, df_type is invalid, or required ID columns are missing.


DataFrame Schemas

Component ID: for the component attribute the ID will be an incrementing ID number starting from 1 in order of bus number

Component Names

for the component_name attribute the name will be the corresponding component label and ID (e.g., "Bus_1", "Gen_1").

Bus DataFrame (df_type="BUS")

Column Description Type Units Base Used
bus Bus number (unique identifier) int
bus_name Bus name/label (e.g., "Bus_1", "Bus_2") str
type Bus type: "Slack", "PV", "PQ" str
p Net active power injection (Gen − Load) float pu S_base (MVA)
q Net reactive power injection (Gen − Load) float pu S_base (MVA)
v_mag Voltage magnitude float pu V_base (kV LL)
angle_deg Voltage angle float degrees
vbase Bus nominal voltage (line-to-line) float kV

Generator DataFrame (df_type="GEN")

Column Description Type Units Base Used
gen Generator ID int
gen_name Generator name (e.g., "Gen_1") str
bus Connected bus number int
p Active power output float pu S_base (MVA)
q Reactive power output float pu S_base (MVA)
Mbase Generator nameplate MVA rating float MVA Mbase (machine)
status Generator status (1=online, 0=offline) int

Load DataFrame (df_type="LOAD")

Column Description Type Units Base Used
load Load ID int
load_name Load name (e.g., "Load_1") str
bus Connected bus number int
p Active power demand float pu S_base (MVA)
q Reactive power demand float pu S_base (MVA)
status Load status (1=connected, 0=offline) int

Line DataFrame (df_type="LINE")

Column Description Type Units Base Used
line Line ID int
line_name Line name (e.g., "Line_1_2") str
ibus From bus number int
jbus To bus number int
line_pct Percentage of thermal rating in use float %
status Line status (1=online, 0=offline) int

Base Usage Summary

  • S_base (System Power Base): All p and q values across buses, generators, and loads are in per-unit on the single, case-wide power base (e.g., 100 MVA):

  • V_base (Bus Voltage Base): Each bus has a nominal voltage in kV (line-to-line)

  • Mbase (Machine Base): Per-generator nameplate MVA rating used for manufacturer parameters.

Example

Update bus data at current time

bus_df = create_bus_dataframe() # with proper schema bus_df.attrs['df_type'] = 'BUS' grid.update("bus", pd.Timestamp.now(), bus_df)

Access updated data

current_buses = grid.bus voltage_timeseries = grid.bus_t.v_mag

Source code in src/wecgrid/modelers/power_system/base.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def update(self, component: str, timestamp: pd.Timestamp, df: pd.DataFrame):
    """
    Update snapshot and time-series data for a power system component.

    This method updates both the current snapshot DataFrame and the historical
    time-series data for the specified component type. It expects DataFrames
    with standardized WEC-Grid schemas and proper `df.attrs['df_type']` attributes.

    Args:
        component (str):
            Component type ("bus", "gen", "line", "load").
        timestamp (pd.Timestamp):
            Timestamp for this snapshot.
        df (pd.DataFrame):
            Component data with `df.attrs['df_type']` set to one of
            {"BUS", "GEN", "LINE", "LOAD"}.

    Raises:
        ValueError:
            If component is not recognized, `df_type` is invalid, or required
            ID columns are missing.

    ----------------------------------------------------------------------
    DataFrame Schemas
    ----------------------------------------------------------------------
    Component ID:
        for the component attribute the ID will be an incrementing ID number starting from 1 in order of bus number

    Component Names:
        for the component_name attribute the name will be the corresponding component label and ID (e.g., "Bus_1", "Gen_1").

    **Bus DataFrame** (`df_type="BUS"`)

    | Column    | Description                                 | Type   | Units            | Base Used              |
    |-----------|---------------------------------------------|--------|------------------|------------------------|
    | bus       | Bus number (unique identifier)              | int    | —                | —                      |
    | bus_name  | Bus name/label (e.g., "Bus_1", "Bus_2")     | str    | —                | —                      |
    | type      | Bus type: "Slack", "PV", "PQ"               | str    | —                | —                      |
    | p         | Net active power injection (Gen − Load)     | float  | pu               | **S_base** (MVA)       |
    | q         | Net reactive power injection (Gen − Load)   | float  | pu               | **S_base** (MVA)       |
    | v_mag     | Voltage magnitude                           | float  | pu               | **V_base** (kV LL)     |
    | angle_deg | Voltage angle                               | float  | degrees          | —                      |
    | vbase     | Bus nominal voltage (line-to-line)          | float  | kV               | —                      |

    **Generator DataFrame** (`df_type="GEN"`)

    | Column     | Description                                 | Type   | Units            | Base Used              |
    |------------|---------------------------------------------|--------|------------------|------------------------|
    | gen        | Generator ID                                | int    | —                | —                      |
    | gen_name   | Generator name (e.g., "Gen_1")              | str    | —                | —                      |
    | bus        | Connected bus number                        | int    | —                | —                      |
    | p          | Active power output                         | float  | pu               | **S_base** (MVA)       |
    | q          | Reactive power output                       | float  | pu               | **S_base** (MVA)       |
    | Mbase      | Generator nameplate MVA rating              | float  | MVA              | **Mbase** (machine)    |
    | status     | Generator status (1=online, 0=offline)      | int    | —                | —                      |

    **Load DataFrame** (`df_type="LOAD"`)

    | Column     | Description                                 | Type   | Units            | Base Used              |
    |------------|---------------------------------------------|--------|------------------|------------------------|
    | load       | Load ID                                     | int    | —                | —                      |
    | load_name  | Load name (e.g., "Load_1")                  | str    | —                | —                      |
    | bus        | Connected bus number                        | int    | —                | —                      |
    | p          | Active power demand                         | float  | pu               | **S_base** (MVA)       |
    | q          | Reactive power demand                       | float  | pu               | **S_base** (MVA)       |
    | status     | Load status (1=connected, 0=offline)        | int    | —                | —                      |

    **Line DataFrame** (`df_type="LINE"`)

    | Column     | Description                                 | Type   | Units            | Base Used              |
    |------------|---------------------------------------------|--------|------------------|------------------------|
    | line       | Line ID                                     | int    | —                | —                      |
    | line_name  | Line name (e.g., "Line_1_2")                | str    | —                | —                      |
    | ibus       | From bus number                             | int    | —                | —                      |
    | jbus       | To bus number                               | int    | —                | —                      |
    | line_pct   | Percentage of thermal rating in use         | float  | %                | —                      |
    | status     | Line status (1=online, 0=offline)           | int    | —                | —                      |

    ----------------------------------------------------------------------
    Base Usage Summary
    ----------------------------------------------------------------------
    - **S_base (System Power Base):**
    All `p` and `q` values across buses, generators, and loads are in per-unit
    on the single, case-wide power base (e.g., 100 MVA):

    - **V_base (Bus Voltage Base):**
    Each bus has a nominal voltage in kV (line-to-line)

    - **Mbase (Machine Base):**
    Per-generator nameplate MVA rating used for manufacturer parameters.

    Example:
        >>> # Update bus data at current time
        >>> bus_df = create_bus_dataframe()  # with proper schema
        >>> bus_df.attrs['df_type'] = 'BUS'
        >>> grid.update("bus", pd.Timestamp.now(), bus_df)

        >>> # Access updated data
        >>> current_buses = grid.bus
        >>> voltage_timeseries = grid.bus_t.v_mag
    """

    if df is None or df.empty:
        return

    # --- figure out the ID column for this df_type ---
    df_type = df.attrs.get("df_type", None)
    id_map = {"BUS": "bus", "GEN": "gen", "LINE": "line", "LOAD": "load"}
    id_col = id_map.get(df_type)
    if id_col is None:
        raise ValueError(f"Cannot determine ID column from df_type='{df_type}'")

    # --- ensure the ID is a real column and set as the index for alignment ---
    if id_col in df.columns:
        pass
    elif df.index.name == id_col:
        df = df.reset_index()
    else:
        raise ValueError(
            f"'{id_col}' not found in columns or as index for df_type='{df_type}'"
        )

    df = df.copy()
    # df.set_index(id_col, inplace=True)   # now index = IDs (bus #, gen ID, etc.)

    # keep snapshot (indexed by ID)
    if not hasattr(self, component):
        raise ValueError(f"No snapshot attribute for component '{component}'")
    setattr(self, component, df)

    # --- write into the time-series store ---
    t_attr = getattr(self, f"{component}_t", None)
    if t_attr is None:
        raise ValueError(f"No time-series attribute for component '{component}'")

    # for each measured variable, maintain a DataFrame with:
    #   rows    = timestamps
    #   columns = component names (not IDs)
    for var in df.columns:
        series = df[var]  # index = IDs, values = this variable for this snapshot

        if var not in t_attr:
            t_attr[var] = pd.DataFrame()

        tdf = t_attr[var]

        # Use component names as column headers instead of IDs
        name_col = f"{component}_name"
        if name_col in df.columns:
            # Create mapping from ID to name
            id_to_name = dict(zip(df.index, df[name_col]))
            # Convert series index from IDs to names
            series_with_names = series.copy()
            series_with_names.index = [
                id_to_name.get(idx, str(idx)) for idx in series.index
            ]

            # add any new component names as columns
            missing = series_with_names.index.difference(tdf.columns)
            if len(missing) > 0:
                for col in missing:
                    tdf[col] = pd.NA

            # set the row for this timestamp, one component at a time to avoid alignment issues
            for comp_name, value in series_with_names.items():
                tdf.loc[timestamp, comp_name] = value
        else:
            # Fallback to using IDs if no name column available
            # add any new IDs as columns
            missing = series.index.difference(tdf.columns)
            if len(missing) > 0:
                for col in missing:
                    tdf[col] = pd.NA

            # set the row for this timestamp, one component at a time
            for comp_id, value in series.items():
                tdf.loc[timestamp, comp_id] = value

        t_attr[var] = tdf

Bases: dict

Dictionary that allows attribute-style access to keys.

This utility class enables accessing dictionary values using dot notation (d.key) in addition to the standard bracket notation (d['key']). This is used for convenient access to time-series data collections.

Example

data = AttrDict({'voltage': df1, 'power': df2}) data.voltage # Same as data['voltage'] data.power = df3 # Same as data['power'] = df3

Raises:

Type Description
AttributeError

If the requested attribute/key does not exist.

Source code in src/wecgrid/modelers/power_system/base.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class AttrDict(dict):
    """Dictionary that allows attribute-style access to keys.

    This utility class enables accessing dictionary values using dot notation
    (d.key) in addition to the standard bracket notation (d['key']). This is
    used for convenient access to time-series data collections.

    Example:
        >>> data = AttrDict({'voltage': df1, 'power': df2})
        >>> data.voltage  # Same as data['voltage']
        >>> data.power = df3  # Same as data['power'] = df3

    Raises:
        AttributeError: If the requested attribute/key does not exist.
    """

    def __getattr__(self, name):
        """Map attribute access to dictionary lookup.

        Raises:
            AttributeError: If the key is absent.
        """
        try:
            return self[name]
        except KeyError:
            raise AttributeError(f"'AttrDict' has no attribute '{name}'")

    def __setattr__(self, name, value):
        """Map attribute assignment to setting a dictionary key."""
        self[name] = value

PSS/E Modeler

Bases: PowerSystemModeler

PSS®E power system modeling interface.

Provides interface for power system modeling and simulation using Siemens PSS®E software. Implements PSS®E-specific functionality for grid analysis, WEC farm integration, and time-series simulation.

Parameters:

Name Type Description Default
engine Any

WEC-GRID simulation engine with case_file, time, and wec_farms attributes.

required

Attributes:

Name Type Description
engine

Reference to simulation engine.

grid GridState

Time-series data for all components.

sbase float

System base power [MVA] from PSS®E case.

psspy module

PSS®E Python API module for direct access.

Example

psse_model = PSSEModeler(engine) psse_model.init_api() psse_model.solve_powerflow()

Notes
  • Requires PSS®E software installation and valid license
  • Compatible with PSS®E version 35.3 Python API
  • Supports both .sav (saved case) and .raw (raw data) formats
  • Automatically captures grid state at each simulation snapshot
TODO
  • Add support for newer PSS®E versions
  • Implement dynamic simulation capabilities
Source code in src/wecgrid/modelers/power_system/psse.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
class PSSEModeler(PowerSystemModeler):
    """PSS®E power system modeling interface.

    Provides interface for power system modeling and simulation using Siemens PSS®E software.
    Implements PSS®E-specific functionality for grid analysis, WEC farm integration,
    and time-series simulation.

    Args:
        engine: WEC-GRID simulation engine with case_file, time, and wec_farms attributes.

    Attributes:
        engine: Reference to simulation engine.
        grid (GridState): Time-series data for all components.
        sbase (float): System base power [MVA] from PSS®E case.
        psspy (module): PSS®E Python API module for direct access.

    Example:
        >>> psse_model = PSSEModeler(engine)
        >>> psse_model.init_api()
        >>> psse_model.solve_powerflow()

    Notes:
        - Requires PSS®E software installation and valid license
        - Compatible with PSS®E version 35.3 Python API
        - Supports both .sav (saved case) and .raw (raw data) formats
        - Automatically captures grid state at each simulation snapshot

    TODO:
        - Add support for newer PSS®E versions
        - Implement dynamic simulation capabilities
    """

    def __init__(self, engine: Any):
        """Initialize PSSEModeler with simulation engine.

        Args:
            engine: WEC-GRID Engine with case_file, time, and wec_farms attributes.

        Note:
            Call init_api() after construction to initialize PSS®E API.
        """
        super().__init__(engine)
        self.grid.software = "psse"
        self.report.software = "psse"
        self.grid.case = engine.case_name

    def init_api(self) -> bool:
        """Initialize the PSS®E environment and load the case.

        This method sets up the PSS®E Python API, loads the specified case file,
        and performs initial power flow solution. It also removes reactive power
        limits on generators and takes an initial snapshot.

        Returns:
            bool: True if initialization is successful, False otherwise.

        Raises:
            ImportError: If PSS®E is not found or not configured correctly.

        Notes:
            The following PSS®E API calls are used for initialization:

            - ``psseinit()``: Initialize PSS®E environment
            - ``case()`` or ``read()``: Load case file (.sav or .raw)
            - ``sysmva()``: Get system MVA base
                Returns: System base MVA [MVA]
            - ``fnsl()``: Solve power flow
            - ``solved()``: Check solution status
                Returns: 0 = converged, 1 = not converged [dimensionless]
        """
        Debug = False  # Set to True for debugging output
        try:
            with silence_stdout():
                import pssepath  # TODO double check this works, conda work around might not be needed

                pssepath.add_pssepath()
                import psspy
                import psse35
                import redirect

                redirect.psse2py()
                psse35.set_minor(3)
                psspy.psseinit(2000)  # need to update based on grid size

            if not Debug:
                psspy.prompt_output(6, "", [])
                psspy.alert_output(6, "", [])
                psspy.progress_output(6, "", [])

            PSSEModeler.psspy = psspy
            self._i = psspy.getdefaultint()
            self._f = psspy.getdefaultreal()
            self._s = psspy.getdefaultchar()

        except ModuleNotFoundError as e:
            raise ImportError("PSS®E not found or not configured correctly.") from e

        ext = self.engine.case_file.lower()
        if ext.endswith(".sav"):
            ierr = psspy.case(self.engine.case_file)
        elif ext.endswith(".raw"):
            ierr = psspy.read(1, self.engine.case_file)
        else:
            print("Unsupported case file format.")
            return False

        if ierr != 0:
            print(f"PSS®E failed to load case. ierr={ierr}")
            return False

        self.sbase = self.psspy.sysmva()
        if not self.solve_powerflow():  # true is good, false is failed power flow
            print("Powerflow solution failed.")
            return False

        self.adjust_reactive_lim()  # Remove reactive limits on generators
        self.take_snapshot(timestamp=self.engine.time.start_time)
        print("PSS®E software initialized")
        return True

    def solve_powerflow(self, log: bool = False):
        """Run power flow solution and check convergence.

        Executes the PSS®E power flow solver using the Newton-Raphson method
        and verifies that the solution converged successfully.

        Args:
            return_details (bool): If True, return detailed dict. If False, return bool.

        Returns:
            bool or dict: Bool for simple convergence check, dict with details for simulation.

        Notes:
            The following PSS®E API calls are used:

            - ``fnsl()``: Full Newton-Raphson power flow solution
            - ``solved()``: Check if power flow solution converged (0 = converged)
            - ``iterat()``: Get iteration count from last solution attempt
        """
        # === Power Flow Solution ===
        pf_start = time.time()
        ierr = self.psspy.fnsl()
        pf_time = time.time() - pf_start

        # === Power Flow Details ===
        ival = self.psspy.solved()
        iterations = self.psspy.iterat()

        converged = ierr == 0 and ival == 0

        if not converged:
            # Define error code descriptions
            ierr_descriptions = {
                0: "No error occurred",
                1: "Invalid OPTIONS value",
                2: "Generators are converted",
                3: "Buses in island(s) without a swing bus; use activity TREE",
                4: "Bus type code and series element status inconsistencies",
                5: "Prerequisite requirements for API are not met",
            }

            ival_descriptions = {
                0: "Met convergence tolerance",
                1: "Iteration limit exceeded",
                2: "Blown up (only when non-divergent option disabled)",
                3: "Terminated by non-divergent option",
                4: "Terminated by console interrupt",
                5: "Singular Jacobian matrix or voltage of 0.0 detected",
                6: "Inertial power flow dispatch error (INLF)",
                7: "OPF solution met convergence tolerance (NOPF)",
                9: "Solution not attempted",
                10: "RSOL converged with Phase shift locked",
                11: "RSOL converged with TOLN increased",
                12: "RSOL converged with Y load conversion due to low voltage",
            }

            ierr_desc = ierr_descriptions.get(ierr, f"Unknown error code: {ierr}")
            ival_desc = ival_descriptions.get(
                ival, f"Unknown convergence status: {ival}"
            )

            error_message = (
                f"PSS®E Error {ierr}: {ierr_desc} | Status {ival}: {ival_desc}"
            )

            # print(f"[ERROR] Powerflow not solved.")
            # print(f"  PSS®E Error Code {ierr}: {ierr_desc}")
            # print(f"  Convergence Status {ival}: {ival_desc}")
        else:
            error_message = "Converged successfully"

        if log:
            self.report.add_pf_solve_data(
                solve_time=pf_time,
                iterations=iterations,
                converged=converged,
                msg=error_message,
            )
        return converged

    def adjust_reactive_lim(self) -> bool:
        """Remove reactive power limits from all generators.

        Adjusts all generators in the PSS®E case to remove reactive power limits
        by setting QT = +9999 and QB = -9999. This is used to more closely align
        the modeling behavior between PSS®E and PyPSA.

        Returns:
            bool: True if successful, False otherwise.

        Notes:
            The following PSS®E API calls are used:

            - ``amachint()``: Get all generator bus numbers
                Returns: Bus numbers [dimensionless]
            - ``machine_chng_4()``: Modify generator reactive power limits
                - Sets QT (Q max) to 9999.0 [MVAr]
                - Sets QB (Q min) to -9999.0 [MVAr]
        """
        ierr, gen_buses = self.psspy.amachint(string=["NUMBER"])
        if ierr > 0:
            print("[ERROR] Failed to retrieve generator bus numbers.")
            return False

        for bus_num in gen_buses[0]:
            # Only modify QT (index 2) and QB (index 3)
            realar_array = [self._f] * 17
            realar_array[2] = 9999.0  # QT (Q max)
            realar_array[3] = -9999.0  # QB (Q min)

            ierr = self.psspy.machine_chng_4(ibus=bus_num, realar=realar_array)
            if ierr > 0:
                print(f"[WARN] Failed to update Q limits at bus {bus_num}.")
        self.grid = (
            GridState()
        )  # TODO Reset state after adding farm but should be a bette way
        self.grid.software = "psse"
        self.grid.case = self.engine.case_name
        self.solve_powerflow()
        self.take_snapshot(timestamp=self.engine.time.start_time)
        return True

    def add_wec_farm(self, farm: WECFarm) -> bool:
        """Add a WEC farm to the PSS®E model.

        This method adds a WEC farm to the PSS®E model by creating the necessary
        electrical infrastructure: a new bus for the WEC farm, a generator on that bus,
        and a transmission line connecting it to the existing grid.

        Args:
            farm (WECFarm): The WEC farm object containing connection details.

        Returns:
            bool: True if the farm is added successfully, False otherwise.

        Raises:
            ValueError: If the WEC farm cannot be added due to invalid parameters.

        Notes:
            The following PSS®E API calls are used:

            - ``busdat()``: Get base voltage of connecting bus
                Returns: Base voltage [kV]
            - ``bus_data_4()``: Add new WEC bus (PV type)
                - Base voltage [kV]
            - ``plant_data_4()``: Add plant data to WEC bus
            - ``machine_data_4()``: Add WEC generator to bus
                - PG: Active power generation [MW]
            - ``branch_data_3()``: Add transmission line from WEC bus to grid
                - R: Resistance [pu]
                - X: Reactance [pu]
                - RATEA: Rating A [MVA]
        TODO:
            Fix the hardcoded line R, X, and RATEA values
        """

        ierr, rval = self.psspy.busdat(farm.connecting_bus, "BASE")

        if ierr > 0:
            print(
                f"Error retrieving base voltage for bus {farm.connecting_bus}. PSS®E error code: {ierr}"
            )

        # Step 1: Add a new bus
        ierr = self.psspy.bus_data_4(
            ibus=farm.bus_location,
            inode=0,
            intgar1=2,  # Bus type (2 = PV bus)
            realar1=rval,  # Base voltage of the from bus in kV
            name=f"WEC BUS {farm.bus_location}",
        )
        if ierr > 0:
            print(f"Error adding bus {farm.bus_location}. PSS®E error code: {ierr}")
            return False

        # Step 2: Add plant data
        ierr = self.psspy.plant_data_4(ibus=farm.bus_location, inode=0)
        if ierr > 0:
            print(
                f"Error adding plant data to bus {farm.bus_location}. PSS®E error code: {ierr}"
            )
            return False

        # Step 3: Add generator
        ierr = self.psspy.machine_data_4(
            ibus=farm.bus_location,
            id=f"W{farm.farm_id}",
            realar1=0.0,  # PG, machine active power (0.0 by default)
        )
        if ierr > 0:
            print(
                f"Error adding generator {farm.farm_id} to bus {farm.bus_location}. PSS®E error code: {ierr}"
            )
            return False

        # Step 4: Add a branch (line) connecting the existing bus to the new bus
        realar_array = [0.0] * 12
        realar_array[0] = 0.01  # R
        realar_array[1] = 0.05  # X
        ratings_array = [0.0] * 12
        ratings_array[0] = 130.00  # RATEA
        ierr = self.psspy.branch_data_3(
            ibus=farm.bus_location,
            jbus=farm.connecting_bus,
            realar=realar_array,
            namear="WEC Line",
        )
        if ierr > 0:
            print(
                f"Error adding branch from {farm.bus_location} to {farm.connecting_bus}. PSS®E error code: {ierr}"
            )
            return False

        self.grid = (
            GridState()
        )  # TODO: Reset state after adding farm, but should be a better way
        self.grid.software = "psse"
        self.solve_powerflow()
        self.take_snapshot(timestamp=self.engine.time.start_time)
        return True

    def simulate(self, load_curve: Optional[pd.DataFrame] = None) -> bool:
        """Simulate the PSS®E grid over time with WEC farm updates.

        Simulates the PSS®E grid over a series of time snapshots, updating WEC farm
        generator outputs and optionally bus loads at each time step. For each snapshot,
        the method updates generator power outputs, applies load changes if provided,
        solves the power flow, and captures the grid state.

        Args:
            load_curve (Optional[pd.DataFrame]): DataFrame containing load values for
                each bus at each snapshot. Index should be snapshots, columns should
                be bus IDs. If None, loads remain constant.

        Returns:
            bool: True if the simulation completes successfully.

        Raises:
            Exception: If there is an error setting generator power, setting load data,
                or solving the power flow at any snapshot.

        Notes:
            The following PSS®E API calls are used for simulation:

            - ``machine_chng_4()``: Update WEC generator active power output
                - PG: Active power generation [MW]
            - ``load_data_6()``: Update bus load values (if load_curve provided)
                - P: Active power load [MW]
                - Q: Reactive power load [MVAr]
            - ``fnsl()``: Solve power flow at each time step
        """
        # log simulation start
        sim_start = time.time()

        for snapshot in tqdm(
            self.engine.time.snapshots, desc="PSS®E Simulating", unit="step"
        ):
            self.report.add_snapshot(snapshot)
            # log itr i start
            iter_start = time.time()

            for farm in self.engine.wec_farms:
                power = farm.power_at_snapshot(snapshot)  # pu sbase
                ierr = (
                    self.psspy.machine_chng_4(
                        ibus=farm.bus_location,
                        id=f"W{farm.farm_id}",
                        realar=[power * self.sbase] + [self._f] * 16,
                    )
                    > 0
                )
                if ierr > 0:
                    raise Exception(
                        f"Error setting generator power at snapshot {snapshot}"
                    )

            if load_curve is not None:
                for bus in load_curve.columns:
                    pl = float(load_curve.loc[snapshot, bus])
                    ierr = self.psspy.load_data_6(
                        ibus=bus, realar=[pl * self.sbase] + [self._f] * 7
                    )
                if ierr > 0:
                    raise Exception(
                        f"Error setting load at bus {bus} on snapshot {snapshot}"
                    )

            results = self.solve_powerflow(log=True)
            if results:
                snap_start = time.time()
                self.take_snapshot(timestamp=snapshot)
                self.report.add_snapshot_data(time.time() - snap_start)
            else:
                raise Exception(f"Powerflow failed at snapshot {snapshot}")

            self.report.add_iteration_time(time.time() - iter_start)

        # log simulation end
        self.report.simulation_time = time.time() - sim_start
        return True

    def take_snapshot(self, timestamp: datetime) -> None:
        """Take a snapshot of the current grid state.

        Captures the current state of all grid components (buses, generators, lines,
        and loads) at the specified timestamp and updates the grid state object.

        Args:
            timestamp (datetime): The timestamp for the snapshot.

        Returns:
            None
        """
        # --- Append time-series for each component ---
        self.grid.update("bus", timestamp, self.snapshot_buses())
        self.grid.update("gen", timestamp, self.snapshot_generators())
        self.grid.update("line", timestamp, self.snapshot_lines())
        self.grid.update("load", timestamp, self.snapshot_loads())

    def snapshot_buses(self) -> pd.DataFrame:
        """Capture current bus state from PSS®E.

        Builds a Pandas DataFrame of the current bus state for the loaded PSS®E grid
        using the PSS®E API. The DataFrame is formatted according to the GridState
        specification and includes bus voltage, power injection, and load data.

        Returns:
            pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag,
                angle_deg, Vbase.

        Raises:
            RuntimeError: If there is an error retrieving bus snapshot data from PSS®E.

        Notes:
            The following PSS®E API calls are used to retrieve bus snapshot data:

            Bus Information:
            - ``abuschar()``: Bus names ('NAME')
                Returns: Bus names [string]
            - ``abusint()``: Bus numbers and types ('NUMBER', 'TYPE')
                Returns: Bus numbers, Bus types 3,2,1
            - ``abusreal()``: Bus voltages and base kV ('PU', 'ANGLED', 'BASE')
                Returns: Acutal Voltage magnitude [pu], Voltage angle [degrees], Base voltage [kV]

            Generator Data:
            - ``amachint()``: Generator bus numbers ('NUMBER')
                Returns: Bus numbers [dimensionless]
            - ``amachreal()``: Generator power output ('PGEN', 'QGEN')
                Returns: Active power [MW], Reactive power [MVAr]

            Load Data:
            - ``aloadint()``: Load bus numbers ('NUMBER')
                Returns: Bus numbers [dimensionless]
            - ``aloadcplx()``: Load power consumption ('TOTALACT')
                Returns: Complex power [MW + j*MVAr]
        """
        # --- Pull data from PSS®E ---
        ierr1, names = self.psspy.abuschar(string=["NAME"])
        ierr2, ints = self.psspy.abusint(string=["NUMBER", "TYPE"])
        ierr3, reals = self.psspy.abusreal(string=["PU", "ANGLED", "BASE"])
        ierr4, gens = self.psspy.amachint(string=["NUMBER"])
        ierr5, pgen = self.psspy.amachreal(string=["PGEN"])
        ierr6, qgen = self.psspy.amachreal(string=["QGEN"])
        ierr7, loads = self.psspy.aloadint(string=["NUMBER"])
        ierr8, pqload = self.psspy.aloadcplx(string=["TOTALACT"])

        if any(
            ierr != 0
            for ierr in [ierr1, ierr2, ierr3, ierr4, ierr5, ierr6, ierr7, ierr8]
        ):
            raise RuntimeError("Error retrieving bus snapshot data from PSSE.")

        # --- Unpack ---
        bus_numbers, bus_types = ints
        v_mag, angle_deg, base_kv = reals  # base_kv is kV, v_mag is in pu
        gen_bus_ids = gens[0]
        pgen_mw = pgen[0]
        qgen_mvar = qgen[0]
        load_bus_ids = loads[0]
        load_cplx = pqload[0]

        # --- Aggregate gen/load per bus ---
        from collections import defaultdict

        gen_map = defaultdict(lambda: [0.0, 0.0])
        for b, p, q in zip(gen_bus_ids, pgen_mw, qgen_mvar):
            gen_map[b][0] += p
            gen_map[b][1] += q

        load_map = defaultdict(lambda: [0.0, 0.0])
        for b, pq in zip(load_bus_ids, load_cplx):
            load_map[b][0] += pq.real
            load_map[b][1] += pq.imag

        # --- Map type codes ---
        type_map = {3: "Slack", 2: "PV", 1: "PQ"}

        # --- Build rows ---
        rows = []
        for i in range(len(bus_numbers)):
            bus = bus_numbers[i]
            name = f"Bus_{bus}"
            pgen_b, qgen_b = gen_map[bus]
            pload_b, qload_b = load_map[bus]

            # per-unit on system MVA base
            p_pu = (pgen_b - pload_b) / self.sbase
            q_pu = (qgen_b - qload_b) / self.sbase

            rows.append(
                {
                    "bus": bus,  # int
                    "bus_name": name,
                    "type": type_map.get(bus_types[i], f"Unknown({bus_types[i]})"),
                    "p": p_pu,
                    "q": q_pu,
                    "v_mag": v_mag[i],  # already pu
                    "angle_deg": angle_deg[i],  # PSSE returns degrees
                    "vbase": base_kv[i],
                }
            )

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "BUS"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_generators(self) -> pd.DataFrame:
        """Capture current generator state from PSS®E.

        Builds a Pandas DataFrame of the current generator state for the loaded PSS®E grid
        using the PSS®E API. The DataFrame includes generator power output, base MVA,
        and status information.

        Returns:
            pd.DataFrame: DataFrame with columns: gen, gen_name, bus, p, q, Mbase, status.

        Raises:
            RuntimeError: If there is an error retrieving generator data from PSS®E.

        Notes:
            The following PSS®E API calls are used to retrieve generator data:

            - ``amachint()``: Generator bus numbers and status ('NUMBER', 'STATUS')
                Returns: Bus numbers [dimensionless], Status codes [dimensionless]
            - ``amachreal()``: Generator power and base MVA ('PGEN', 'QGEN', 'MBASE')
                Returns: Active power [MW], Reactive power [MVAr], MBase MVA [MVA]
        """

        ierr1, int_arr = self.psspy.amachint(string=["NUMBER", "STATUS"])
        ierr2, real_arr = self.psspy.amachreal(string=["PGEN", "QGEN", "MBASE"])
        if any(ierr != 0 for ierr in [ierr1, ierr2]):
            raise RuntimeError("Error fetching generator (machine) data.")

        bus_ids, statuses = int_arr
        pgen_mw, qgen_mvar, mbases = real_arr

        rows = []
        for i, bus in enumerate(bus_ids):
            rows.append(
                {
                    "gen": i + 1,
                    "gen_name": f"Gen_{i+1}",
                    "bus": bus,
                    "p": pgen_mw[i] / self.sbase,
                    "q": qgen_mvar[i] / self.sbase,
                    "Mbase": mbases[i],
                    "status": statuses[i],
                }
            )

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "GEN"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_lines(self) -> pd.DataFrame:
        """Capture current transmission line state from PSS®E.

        Builds a Pandas DataFrame of the current transmission line state for the loaded
        PSS®E grid using the PSS®E API. The DataFrame includes line loading percentages
        and connection information.

        Returns:
            pd.DataFrame: DataFrame with columns: line, line_name, ibus, jbus, line_pct, status.
                Line names are formatted as "Line_ibus_jbus_count".

        Raises:
            RuntimeError: If there is an error retrieving line data from PSS®E.

        Notes:
            The following PSS®E API calls are used to retrieve line data:

            - ``abrnchar()``: Line IDs ('ID')
                Returns: Line identifiers [string]
            - ``abrnint()``: Line bus connections and status ('FROMNUMBER', 'TONUMBER', 'STATUS')
                Returns: From bus [dimensionless], To bus [dimensionless], Status [dimensionless]
            - ``abrnreal()``: Line loading percentage ('PCTRATE')
                Returns: Line loading [%] "Percent from bus current of default rating set"
        """

        ierr1, carray = self.psspy.abrnchar(string=["ID"])
        ids = carray[0]

        ierr2, iarray = self.psspy.abrnint(string=["FROMNUMBER", "TONUMBER", "STATUS"])
        ibuses, jbuses, statuses = iarray

        ierr3, rarray = self.psspy.abrnreal(string=["PCTRATE"])
        pctrates = rarray[0]

        if any(ierr != 0 for ierr in [ierr1, ierr2, ierr3]):
            raise RuntimeError("Error fetching line data from PSSE.")

        rows = []

        for i in range(len(ibuses)):
            ibus = ibuses[i]
            jbus = jbuses[i]

            rows.append(
                {
                    "line": i + 1,
                    "line_name": f"Line_{i+1}",
                    "ibus": ibus,
                    "jbus": jbus,
                    "line_pct": pctrates[i],
                    "status": statuses[i],
                }
            )

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "LINE"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_loads(self) -> pd.DataFrame:
        """Capture current load state from PSS®E.

        Builds a Pandas DataFrame of the current load state for the loaded PSS®E grid
        using the PSS®E API. The DataFrame includes load power consumption and status
        information for all buses with loads.

        Returns:
            pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status.
                Load names are formatted as "Load_bus_count".

        Raises:
            RuntimeError: If there is an error retrieving load data from PSS®E.

        Notes:
            The following PSS®E API calls are used to retrieve load data:

            - ``aloadchar()``: Load IDs ('ID')
                Returns: Load identifiers [string]
            - ``aloadint()``: Load bus numbers and status ('NUMBER', 'STATUS')
                Returns: Bus numbers [dimensionless], Status codes [dimensionless]
            - ``aloadcplx()``: Load power consumption ('TOTALACT')
                Returns: Complex power consumption [MW + j*MVAr]
        """
        # --- Load character data: IDs
        ierr1, char_arr = self.psspy.aloadchar(string=["ID"])
        load_ids = char_arr[0]

        # --- Load integer data: bus number and status
        ierr2, int_arr = self.psspy.aloadint(string=["NUMBER", "STATUS"])
        bus_numbers, statuses = int_arr

        # --- Load complex power (in MW/MVAR)
        ierr3, complex_arr = self.psspy.aloadcplx(string=["TOTALACT"])
        total_act = complex_arr[0]

        if any(ierr != 0 for ierr in [ierr1, ierr2, ierr3]):
            raise RuntimeError("Error retrieving load snapshot data from PSSE.")

        rows = []
        for i in range(len(bus_numbers)):
            rows.append(
                {
                    "load": i + 1,
                    "load_name": f"Load_{i+1}",
                    "bus": bus_numbers[i],
                    "p": total_act[i].real / self.sbase,  # Convert MW to pu
                    "q": total_act[i].imag / self.sbase,  # Convert MVAR to pu
                    "status": statuses[i],
                }
            )

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "LOAD"
        df.index = pd.RangeIndex(start=0, stop=len(df))  # Clean index
        return df

add_wec_farm(farm)

Add a WEC farm to the PSS®E model.

This method adds a WEC farm to the PSS®E model by creating the necessary electrical infrastructure: a new bus for the WEC farm, a generator on that bus, and a transmission line connecting it to the existing grid.

Parameters:

Name Type Description Default
farm WECFarm

The WEC farm object containing connection details.

required

Returns:

Name Type Description
bool bool

True if the farm is added successfully, False otherwise.

Raises:

Type Description
ValueError

If the WEC farm cannot be added due to invalid parameters.

Notes

The following PSS®E API calls are used:

  • busdat(): Get base voltage of connecting bus Returns: Base voltage [kV]
  • bus_data_4(): Add new WEC bus (PV type)
    • Base voltage [kV]
  • plant_data_4(): Add plant data to WEC bus
  • machine_data_4(): Add WEC generator to bus
    • PG: Active power generation [MW]
  • branch_data_3(): Add transmission line from WEC bus to grid
    • R: Resistance [pu]
    • X: Reactance [pu]
    • RATEA: Rating A [MVA]

TODO: Fix the hardcoded line R, X, and RATEA values

Source code in src/wecgrid/modelers/power_system/psse.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
def add_wec_farm(self, farm: WECFarm) -> bool:
    """Add a WEC farm to the PSS®E model.

    This method adds a WEC farm to the PSS®E model by creating the necessary
    electrical infrastructure: a new bus for the WEC farm, a generator on that bus,
    and a transmission line connecting it to the existing grid.

    Args:
        farm (WECFarm): The WEC farm object containing connection details.

    Returns:
        bool: True if the farm is added successfully, False otherwise.

    Raises:
        ValueError: If the WEC farm cannot be added due to invalid parameters.

    Notes:
        The following PSS®E API calls are used:

        - ``busdat()``: Get base voltage of connecting bus
            Returns: Base voltage [kV]
        - ``bus_data_4()``: Add new WEC bus (PV type)
            - Base voltage [kV]
        - ``plant_data_4()``: Add plant data to WEC bus
        - ``machine_data_4()``: Add WEC generator to bus
            - PG: Active power generation [MW]
        - ``branch_data_3()``: Add transmission line from WEC bus to grid
            - R: Resistance [pu]
            - X: Reactance [pu]
            - RATEA: Rating A [MVA]
    TODO:
        Fix the hardcoded line R, X, and RATEA values
    """

    ierr, rval = self.psspy.busdat(farm.connecting_bus, "BASE")

    if ierr > 0:
        print(
            f"Error retrieving base voltage for bus {farm.connecting_bus}. PSS®E error code: {ierr}"
        )

    # Step 1: Add a new bus
    ierr = self.psspy.bus_data_4(
        ibus=farm.bus_location,
        inode=0,
        intgar1=2,  # Bus type (2 = PV bus)
        realar1=rval,  # Base voltage of the from bus in kV
        name=f"WEC BUS {farm.bus_location}",
    )
    if ierr > 0:
        print(f"Error adding bus {farm.bus_location}. PSS®E error code: {ierr}")
        return False

    # Step 2: Add plant data
    ierr = self.psspy.plant_data_4(ibus=farm.bus_location, inode=0)
    if ierr > 0:
        print(
            f"Error adding plant data to bus {farm.bus_location}. PSS®E error code: {ierr}"
        )
        return False

    # Step 3: Add generator
    ierr = self.psspy.machine_data_4(
        ibus=farm.bus_location,
        id=f"W{farm.farm_id}",
        realar1=0.0,  # PG, machine active power (0.0 by default)
    )
    if ierr > 0:
        print(
            f"Error adding generator {farm.farm_id} to bus {farm.bus_location}. PSS®E error code: {ierr}"
        )
        return False

    # Step 4: Add a branch (line) connecting the existing bus to the new bus
    realar_array = [0.0] * 12
    realar_array[0] = 0.01  # R
    realar_array[1] = 0.05  # X
    ratings_array = [0.0] * 12
    ratings_array[0] = 130.00  # RATEA
    ierr = self.psspy.branch_data_3(
        ibus=farm.bus_location,
        jbus=farm.connecting_bus,
        realar=realar_array,
        namear="WEC Line",
    )
    if ierr > 0:
        print(
            f"Error adding branch from {farm.bus_location} to {farm.connecting_bus}. PSS®E error code: {ierr}"
        )
        return False

    self.grid = (
        GridState()
    )  # TODO: Reset state after adding farm, but should be a better way
    self.grid.software = "psse"
    self.solve_powerflow()
    self.take_snapshot(timestamp=self.engine.time.start_time)
    return True

adjust_reactive_lim()

Remove reactive power limits from all generators.

Adjusts all generators in the PSS®E case to remove reactive power limits by setting QT = +9999 and QB = -9999. This is used to more closely align the modeling behavior between PSS®E and PyPSA.

Returns:

Name Type Description
bool bool

True if successful, False otherwise.

Notes

The following PSS®E API calls are used:

  • amachint(): Get all generator bus numbers Returns: Bus numbers [dimensionless]
  • machine_chng_4(): Modify generator reactive power limits
    • Sets QT (Q max) to 9999.0 [MVAr]
    • Sets QB (Q min) to -9999.0 [MVAr]
Source code in src/wecgrid/modelers/power_system/psse.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def adjust_reactive_lim(self) -> bool:
    """Remove reactive power limits from all generators.

    Adjusts all generators in the PSS®E case to remove reactive power limits
    by setting QT = +9999 and QB = -9999. This is used to more closely align
    the modeling behavior between PSS®E and PyPSA.

    Returns:
        bool: True if successful, False otherwise.

    Notes:
        The following PSS®E API calls are used:

        - ``amachint()``: Get all generator bus numbers
            Returns: Bus numbers [dimensionless]
        - ``machine_chng_4()``: Modify generator reactive power limits
            - Sets QT (Q max) to 9999.0 [MVAr]
            - Sets QB (Q min) to -9999.0 [MVAr]
    """
    ierr, gen_buses = self.psspy.amachint(string=["NUMBER"])
    if ierr > 0:
        print("[ERROR] Failed to retrieve generator bus numbers.")
        return False

    for bus_num in gen_buses[0]:
        # Only modify QT (index 2) and QB (index 3)
        realar_array = [self._f] * 17
        realar_array[2] = 9999.0  # QT (Q max)
        realar_array[3] = -9999.0  # QB (Q min)

        ierr = self.psspy.machine_chng_4(ibus=bus_num, realar=realar_array)
        if ierr > 0:
            print(f"[WARN] Failed to update Q limits at bus {bus_num}.")
    self.grid = (
        GridState()
    )  # TODO Reset state after adding farm but should be a bette way
    self.grid.software = "psse"
    self.grid.case = self.engine.case_name
    self.solve_powerflow()
    self.take_snapshot(timestamp=self.engine.time.start_time)
    return True

init_api()

Initialize the PSS®E environment and load the case.

This method sets up the PSS®E Python API, loads the specified case file, and performs initial power flow solution. It also removes reactive power limits on generators and takes an initial snapshot.

Returns:

Name Type Description
bool bool

True if initialization is successful, False otherwise.

Raises:

Type Description
ImportError

If PSS®E is not found or not configured correctly.

Notes

The following PSS®E API calls are used for initialization:

  • psseinit(): Initialize PSS®E environment
  • case() or read(): Load case file (.sav or .raw)
  • sysmva(): Get system MVA base Returns: System base MVA [MVA]
  • fnsl(): Solve power flow
  • solved(): Check solution status Returns: 0 = converged, 1 = not converged [dimensionless]
Source code in src/wecgrid/modelers/power_system/psse.py
 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
def init_api(self) -> bool:
    """Initialize the PSS®E environment and load the case.

    This method sets up the PSS®E Python API, loads the specified case file,
    and performs initial power flow solution. It also removes reactive power
    limits on generators and takes an initial snapshot.

    Returns:
        bool: True if initialization is successful, False otherwise.

    Raises:
        ImportError: If PSS®E is not found or not configured correctly.

    Notes:
        The following PSS®E API calls are used for initialization:

        - ``psseinit()``: Initialize PSS®E environment
        - ``case()`` or ``read()``: Load case file (.sav or .raw)
        - ``sysmva()``: Get system MVA base
            Returns: System base MVA [MVA]
        - ``fnsl()``: Solve power flow
        - ``solved()``: Check solution status
            Returns: 0 = converged, 1 = not converged [dimensionless]
    """
    Debug = False  # Set to True for debugging output
    try:
        with silence_stdout():
            import pssepath  # TODO double check this works, conda work around might not be needed

            pssepath.add_pssepath()
            import psspy
            import psse35
            import redirect

            redirect.psse2py()
            psse35.set_minor(3)
            psspy.psseinit(2000)  # need to update based on grid size

        if not Debug:
            psspy.prompt_output(6, "", [])
            psspy.alert_output(6, "", [])
            psspy.progress_output(6, "", [])

        PSSEModeler.psspy = psspy
        self._i = psspy.getdefaultint()
        self._f = psspy.getdefaultreal()
        self._s = psspy.getdefaultchar()

    except ModuleNotFoundError as e:
        raise ImportError("PSS®E not found or not configured correctly.") from e

    ext = self.engine.case_file.lower()
    if ext.endswith(".sav"):
        ierr = psspy.case(self.engine.case_file)
    elif ext.endswith(".raw"):
        ierr = psspy.read(1, self.engine.case_file)
    else:
        print("Unsupported case file format.")
        return False

    if ierr != 0:
        print(f"PSS®E failed to load case. ierr={ierr}")
        return False

    self.sbase = self.psspy.sysmva()
    if not self.solve_powerflow():  # true is good, false is failed power flow
        print("Powerflow solution failed.")
        return False

    self.adjust_reactive_lim()  # Remove reactive limits on generators
    self.take_snapshot(timestamp=self.engine.time.start_time)
    print("PSS®E software initialized")
    return True

simulate(load_curve=None)

Simulate the PSS®E grid over time with WEC farm updates.

Simulates the PSS®E grid over a series of time snapshots, updating WEC farm generator outputs and optionally bus loads at each time step. For each snapshot, the method updates generator power outputs, applies load changes if provided, solves the power flow, and captures the grid state.

Parameters:

Name Type Description Default
load_curve Optional[DataFrame]

DataFrame containing load values for each bus at each snapshot. Index should be snapshots, columns should be bus IDs. If None, loads remain constant.

None

Returns:

Name Type Description
bool bool

True if the simulation completes successfully.

Raises:

Type Description
Exception

If there is an error setting generator power, setting load data, or solving the power flow at any snapshot.

Notes

The following PSS®E API calls are used for simulation:

  • machine_chng_4(): Update WEC generator active power output
    • PG: Active power generation [MW]
  • load_data_6(): Update bus load values (if load_curve provided)
    • P: Active power load [MW]
    • Q: Reactive power load [MVAr]
  • fnsl(): Solve power flow at each time step
Source code in src/wecgrid/modelers/power_system/psse.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
def simulate(self, load_curve: Optional[pd.DataFrame] = None) -> bool:
    """Simulate the PSS®E grid over time with WEC farm updates.

    Simulates the PSS®E grid over a series of time snapshots, updating WEC farm
    generator outputs and optionally bus loads at each time step. For each snapshot,
    the method updates generator power outputs, applies load changes if provided,
    solves the power flow, and captures the grid state.

    Args:
        load_curve (Optional[pd.DataFrame]): DataFrame containing load values for
            each bus at each snapshot. Index should be snapshots, columns should
            be bus IDs. If None, loads remain constant.

    Returns:
        bool: True if the simulation completes successfully.

    Raises:
        Exception: If there is an error setting generator power, setting load data,
            or solving the power flow at any snapshot.

    Notes:
        The following PSS®E API calls are used for simulation:

        - ``machine_chng_4()``: Update WEC generator active power output
            - PG: Active power generation [MW]
        - ``load_data_6()``: Update bus load values (if load_curve provided)
            - P: Active power load [MW]
            - Q: Reactive power load [MVAr]
        - ``fnsl()``: Solve power flow at each time step
    """
    # log simulation start
    sim_start = time.time()

    for snapshot in tqdm(
        self.engine.time.snapshots, desc="PSS®E Simulating", unit="step"
    ):
        self.report.add_snapshot(snapshot)
        # log itr i start
        iter_start = time.time()

        for farm in self.engine.wec_farms:
            power = farm.power_at_snapshot(snapshot)  # pu sbase
            ierr = (
                self.psspy.machine_chng_4(
                    ibus=farm.bus_location,
                    id=f"W{farm.farm_id}",
                    realar=[power * self.sbase] + [self._f] * 16,
                )
                > 0
            )
            if ierr > 0:
                raise Exception(
                    f"Error setting generator power at snapshot {snapshot}"
                )

        if load_curve is not None:
            for bus in load_curve.columns:
                pl = float(load_curve.loc[snapshot, bus])
                ierr = self.psspy.load_data_6(
                    ibus=bus, realar=[pl * self.sbase] + [self._f] * 7
                )
            if ierr > 0:
                raise Exception(
                    f"Error setting load at bus {bus} on snapshot {snapshot}"
                )

        results = self.solve_powerflow(log=True)
        if results:
            snap_start = time.time()
            self.take_snapshot(timestamp=snapshot)
            self.report.add_snapshot_data(time.time() - snap_start)
        else:
            raise Exception(f"Powerflow failed at snapshot {snapshot}")

        self.report.add_iteration_time(time.time() - iter_start)

    # log simulation end
    self.report.simulation_time = time.time() - sim_start
    return True

snapshot_buses()

Capture current bus state from PSS®E.

Builds a Pandas DataFrame of the current bus state for the loaded PSS®E grid using the PSS®E API. The DataFrame is formatted according to the GridState specification and includes bus voltage, power injection, and load data.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag, angle_deg, Vbase.

Raises:

Type Description
RuntimeError

If there is an error retrieving bus snapshot data from PSS®E.

Notes

The following PSS®E API calls are used to retrieve bus snapshot data:

Bus Information: - abuschar(): Bus names ('NAME') Returns: Bus names [string] - abusint(): Bus numbers and types ('NUMBER', 'TYPE') Returns: Bus numbers, Bus types 3,2,1 - abusreal(): Bus voltages and base kV ('PU', 'ANGLED', 'BASE') Returns: Acutal Voltage magnitude [pu], Voltage angle [degrees], Base voltage [kV]

Generator Data: - amachint(): Generator bus numbers ('NUMBER') Returns: Bus numbers [dimensionless] - amachreal(): Generator power output ('PGEN', 'QGEN') Returns: Active power [MW], Reactive power [MVAr]

Load Data: - aloadint(): Load bus numbers ('NUMBER') Returns: Bus numbers [dimensionless] - aloadcplx(): Load power consumption ('TOTALACT') Returns: Complex power [MW + j*MVAr]

Source code in src/wecgrid/modelers/power_system/psse.py
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
def snapshot_buses(self) -> pd.DataFrame:
    """Capture current bus state from PSS®E.

    Builds a Pandas DataFrame of the current bus state for the loaded PSS®E grid
    using the PSS®E API. The DataFrame is formatted according to the GridState
    specification and includes bus voltage, power injection, and load data.

    Returns:
        pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag,
            angle_deg, Vbase.

    Raises:
        RuntimeError: If there is an error retrieving bus snapshot data from PSS®E.

    Notes:
        The following PSS®E API calls are used to retrieve bus snapshot data:

        Bus Information:
        - ``abuschar()``: Bus names ('NAME')
            Returns: Bus names [string]
        - ``abusint()``: Bus numbers and types ('NUMBER', 'TYPE')
            Returns: Bus numbers, Bus types 3,2,1
        - ``abusreal()``: Bus voltages and base kV ('PU', 'ANGLED', 'BASE')
            Returns: Acutal Voltage magnitude [pu], Voltage angle [degrees], Base voltage [kV]

        Generator Data:
        - ``amachint()``: Generator bus numbers ('NUMBER')
            Returns: Bus numbers [dimensionless]
        - ``amachreal()``: Generator power output ('PGEN', 'QGEN')
            Returns: Active power [MW], Reactive power [MVAr]

        Load Data:
        - ``aloadint()``: Load bus numbers ('NUMBER')
            Returns: Bus numbers [dimensionless]
        - ``aloadcplx()``: Load power consumption ('TOTALACT')
            Returns: Complex power [MW + j*MVAr]
    """
    # --- Pull data from PSS®E ---
    ierr1, names = self.psspy.abuschar(string=["NAME"])
    ierr2, ints = self.psspy.abusint(string=["NUMBER", "TYPE"])
    ierr3, reals = self.psspy.abusreal(string=["PU", "ANGLED", "BASE"])
    ierr4, gens = self.psspy.amachint(string=["NUMBER"])
    ierr5, pgen = self.psspy.amachreal(string=["PGEN"])
    ierr6, qgen = self.psspy.amachreal(string=["QGEN"])
    ierr7, loads = self.psspy.aloadint(string=["NUMBER"])
    ierr8, pqload = self.psspy.aloadcplx(string=["TOTALACT"])

    if any(
        ierr != 0
        for ierr in [ierr1, ierr2, ierr3, ierr4, ierr5, ierr6, ierr7, ierr8]
    ):
        raise RuntimeError("Error retrieving bus snapshot data from PSSE.")

    # --- Unpack ---
    bus_numbers, bus_types = ints
    v_mag, angle_deg, base_kv = reals  # base_kv is kV, v_mag is in pu
    gen_bus_ids = gens[0]
    pgen_mw = pgen[0]
    qgen_mvar = qgen[0]
    load_bus_ids = loads[0]
    load_cplx = pqload[0]

    # --- Aggregate gen/load per bus ---
    from collections import defaultdict

    gen_map = defaultdict(lambda: [0.0, 0.0])
    for b, p, q in zip(gen_bus_ids, pgen_mw, qgen_mvar):
        gen_map[b][0] += p
        gen_map[b][1] += q

    load_map = defaultdict(lambda: [0.0, 0.0])
    for b, pq in zip(load_bus_ids, load_cplx):
        load_map[b][0] += pq.real
        load_map[b][1] += pq.imag

    # --- Map type codes ---
    type_map = {3: "Slack", 2: "PV", 1: "PQ"}

    # --- Build rows ---
    rows = []
    for i in range(len(bus_numbers)):
        bus = bus_numbers[i]
        name = f"Bus_{bus}"
        pgen_b, qgen_b = gen_map[bus]
        pload_b, qload_b = load_map[bus]

        # per-unit on system MVA base
        p_pu = (pgen_b - pload_b) / self.sbase
        q_pu = (qgen_b - qload_b) / self.sbase

        rows.append(
            {
                "bus": bus,  # int
                "bus_name": name,
                "type": type_map.get(bus_types[i], f"Unknown({bus_types[i]})"),
                "p": p_pu,
                "q": q_pu,
                "v_mag": v_mag[i],  # already pu
                "angle_deg": angle_deg[i],  # PSSE returns degrees
                "vbase": base_kv[i],
            }
        )

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "BUS"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_generators()

Capture current generator state from PSS®E.

Builds a Pandas DataFrame of the current generator state for the loaded PSS®E grid using the PSS®E API. The DataFrame includes generator power output, base MVA, and status information.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: gen, gen_name, bus, p, q, Mbase, status.

Raises:

Type Description
RuntimeError

If there is an error retrieving generator data from PSS®E.

Notes

The following PSS®E API calls are used to retrieve generator data:

  • amachint(): Generator bus numbers and status ('NUMBER', 'STATUS') Returns: Bus numbers [dimensionless], Status codes [dimensionless]
  • amachreal(): Generator power and base MVA ('PGEN', 'QGEN', 'MBASE') Returns: Active power [MW], Reactive power [MVAr], MBase MVA [MVA]
Source code in src/wecgrid/modelers/power_system/psse.py
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
def snapshot_generators(self) -> pd.DataFrame:
    """Capture current generator state from PSS®E.

    Builds a Pandas DataFrame of the current generator state for the loaded PSS®E grid
    using the PSS®E API. The DataFrame includes generator power output, base MVA,
    and status information.

    Returns:
        pd.DataFrame: DataFrame with columns: gen, gen_name, bus, p, q, Mbase, status.

    Raises:
        RuntimeError: If there is an error retrieving generator data from PSS®E.

    Notes:
        The following PSS®E API calls are used to retrieve generator data:

        - ``amachint()``: Generator bus numbers and status ('NUMBER', 'STATUS')
            Returns: Bus numbers [dimensionless], Status codes [dimensionless]
        - ``amachreal()``: Generator power and base MVA ('PGEN', 'QGEN', 'MBASE')
            Returns: Active power [MW], Reactive power [MVAr], MBase MVA [MVA]
    """

    ierr1, int_arr = self.psspy.amachint(string=["NUMBER", "STATUS"])
    ierr2, real_arr = self.psspy.amachreal(string=["PGEN", "QGEN", "MBASE"])
    if any(ierr != 0 for ierr in [ierr1, ierr2]):
        raise RuntimeError("Error fetching generator (machine) data.")

    bus_ids, statuses = int_arr
    pgen_mw, qgen_mvar, mbases = real_arr

    rows = []
    for i, bus in enumerate(bus_ids):
        rows.append(
            {
                "gen": i + 1,
                "gen_name": f"Gen_{i+1}",
                "bus": bus,
                "p": pgen_mw[i] / self.sbase,
                "q": qgen_mvar[i] / self.sbase,
                "Mbase": mbases[i],
                "status": statuses[i],
            }
        )

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "GEN"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_lines()

Capture current transmission line state from PSS®E.

Builds a Pandas DataFrame of the current transmission line state for the loaded PSS®E grid using the PSS®E API. The DataFrame includes line loading percentages and connection information.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: line, line_name, ibus, jbus, line_pct, status. Line names are formatted as "Line_ibus_jbus_count".

Raises:

Type Description
RuntimeError

If there is an error retrieving line data from PSS®E.

Notes

The following PSS®E API calls are used to retrieve line data:

  • abrnchar(): Line IDs ('ID') Returns: Line identifiers [string]
  • abrnint(): Line bus connections and status ('FROMNUMBER', 'TONUMBER', 'STATUS') Returns: From bus [dimensionless], To bus [dimensionless], Status [dimensionless]
  • abrnreal(): Line loading percentage ('PCTRATE') Returns: Line loading [%] "Percent from bus current of default rating set"
Source code in src/wecgrid/modelers/power_system/psse.py
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
def snapshot_lines(self) -> pd.DataFrame:
    """Capture current transmission line state from PSS®E.

    Builds a Pandas DataFrame of the current transmission line state for the loaded
    PSS®E grid using the PSS®E API. The DataFrame includes line loading percentages
    and connection information.

    Returns:
        pd.DataFrame: DataFrame with columns: line, line_name, ibus, jbus, line_pct, status.
            Line names are formatted as "Line_ibus_jbus_count".

    Raises:
        RuntimeError: If there is an error retrieving line data from PSS®E.

    Notes:
        The following PSS®E API calls are used to retrieve line data:

        - ``abrnchar()``: Line IDs ('ID')
            Returns: Line identifiers [string]
        - ``abrnint()``: Line bus connections and status ('FROMNUMBER', 'TONUMBER', 'STATUS')
            Returns: From bus [dimensionless], To bus [dimensionless], Status [dimensionless]
        - ``abrnreal()``: Line loading percentage ('PCTRATE')
            Returns: Line loading [%] "Percent from bus current of default rating set"
    """

    ierr1, carray = self.psspy.abrnchar(string=["ID"])
    ids = carray[0]

    ierr2, iarray = self.psspy.abrnint(string=["FROMNUMBER", "TONUMBER", "STATUS"])
    ibuses, jbuses, statuses = iarray

    ierr3, rarray = self.psspy.abrnreal(string=["PCTRATE"])
    pctrates = rarray[0]

    if any(ierr != 0 for ierr in [ierr1, ierr2, ierr3]):
        raise RuntimeError("Error fetching line data from PSSE.")

    rows = []

    for i in range(len(ibuses)):
        ibus = ibuses[i]
        jbus = jbuses[i]

        rows.append(
            {
                "line": i + 1,
                "line_name": f"Line_{i+1}",
                "ibus": ibus,
                "jbus": jbus,
                "line_pct": pctrates[i],
                "status": statuses[i],
            }
        )

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "LINE"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_loads()

Capture current load state from PSS®E.

Builds a Pandas DataFrame of the current load state for the loaded PSS®E grid using the PSS®E API. The DataFrame includes load power consumption and status information for all buses with loads.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status. Load names are formatted as "Load_bus_count".

Raises:

Type Description
RuntimeError

If there is an error retrieving load data from PSS®E.

Notes

The following PSS®E API calls are used to retrieve load data:

  • aloadchar(): Load IDs ('ID') Returns: Load identifiers [string]
  • aloadint(): Load bus numbers and status ('NUMBER', 'STATUS') Returns: Bus numbers [dimensionless], Status codes [dimensionless]
  • aloadcplx(): Load power consumption ('TOTALACT') Returns: Complex power consumption [MW + j*MVAr]
Source code in src/wecgrid/modelers/power_system/psse.py
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
def snapshot_loads(self) -> pd.DataFrame:
    """Capture current load state from PSS®E.

    Builds a Pandas DataFrame of the current load state for the loaded PSS®E grid
    using the PSS®E API. The DataFrame includes load power consumption and status
    information for all buses with loads.

    Returns:
        pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status.
            Load names are formatted as "Load_bus_count".

    Raises:
        RuntimeError: If there is an error retrieving load data from PSS®E.

    Notes:
        The following PSS®E API calls are used to retrieve load data:

        - ``aloadchar()``: Load IDs ('ID')
            Returns: Load identifiers [string]
        - ``aloadint()``: Load bus numbers and status ('NUMBER', 'STATUS')
            Returns: Bus numbers [dimensionless], Status codes [dimensionless]
        - ``aloadcplx()``: Load power consumption ('TOTALACT')
            Returns: Complex power consumption [MW + j*MVAr]
    """
    # --- Load character data: IDs
    ierr1, char_arr = self.psspy.aloadchar(string=["ID"])
    load_ids = char_arr[0]

    # --- Load integer data: bus number and status
    ierr2, int_arr = self.psspy.aloadint(string=["NUMBER", "STATUS"])
    bus_numbers, statuses = int_arr

    # --- Load complex power (in MW/MVAR)
    ierr3, complex_arr = self.psspy.aloadcplx(string=["TOTALACT"])
    total_act = complex_arr[0]

    if any(ierr != 0 for ierr in [ierr1, ierr2, ierr3]):
        raise RuntimeError("Error retrieving load snapshot data from PSSE.")

    rows = []
    for i in range(len(bus_numbers)):
        rows.append(
            {
                "load": i + 1,
                "load_name": f"Load_{i+1}",
                "bus": bus_numbers[i],
                "p": total_act[i].real / self.sbase,  # Convert MW to pu
                "q": total_act[i].imag / self.sbase,  # Convert MVAR to pu
                "status": statuses[i],
            }
        )

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "LOAD"
    df.index = pd.RangeIndex(start=0, stop=len(df))  # Clean index
    return df

solve_powerflow(log=False)

Run power flow solution and check convergence.

Executes the PSS®E power flow solver using the Newton-Raphson method and verifies that the solution converged successfully.

Parameters:

Name Type Description Default
return_details bool

If True, return detailed dict. If False, return bool.

required

Returns:

Type Description

bool or dict: Bool for simple convergence check, dict with details for simulation.

Notes

The following PSS®E API calls are used:

  • fnsl(): Full Newton-Raphson power flow solution
  • solved(): Check if power flow solution converged (0 = converged)
  • iterat(): Get iteration count from last solution attempt
Source code in src/wecgrid/modelers/power_system/psse.py
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def solve_powerflow(self, log: bool = False):
    """Run power flow solution and check convergence.

    Executes the PSS®E power flow solver using the Newton-Raphson method
    and verifies that the solution converged successfully.

    Args:
        return_details (bool): If True, return detailed dict. If False, return bool.

    Returns:
        bool or dict: Bool for simple convergence check, dict with details for simulation.

    Notes:
        The following PSS®E API calls are used:

        - ``fnsl()``: Full Newton-Raphson power flow solution
        - ``solved()``: Check if power flow solution converged (0 = converged)
        - ``iterat()``: Get iteration count from last solution attempt
    """
    # === Power Flow Solution ===
    pf_start = time.time()
    ierr = self.psspy.fnsl()
    pf_time = time.time() - pf_start

    # === Power Flow Details ===
    ival = self.psspy.solved()
    iterations = self.psspy.iterat()

    converged = ierr == 0 and ival == 0

    if not converged:
        # Define error code descriptions
        ierr_descriptions = {
            0: "No error occurred",
            1: "Invalid OPTIONS value",
            2: "Generators are converted",
            3: "Buses in island(s) without a swing bus; use activity TREE",
            4: "Bus type code and series element status inconsistencies",
            5: "Prerequisite requirements for API are not met",
        }

        ival_descriptions = {
            0: "Met convergence tolerance",
            1: "Iteration limit exceeded",
            2: "Blown up (only when non-divergent option disabled)",
            3: "Terminated by non-divergent option",
            4: "Terminated by console interrupt",
            5: "Singular Jacobian matrix or voltage of 0.0 detected",
            6: "Inertial power flow dispatch error (INLF)",
            7: "OPF solution met convergence tolerance (NOPF)",
            9: "Solution not attempted",
            10: "RSOL converged with Phase shift locked",
            11: "RSOL converged with TOLN increased",
            12: "RSOL converged with Y load conversion due to low voltage",
        }

        ierr_desc = ierr_descriptions.get(ierr, f"Unknown error code: {ierr}")
        ival_desc = ival_descriptions.get(
            ival, f"Unknown convergence status: {ival}"
        )

        error_message = (
            f"PSS®E Error {ierr}: {ierr_desc} | Status {ival}: {ival_desc}"
        )

        # print(f"[ERROR] Powerflow not solved.")
        # print(f"  PSS®E Error Code {ierr}: {ierr_desc}")
        # print(f"  Convergence Status {ival}: {ival_desc}")
    else:
        error_message = "Converged successfully"

    if log:
        self.report.add_pf_solve_data(
            solve_time=pf_time,
            iterations=iterations,
            converged=converged,
            msg=error_message,
        )
    return converged

take_snapshot(timestamp)

Take a snapshot of the current grid state.

Captures the current state of all grid components (buses, generators, lines, and loads) at the specified timestamp and updates the grid state object.

Parameters:

Name Type Description Default
timestamp datetime

The timestamp for the snapshot.

required

Returns:

Type Description
None

None

Source code in src/wecgrid/modelers/power_system/psse.py
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
def take_snapshot(self, timestamp: datetime) -> None:
    """Take a snapshot of the current grid state.

    Captures the current state of all grid components (buses, generators, lines,
    and loads) at the specified timestamp and updates the grid state object.

    Args:
        timestamp (datetime): The timestamp for the snapshot.

    Returns:
        None
    """
    # --- Append time-series for each component ---
    self.grid.update("bus", timestamp, self.snapshot_buses())
    self.grid.update("gen", timestamp, self.snapshot_generators())
    self.grid.update("line", timestamp, self.snapshot_lines())
    self.grid.update("load", timestamp, self.snapshot_loads())

PyPSA Modeler

Bases: PowerSystemModeler

PyPSA power system modeling interface.

Provides interface for power system modeling and simulation using PyPSA (Python for Power System Analysis). Implements PyPSA-specific functionality for grid analysis, WEC farm integration, and time-series simulation.

Parameters:

Name Type Description Default
engine Any

WEC-GRID simulation engine with case_file, time, and wec_farms attributes.

required

Attributes:

Name Type Description
engine

Reference to simulation engine.

grid GridState

Time-series data for all components.

network Network

PyPSA Network object for power system analysis.

sbase float

System base power [MVA] from case file.

parser float

GRG PSS®E case file parser object for data extraction.

Example

pypsa_model = PyPSAModeler(engine) pypsa_model.init_api() pypsa_model.simulate()

Notes
  • Compatible with PyPSA version 0.21+ for power system analysis
  • Uses GRG PSS®E parser for case file import and conversion
  • Automatically converts PSS®E impedance values to PyPSA format
  • Provides validation against PSS®E results for cross-platform verification
TODO
  • Add support for PyPSA native case formats
  • Implement dynamic component ratings
Source code in src/wecgrid/modelers/power_system/pypsa.py
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  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
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
class PyPSAModeler(PowerSystemModeler):
    """PyPSA power system modeling interface.

    Provides interface for power system modeling and simulation using PyPSA
    (Python for Power System Analysis). Implements PyPSA-specific functionality
    for grid analysis, WEC farm integration, and time-series simulation.

    Args:
        engine: WEC-GRID simulation engine with case_file, time, and wec_farms attributes.

    Attributes:
        engine: Reference to simulation engine.
        grid (GridState): Time-series data for all components.
        network (pypsa.Network): PyPSA Network object for power system analysis.
        sbase (float): System base power [MVA] from case file.
        parser: GRG PSS®E case file parser object for data extraction.

    Example:
        >>> pypsa_model = PyPSAModeler(engine)
        >>> pypsa_model.init_api()
        >>> pypsa_model.simulate()

    Notes:
        - Compatible with PyPSA version 0.21+ for power system analysis
        - Uses GRG PSS®E parser for case file import and conversion
        - Automatically converts PSS®E impedance values to PyPSA format
        - Provides validation against PSS®E results for cross-platform verification

    TODO:
        - Add support for PyPSA native case formats
        - Implement dynamic component ratings
    """

    def __init__(self, engine: Any):
        """Initialize PyPSAModeler with simulation engine.

        Args:
            engine: WEC-GRID Engine with case_file, time, and wec_farms attributes.

        Note:
            Call init_api() after construction to initialize PyPSA network.
        """
        super().__init__(engine)
        self.network: Optional[pypsa.Network] = None
        self.grid.software = "pypsa"
        self.report.software = "pypsa"
        self.grid.case = engine.case_name

    # def __repr__(self) -> str:
    #     """String representation of PyPSA model with network summary.

    #     Returns:
    #         str: Tree-style summary with case name, component counts, and system base [MVA].
    #     """
    #     return (
    #         f"pypsa:\n"
    #         f"├─ case: {self.engine.case_name}\n"
    #         f"├─ buses: {len(self.grid.bus)}\n"
    #         f"├─ generators: {len(self.grid.gen)}\n"
    #         f"├─ loads: {len(self.grid.load)}\n"
    #         f"└─ lines: {len(self.grid.line)}"
    #         f"\n"
    #         f"Sbase: {self.sbase} MVA"
    #     )

    def init_api(self) -> bool:
        """Initialize the PyPSA environment and load the case.

        This method sets up the PyPSA network by importing the PSS®E case file,
        creating the network structure, and performing initial power flow solution.
        It also takes an initial snapshot of the grid state.

        Returns:
            bool: True if initialization is successful, False otherwise.

        Raises:
            ImportError: If PyPSA or GRG dependencies are not found.
            ValueError: If case file cannot be parsed or is invalid.

        Notes:
            The initialization process includes:

            - Parsing PSS®E case file using GRG parser
            - Creating PyPSA Network with system base MVA [MVA]
            - Converting PSS®E impedance values to PyPSA format
            - Adding buses with voltage limits [kV] and control types
            - Adding lines with impedance [Ohm] and ratings [MVA]
            - Adding generators with power limits [MW], [MVAr]
            - Adding loads with power consumption [MW], [MVAr]
            - Adding transformers and shunt impedances
            - Solving initial power flow
        """
        if not self.import_raw_to_pypsa():
            return False
        if not self.solve_powerflow():
            return False
        self.take_snapshot(timestamp=self.engine.time.start_time)  # populates self.grid
        # print("PyPSA software initialized")
        return True

    def solve_powerflow(self, log: bool = False) -> bool:
        """Run power flow solution and check convergence.

        Executes the PyPSA power flow solver with suppressed logging output
        and verifies that the solution converged successfully for all snapshots.

        Returns:
            bool: True if power flow converged for all snapshots, False otherwise.

        Notes:
            The power flow solution process:

            - Temporarily suppresses PyPSA logging to reduce output
            - Calls ``network.pf()`` for power flow calculation
            - Checks convergence status for all snapshots
            - Reports any failed snapshots for debugging

        Example:
            >>> if modeler.solve_powerflow():
            ...     print("Power flow converged successfully")
            ... else:
            ...     print("Power flow failed to converge")
        """

        # Suppress PyPSA logging
        logger = logging.getLogger("pypsa")
        previous_level = logger.level
        logger.setLevel(logging.WARNING)

        try:
            # Optional: suppress stdout too, just in case
            with io.StringIO() as buf, contextlib.redirect_stdout(buf):
                # === Power Flow Solution ===
                pf_start = time.time()
                results = self.network.pf()
                pf_time = time.time() - pf_start

        except Exception as e:
            if log:
                self.report.add_pf_solve_data(
                    solve_time=0.0, iterations=0, converged=0, msg=e
                )
            return 0

        if log:
            self.report.add_pf_solve_data(
                solve_time=pf_time,
                iterations=results.n_iter.iloc[0][0],
                converged=1,
                msg="converged",
            )
        return 1

    def import_raw_to_pypsa(self) -> bool:
        """Import PSS®E case file and build PyPSA Network.

        Builds a PyPSA Network from a parsed PSS®E RAW case file using the GRG parser.
        Converts PSS®E data structures and impedance values to PyPSA format, including
        buses, lines, generators, loads, transformers, and shunt impedances.

        Returns:
            bool: True if case import is successful, False otherwise.

        Raises:
            Exception: If case file parsing fails or case is invalid.

        Notes:
            The import process includes:

            Bus Data:
            - Bus numbers, names, and base voltages [kV]
            - Voltage magnitude setpoints and limits [pu]
            - Bus type mapping (PQ, PV, Slack)

            Line Data:
            - Resistance and reactance converted from [pu] to [Ohm]
            - Conductance and susceptance converted from [pu] to [Siemens]
            - Thermal ratings [MVA]

            Generator Data:
            - Active and reactive power setpoints [MW], [MVAr]
            - Power limits and control modes
            - Generator status and carrier type

            Load Data:
            - Active and reactive power consumption [MW], [MVAr]
            - Load status and bus assignment

            Transformer Data:
            - Impedance values normalized to transformer base [pu]
            - Tap ratios and phase shift angles [degrees]
            - Thermal ratings [MVA]

            Shunt Data:
            - Conductance and susceptance [Siemens]
            - Status and bus assignment
        """
        try:
            # Temporarily silence GRG's print_err
            original_print_err = grgio.print_err
            grgio.print_err = lambda *args, **kwargs: None

            self.parser = parse_psse_case_file(self.engine.case_file)

            # Restore original print_err
            grgio.print_err = original_print_err

            # Validate case
            if not self.parser or not self.parser.buses:
                print("[GRG ERROR] Parsed case is empty or invalid.")
                return False

            self.sbase = self.parser.sbase
            self.network = pypsa.Network(s_n_mva=self.sbase)

        except Exception as e:
            print(f"[GRG ERROR] Failed to parse case: {e}")
            return False

        self.parser.bus_lookup = {bus.i: bus for bus in self.parser.buses}

        # Mapping PSS/E bus types to PyPSA control types
        ide_to_ctrl = {1: "PQ", 2: "PV", 3: "Slack"}

        # --- Add Buses ---
        for bus in self.parser.buses:
            self.network.add(
                "Bus",
                name=str(bus.i),
                v_nom=bus.basekv,  # [kV]
                v_mag_pu_set=bus.vm,  # [pu]
                v_mag_pu_min=bus.nvlo,  # [pu]
                v_mag_pu_max=bus.nvhi,  # [pu]
                control=ide_to_ctrl.get(bus.ide, "PQ"),
            )

        # --- Add Lines (Branches) ---
        for idx, br in enumerate(self.parser.branches):
            line_name = f"L{idx}"
            S_base_MVA = self.parser.sbase
            V_base_kV = self.network.buses.at[str(br.i), "v_nom"]

            # Convert PSS®E p.u. values to physical units
            r_ohm = br.r * (V_base_kV**2) / S_base_MVA
            x_ohm = br.x * (V_base_kV**2) / S_base_MVA
            g_siemens = (br.gi + br.gj) * S_base_MVA / (V_base_kV**2)
            b_siemens = (br.bi + br.bj) * S_base_MVA / (V_base_kV**2)

            self.network.add(
                "Line",
                name=line_name,
                bus0=str(br.i),
                bus1=str(br.j),
                type="",
                r=r_ohm,
                x=x_ohm,
                g=g_siemens,
                b=b_siemens,
                s_nom=br.ratea,
                s_nom_extendable=False,
                length=br.len,
                v_ang_min=-inf,
                v_ang_max=inf,
            )

        # --- Add Generators ---
        for idx, g in enumerate(self.parser.generators):
            if g.stat != 1:
                continue
            gname = f"G{idx}"
            S_base_MVA = self.parser.sbase

            # Control type from IDE (bus type), fallback to "PQ"
            ctrl = ide_to_ctrl.get(self.parser.bus_lookup[g.i].ide, "PQ")

            # Active power limits and nominal power
            p_nom = g.pt  # pt (float): active power output upper bound (MW)
            p_nom_min = g.pb  # pb (float): active power output lower bound (MW)
            p_set = g.pg  # pg (float): active power output (MW)
            p_min_pu = g.pb / g.pt if g.pt != 0 else 0.0  # Avoid div by zero

            # Reactive setpoint
            q_set = g.qg  # qg (float): reactive power output (MVAr)

            # Optional: carrier type (e.g., detect wind)
            carrier = "wind" if getattr(g, "wmod", 0) != 0 else "other"

            self.network.add(
                "Generator",
                name=gname,
                bus=str(g.i),
                control=ctrl,
                p_nom=p_nom,
                p_nom_extendable=False,
                p_nom_min=p_nom_min,
                p_nom_max=p_nom,
                p_min_pu=p_min_pu,
                p_max_pu=1.0,
                p_set=p_set,
                q_set=q_set,
                carrier=carrier,
                efficiency=1.0,  # Default unless you have a better estimate
            )

        # --- Add Loads ---
        for idx, load in enumerate(self.parser.loads):
            if load.status != 1:
                continue  # Skip out-of-service loads

            lname = f"L{idx}"

            self.network.add(
                "Load",
                name=lname,
                bus=str(load.i),
                carrier="AC",  # Default for electrical loads
                p_set=load.pl,
                q_set=load.ql,
            )
        # --- Add Transformers ---
        for idx, tx in enumerate(self.parser.transformers):
            p1 = tx.p1
            p2 = tx.p2
            w1 = tx.w1
            w2 = tx.w2

            # Skip transformer if it's out of service (status not equal to 1 = fully in-service)
            if p1.stat != 1:
                continue

            # Transformer name and buses
            name = f"T{idx}"
            bus0 = str(p1.i)
            bus1 = str(p1.j)

            # Apparent power base (MVA)
            s_nom = w1.rata if w1.rata > 0.0 else p2.sbase12

            # Normalize impedance from sbase12 to s_nom
            r = p2.r12 * (p2.sbase12 / s_nom)
            x = p2.x12 * (p2.sbase12 / s_nom)

            # Optional magnetizing admittance (can be set to 0.0 if not used)
            g = p1.mag1 / s_nom if p1.mag1 != 0.0 else 0.0
            b = p1.mag2 / s_nom if p1.mag2 != 0.0 else 0.0

            # Tap ratio and angle shift
            tap_ratio = w1.windv
            phase_shift = w1.ang
            # --- Add Two-Winding Transformers ---
            self.network.add(
                "Transformer",
                name=name,
                bus0=bus0,
                bus1=bus1,
                type="",  # Use explicit parameters
                model="t",  # PyPSA's physical default
                r=r,
                x=x,
                g=g,
                b=b,
                s_nom=s_nom,
                s_nom_extendable=False,
                num_parallel=1,
                tap_ratio=tap_ratio,
                tap_side=0,
                phase_shift=phase_shift,
                # active      = True,
                v_ang_min=-180,
                v_ang_max=180,
            )

        # --- Add Shunt Impedances ---
        for idx, sh in enumerate(self.parser.switched_shunts):
            if sh.stat != 1:
                continue  # Skip out-of-service shunts

            # For switched shunts, calculate total susceptance from initial + all blocks
            # binit is in MVAr at 1.0 pu voltage on system base
            total_susceptance_mvar = sh.binit

            # Add all switched shunt blocks that are available
            blocks = [
                (sh.n1, sh.b1),
                (sh.n2, sh.b2),
                (sh.n3, sh.b3),
                (sh.n4, sh.b4),
                (sh.n5, sh.b5),
                (sh.n6, sh.b6),
                (sh.n7, sh.b7),
                (sh.n8, sh.b8),
            ]

            # For initial power flow, only use binit (fixed part)
            # Switchable blocks would be controlled during operation
            for n_steps, b_increment in blocks:
                if n_steps is not None and b_increment is not None and n_steps > 0:
                    # Conservative: assume steps are off initially
                    pass

            # Skip shunts with zero susceptance
            if abs(total_susceptance_mvar) < 1e-6:
                continue

            # Convert MVAr to Siemens
            # PSS®E shunt: binit is "MVAr per unit voltage"
            # This means: at 1.0 pu voltage (= V_base_kV), reactive power = binit MVAr
            # Formula: B_siemens = Q_MVAr_at_rated_voltage / V_base_kV^2
            v_base_kv = self.network.buses.at[str(sh.i), "v_nom"]

            # Convert: B = Q / V^2 (Siemens = MVAr / kV^2)
            b_siemens = total_susceptance_mvar / (v_base_kv**2)

            # Additional check for reasonable values
            if abs(b_siemens) > 1000:  # Very large susceptance values
                print(
                    f"[WARNING] Large shunt susceptance at bus {sh.i}: {b_siemens:.6f} S"
                )
                continue

            shunt_name = f"Shunt_{idx}"

            self.network.add(
                "ShuntImpedance",
                name=shunt_name,
                bus=str(sh.i),
                g=0.0,  # Switched shunts typically don't have conductance
                b=b_siemens,
            )
        return 1

    def add_wec_farm(self, farm) -> bool:
        """Add a WEC farm to the PyPSA model.

        This method adds a WEC farm to the PyPSA model by creating the necessary
        electrical infrastructure: a new bus for the WEC farm, a generator on that bus,
        and a transmission line connecting it to the existing grid.

        Args:
            farm (WECFarm): The WEC farm object containing connection details including
                bus_location, connecting_bus, and farm identification.

        Returns:
            bool: True if the farm is added successfully, False otherwise.

        Raises:
            Exception: If the WEC farm cannot be added due to PyPSA errors.

        Notes:
            The WEC farm addition process includes:

            Bus Creation:
            - Creates new bus at farm.bus_location
            - Uses same voltage level as connecting bus [kV]
            - Sets AC carrier type for electrical connection

            Line Creation:
            - Adds transmission line between WEC bus and grid
            - Uses hardcoded impedance values [Ohm]
            - Sets thermal rating [MVA]

            Generator Creation:
            - Adds WEC generator with wave energy carrier type
            - Initial power setpoint of 0.0 [MW]
            - PV control mode for voltage regulation

        TODO:
            Replace hardcoded line impedance values with calculated values
            based on farm specifications and connection distance.
        """
        try:
            self.network.add(
                "Bus",
                name=str(farm.bus_location),
                v_nom=self.network.buses.at[str(farm.connecting_bus), "v_nom"],
                carrier="AC",
            )
            self.network.add(
                "Line",
                name=f"WEC Line {farm.bus_location}",  # todo updat this to follow convention
                bus0=str(farm.bus_location),
                bus1=str(farm.connecting_bus),
                r=0.01,
                x=0.05,
                s_nom=130.00,
            )
            self.network.add(
                "Generator",
                name=f"W{farm.farm_id}",
                bus=str(farm.bus_location),
                carrier="wave",
                p_set=0.0,
                control="PV",
            )
            self.grid = (
                GridState()
            )  # TODO Reset state after adding farm but should be a bette way
            self.grid.software = "pypsa"
            self.grid.case = self.engine.case_name
            self.solve_powerflow()
            self.take_snapshot(
                timestamp=self.engine.time.start_time
            )  # Update grid state

            return True
        except Exception as e:
            print(f"[PyPSA ERROR]: Failed to add WEC Components: {e}")
            return False

    # def simulate(self, load_curve=None) -> bool:
    #     """Simulate the PyPSA grid over time with WEC farm updates.

    #     Simulates the PyPSA grid over a series of time snapshots, updating WEC farm
    #     generator outputs and optionally bus loads at each time step. For each
    #     snapshot, the method updates generator power outputs, applies load changes
    #     if provided, solves the power flow, and captures the grid state.

    #     Args:
    #         load_curve (Optional[pd.DataFrame]): DataFrame containing load values for
    #             each bus at each snapshot. Index should be snapshots (same dtype/order
    #             as self.engine.time.snapshots), columns should be bus IDs. If None,
    #             loads remain constant.

    #     Returns:
    #         bool: True if the simulation completes successfully.

    #     Raises:
    #         Exception: If there is an error setting generator power, setting load data,
    #             or solving the power flow at any snapshot.

    #     Notes:
    #         The simulation process includes:

    #         WEC Generator Updates:
    #         - Updates WEC generator power setpoints [MW]
    #         - Converts from per-unit to MW using system base (self.sbase)
    #         - Uses farm power curve data for each time snapshot

    #         Load Updates (if load_curve provided):
    #         - Updates bus load values [MW]
    #         - Converts from per-unit to MW using system base
    #         - Maps bus numbers to PyPSA load component names

    #         Power Flow Solution:
    #         - Solves power flow at each time step
    #         - Captures grid state snapshots for analysis
    #         - Provides progress indication via tqdm
    #     """
    #     n = self.network
    #     sbase = float(self.sbase)

    #     # ---------- timing ----------
    #     if not hasattr(self, "_timing_data"):
    #         self._timing_data = {
    #             "simulation_total": 0.0,
    #             "iteration_times": [],
    #             "solve_powerflow_times": [],
    #             "take_snapshot_times": [],
    #         }

    #     # ---------- mappings (once) ----------
    #     bus_to_load = {str(bus): name for name, bus in n.loads["bus"].items()}

    #     mapped_cols = []
    #     mapped_load_ids = []
    #     load_row_pos = None
    #     pset_col_pos = None
    #     fast_row_lookup = False
    #     load_curve_np = None

    #     if load_curve is not None and not load_curve.empty:
    #         for col in load_curve.columns:
    #             lid = bus_to_load.get(str(col))
    #             if lid is not None and lid in n.loads.index:
    #                 mapped_cols.append(col)
    #                 mapped_load_ids.append(lid)

    #         if mapped_load_ids:
    #             load_row_pos = n.loads.index.get_indexer(pd.Index(mapped_load_ids))
    #             pset_col_pos = n.loads.columns.get_loc("p_set")

    #             fast_row_lookup = load_curve.index.equals(self.engine.time.snapshots)
    #             if fast_row_lookup:
    #                 load_curve_np = load_curve[mapped_cols].to_numpy(dtype=float, copy=False)

    #     # WEC farms → generator ids
    #     farm_objs = list(self.engine.wec_farms)
    #     farm_gen_ids = [f"W{farm.farm_id}" for farm in farm_objs]
    #     missing = [gid for gid in farm_gen_ids if gid not in n.generators.index]
    #     if missing:
    #         raise ValueError(f"Missing WEC generators in network: {missing}")

    #     # ---------- main loop ----------
    #     t0 = time.perf_counter()
    #     for t_idx, snapshot in enumerate(tqdm(self.engine.time.snapshots, desc="PyPSA Simulating", unit="step")):
    #         step_start = time.perf_counter()

    #         # --- WEC generator p_set (vector) ---
    #         if farm_gen_ids:
    #             p_mw = np.fromiter(
    #                 (farm.power_at_snapshot(snapshot) * sbase for farm in farm_objs),
    #                 dtype=float,
    #                 count=len(farm_objs),
    #             )
    #             n.generators.loc[farm_gen_ids, "p_set"] = p_mw

    #         # --- Loads p_set (vector) ---
    #         if mapped_cols:
    #             if fast_row_lookup:
    #                 row = load_curve_np[t_idx] * sbase
    #             else:
    #                 row = load_curve.loc[snapshot, mapped_cols].to_numpy(dtype=float, copy=False) * sbase
    #             n.loads.iloc[load_row_pos, pset_col_pos] = row

    #         # --- solve PF + snapshot timing ---
    #         pf_start = time.perf_counter()
    #         ok = self.solve_powerflow()
    #         pf_end = time.perf_counter()
    #         if not ok:
    #             raise Exception(f"Powerflow failed at snapshot {snapshot}")
    #         self._timing_data["solve_powerflow_times"].append(pf_end - pf_start)

    #         snap_start = time.perf_counter()
    #         self.take_snapshot(timestamp=snapshot)
    #         snap_end = time.perf_counter()
    #         self._timing_data["take_snapshot_times"].append(snap_end - snap_start)

    #         step_end = time.perf_counter()
    #         self._timing_data["iteration_times"].append(step_end - step_start)

    #     self._timing_data["simulation_total"] = time.perf_counter() - t0
    #     return True

    def simulate(self, load_curve=None) -> bool:
        """Simulate the PyPSA grid over time with WEC farm updates.

        Args:
            load_curve (Optional[pd.DataFrame]): DataFrame containing load values for
                each bus at each snapshot. Index should be snapshots, columns should
                be bus IDs. If None, loads remain constant.

        Returns:
            bool: True if the simulation completes successfully.
        """

        # Create clean bus-to-load mapping
        bus_to_load = {}
        for load_idx, bus_num in self.network.loads["bus"].items():
            bus_to_load[str(bus_num)] = load_idx

        # Map WEC farms to their generator names
        wec_generators = {}
        available_gens = list(self.network.generators.index)

        for farm in self.engine.wec_farms:
            # Try common WEC generator naming patterns
            possible_names = [f"W{farm.farm_id}", f"WEC_{farm.farm_id}", farm.farm_name]
            gen_name = next(
                (name for name in possible_names if name in available_gens), None
            )

            if gen_name is None:
                print(f"Error: WEC generator for farm {farm.farm_id} not found")
                return False

            wec_generators[farm.farm_id] = gen_name

        # Main simulation loop
        sim_start = time.time()

        for snapshot in tqdm(
            self.engine.time.snapshots, desc="PyPSA Simulating", unit="step"
        ):
            self.report.add_snapshot(snapshot)
            iter_start = time.time()

            # Update WEC generators
            for farm in self.engine.wec_farms:
                gen_name = wec_generators[farm.farm_id]
                power_pu = farm.power_at_snapshot(snapshot)
                power_mw = power_pu * self.sbase  # Convert pu -> MW
                # print(f"{farm.farm_name}: Seting {gen_name} - {power_mw} MW")
                self.network.generators.at[gen_name, "p_set"] = power_mw

            # Update loads if provided
            if load_curve is not None and snapshot in load_curve.index:
                for bus_str in load_curve.columns:
                    if str(bus_str) in bus_to_load:
                        load_name = bus_to_load[str(bus_str)]
                        load_pu = load_curve.loc[snapshot, bus_str]
                        if not pd.isna(load_pu):
                            load_mw = float(load_pu) * self.sbase
                            self.network.loads.at[load_name, "p_set"] = load_mw

            # Solve power flow
            results = self.solve_powerflow(log=True)
            if results:
                snap_start = time.time()
                self.take_snapshot(timestamp=snapshot)
                self.report.add_snapshot_data(time.time() - snap_start)
            else:
                raise Exception(f"Powerflow failed at snapshot {snapshot}")

            self.report.add_iteration_time(time.time() - iter_start)

        # log simulation end
        self.report.simulation_time = time.time() - sim_start
        return True

    def take_snapshot(self, timestamp: datetime) -> None:
        """Take a snapshot of the current grid state.

        Captures the current state of all grid components (buses, generators, lines,
        and loads) at the specified timestamp and updates the grid state object.

        Args:
            timestamp (datetime): The timestamp for the snapshot.

        Returns:
            None

        Note:
            This method calls individual snapshot methods for each component type
            and updates the internal grid state with time-series data.
        """
        self.grid.update("bus", timestamp, self.snapshot_buses())
        self.grid.update("gen", timestamp, self.snapshot_generators())
        self.grid.update("line", timestamp, self.snapshot_lines())
        self.grid.update("load", timestamp, self.snapshot_loads())

    def snapshot_buses(self) -> pd.DataFrame:
        """Capture current bus state from PyPSA.

        Builds a Pandas DataFrame of the current bus state for the loaded PyPSA network.
        The DataFrame is formatted according to the GridState specification and includes
        bus voltage, power injection, and control data.

        Returns:
            pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag,
                angle_deg, Vbase. Index represents individual buses.

        Notes:
            The following PyPSA network data is used to create bus snapshots:

            link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#bus

            Bus Information:
            - Bus names and numbers "name" (converted from string indices) [dimensionless]
            - Bus control types "type"(PQ, PV, Slack) [string]
            - Base voltage levels "v_nom" [kV]

            Electrical Quantities:
            - Active and reactive power injections "p", "q" [MW], [MVAr] → [pu]
            - Voltage magnitude "v_mag_pu" [pu] of v_nom
            - Voltage angle "v_ang" [radians] → [degrees]

            Time Series Data:
            - Uses latest snapshot from network.snapshots
            - Defaults to steady-state values if no time series available
        """
        n = self.network
        buses = n.buses  # index = bus names (strings)

        # choose the latest snapshot (or change to a passed-in timestamp)
        if len(n.snapshots) > 0:
            ts = n.snapshots[-1]
            p_MW = (
                getattr(n.buses_t, "p", pd.DataFrame())
                .reindex(index=[ts], columns=buses.index)
                .iloc[0]
                .fillna(0.0)
            )
            q_MVAr = (
                getattr(n.buses_t, "q", pd.DataFrame())
                .reindex(index=[ts], columns=buses.index)
                .iloc[0]
                .fillna(0.0)
            )
            vmag_pu = (
                getattr(n.buses_t, "v_mag_pu", pd.DataFrame())
                .reindex(index=[ts], columns=buses.index)
                .iloc[0]
                .fillna(1.0)
            )
            vang_rad = (
                getattr(n.buses_t, "v_ang", pd.DataFrame())
                .reindex(index=[ts], columns=buses.index)
                .iloc[0]
                .fillna(0.0)
            )
        else:
            # no time series yet
            idx = buses.index
            p_MW = pd.Series(0.0, index=idx)
            q_MVAr = pd.Series(0.0, index=idx)
            vmag_pu = pd.Series(1.0, index=idx)
            vang_rad = pd.Series(0.0, index=idx)

        df = pd.DataFrame(
            {
                "bus": buses.index.astype(int),
                "bus_name": [f"Bus_{int(bus_id)}" for bus_id in buses.index],
                "type": buses.get("control", pd.Series("PQ", index=buses.index)).fillna(
                    "PQ"
                ),
                "p": (p_MW / self.sbase).astype(float),
                "q": (q_MVAr / self.sbase).astype(float),
                "v_mag": vmag_pu.astype(float),
                "angle_deg": np.degrees(vang_rad.astype(float)),
                "vbase": buses.get(
                    "v_nom", pd.Series(np.nan, index=buses.index)
                ).astype(float),
            }
        )

        df.attrs["df_type"] = "BUS"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_generators(self) -> pd.DataFrame:
        """Capture current generator state from PyPSA.

        Builds a Pandas DataFrame of the current generator state for the loaded PyPSA network.
        The DataFrame includes generator power output, base power, and status information.

        Returns:
            pd.DataFrame: DataFrame with columns: gen, bus, p, q, base, status.
                Generator names are formatted as "bus_count" (e.g., "1_1", "1_2").

        Notes:
            The following PyPSA network data is used to create generator snapshots:
            link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#generator

            Generator Information:
            - Generator names and bus assignments [dimensionless]
            - Active and reactive power output [MW], [MVAr] → [pu]
            - Generator status and availability [dimensionless]

            Time Series Data:
            - Uses latest snapshot from generators_t for power values
            - Uses generator 'active' attribute for status if available
            - Per-bus counter for consistent naming convention

            Power Conversion:
            - All power values converted to per-unit on system base
            - System base MVA used for normalization [MVA]
        """

        n = self.network
        gens = n.generators
        sbase = self.sbase

        if len(n.snapshots) > 0:
            ts = n.snapshots[-1]
            p_MW = (
                getattr(n.generators_t, "p", pd.DataFrame())
                .reindex(index=[ts], columns=gens.index)
                .iloc[0]
                .fillna(0.0)
            )
            q_MVAr = (
                getattr(n.generators_t, "q", pd.DataFrame())
                .reindex(index=[ts], columns=gens.index)
                .iloc[0]
                .fillna(0.0)
            )
            stat = (
                getattr(n.generators_t, "status", pd.DataFrame())
                .reindex(index=[ts], columns=gens.index)
                .iloc[0]
            )
            if stat.isna().all() and "active" in gens.columns:
                stat = gens["active"].astype(int).reindex(gens.index).fillna(1)
            else:
                stat = stat.fillna(1).astype(int)
        else:
            idx = gens.index
            p_MW = pd.Series(0.0, index=idx)
            q_MVAr = pd.Series(0.0, index=idx)
            stat = pd.Series(1, index=idx, dtype=int)

        # Counter per bus for naming

        bus_nums = []
        gen_ids = []
        gen_names = []

        for i, bus in enumerate(gens["bus"]):
            try:
                bus_num = int(bus)
            except Exception:
                bus_num = bus
            bus_nums.append(bus_num)
            gen_ids.append(i + 1)
            gen_names.append(f"Gen_{i+1}")

        df = pd.DataFrame(
            {
                "gen": gen_ids,
                "gen_name": gen_names,
                "bus": bus_nums,
                "p": (p_MW / sbase).astype(float),
                "q": (q_MVAr / sbase).astype(float),
                "Mbase": 0.0,  # MBASE not avaiable
                "status": stat.astype(int),
            }
        )

        df.attrs["df_type"] = "GEN"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_lines(self) -> pd.DataFrame:
        """Capture current transmission line state from PyPSA.

        Builds a Pandas DataFrame of the current transmission line state for the loaded
        PyPSA network. The DataFrame includes line loading percentages and connection
        information.

        Returns:
            pd.DataFrame: DataFrame with columns: line, ibus, jbus, line_pct, status.
                Line names are formatted as "Line_ibus_jbus_count".

        Notes:
            The following PyPSA network data is used to create line snapshots:

            Line Information:
            - Line bus connections (bus0, bus1) [dimensionless]
            - Line thermal ratings (s_nom) [MVA]
            - Line status (assumed active = 1)

            Power Flow Data:
            - Active power flow at both ends [MW]
            - Reactive power flow at both ends [MVAr]
            - Apparent power calculated from P and Q [MVA]
            - Line loading as percentage of thermal rating [%]

            Naming Convention:
            - Lines named as "Line_ibus_jbus_count" for consistency
            - Per-bus-pair counter for multiple parallel lines
            - Bus numbers converted from PyPSA string indices
        """

        n = self.network

        # choose latest snapshot if available
        if len(n.snapshots) > 0:
            ts = n.snapshots[-1]
            p0 = (
                getattr(n.lines_t, "p0", pd.DataFrame())
                .reindex(index=[ts], columns=n.lines.index)
                .iloc[0]
                .fillna(0.0)
            )
            q0 = (
                getattr(n.lines_t, "q0", pd.DataFrame())
                .reindex(index=[ts], columns=n.lines.index)
                .iloc[0]
                .fillna(0.0)
            )
            p1 = (
                getattr(n.lines_t, "p1", pd.DataFrame())
                .reindex(index=[ts], columns=n.lines.index)
                .iloc[0]
                .fillna(0.0)
            )
            q1 = (
                getattr(n.lines_t, "q1", pd.DataFrame())
                .reindex(index=[ts], columns=n.lines.index)
                .iloc[0]
                .fillna(0.0)
            )
        else:
            # no time series → assume zero flow
            idx = n.lines.index
            p0 = pd.Series(0.0, index=idx)
            q0 = pd.Series(0.0, index=idx)
            p1 = pd.Series(0.0, index=idx)
            q1 = pd.Series(0.0, index=idx)

        rows = []

        for i, (line_name, line) in enumerate(n.lines.iterrows()):
            ibus_name, jbus_name = line.bus0, line.bus1

            ibus = int(ibus_name)
            jbus = int(jbus_name)

            line_id = i + 1

            # apparent power (MVA) at each end
            S0 = np.hypot(p0[line_name], q0[line_name])
            S1 = np.hypot(p1[line_name], q1[line_name])
            Smax = max(S0, S1)

            s_nom = float(line.s_nom) if pd.notna(line.s_nom) else np.nan
            line_pct = float(100.0 * Smax / s_nom) if s_nom and s_nom > 0 else np.nan

            rows.append(
                {
                    "line": line_id,
                    "line_name": f"Line_{line_id}",
                    "ibus": ibus,
                    "jbus": jbus,
                    "line_pct": line_pct,  # % of s_nom at latest snapshot
                    "status": 1,  # hard coded
                }
            )

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "LINE"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

    def snapshot_loads(self) -> pd.DataFrame:
        """Capture current load state from PyPSA.

        Builds a Pandas DataFrame of the current load state for the loaded PyPSA network.
        The DataFrame includes load power consumption and status information for all
        buses with loads.

        Returns:
            pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status.
                Load names are formatted as "Load_bus_count".

        Notes:
            The following PyPSA network data is used to create load snapshots:

            link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#load

            Load Information:
            - Load names and bus assignments [dimensionless]
            - Active and reactive power consumption [MW], [MVAr] → [pu]
            - Load status from 'active' attribute [dimensionless]

            Time Series Data:
            - Uses latest snapshot from loads_t for power values
            - Defaults to steady-state values if no time series available
            - Per-bus counter for consistent naming convention

            Power Conversion:
            - All power values converted to per-unit on system base
            - System base MVA used for normalization [MVA]
        """
        n = self.network
        sbase = float(self.sbase)

        # latest snapshot values (MW / MVAr)
        if len(n.snapshots) and hasattr(n.loads_t, "p") and hasattr(n.loads_t, "q"):
            ts = n.snapshots[-1]
            p_MW = (
                n.loads_t.p.reindex(index=[ts], columns=n.loads.index)
                .iloc[0]
                .fillna(0.0)
            )
            q_MVAr = (
                n.loads_t.q.reindex(index=[ts], columns=n.loads.index)
                .iloc[0]
                .fillna(0.0)
            )
        else:
            idx = n.loads.index
            p_MW = pd.Series(0.0, index=idx)
            q_MVAr = pd.Series(0.0, index=idx)

        # status: use 'active' if present, else assume in-service
        has_active = "active" in getattr(n.loads, "columns", [])
        status_series = (
            n.loads["active"].astype(bool)
            if has_active
            else pd.Series(True, index=n.loads.index)
        )

        rows = []
        count = 1
        for load_name, rec in n.loads.iterrows():
            bus = int(rec.bus)

            rows.append(
                {
                    "load": count,
                    "load_name": f"Load_{count}",
                    "bus": bus,
                    "p": float(p_MW.get(load_name, 0.0)) / sbase,
                    "q": float(q_MVAr.get(load_name, 0.0)) / sbase,
                    "status": 1 if bool(status_series.get(load_name, True)) else 0,
                }
            )
            count += 1

        df = pd.DataFrame(rows)
        df.attrs["df_type"] = "LOAD"
        df.index = pd.RangeIndex(start=0, stop=len(df))
        return df

add_wec_farm(farm)

Add a WEC farm to the PyPSA model.

This method adds a WEC farm to the PyPSA model by creating the necessary electrical infrastructure: a new bus for the WEC farm, a generator on that bus, and a transmission line connecting it to the existing grid.

Parameters:

Name Type Description Default
farm WECFarm

The WEC farm object containing connection details including bus_location, connecting_bus, and farm identification.

required

Returns:

Name Type Description
bool bool

True if the farm is added successfully, False otherwise.

Raises:

Type Description
Exception

If the WEC farm cannot be added due to PyPSA errors.

Notes

The WEC farm addition process includes:

Bus Creation: - Creates new bus at farm.bus_location - Uses same voltage level as connecting bus [kV] - Sets AC carrier type for electrical connection

Line Creation: - Adds transmission line between WEC bus and grid - Uses hardcoded impedance values [Ohm] - Sets thermal rating [MVA]

Generator Creation: - Adds WEC generator with wave energy carrier type - Initial power setpoint of 0.0 [MW] - PV control mode for voltage regulation

TODO

Replace hardcoded line impedance values with calculated values based on farm specifications and connection distance.

Source code in src/wecgrid/modelers/power_system/pypsa.py
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def add_wec_farm(self, farm) -> bool:
    """Add a WEC farm to the PyPSA model.

    This method adds a WEC farm to the PyPSA model by creating the necessary
    electrical infrastructure: a new bus for the WEC farm, a generator on that bus,
    and a transmission line connecting it to the existing grid.

    Args:
        farm (WECFarm): The WEC farm object containing connection details including
            bus_location, connecting_bus, and farm identification.

    Returns:
        bool: True if the farm is added successfully, False otherwise.

    Raises:
        Exception: If the WEC farm cannot be added due to PyPSA errors.

    Notes:
        The WEC farm addition process includes:

        Bus Creation:
        - Creates new bus at farm.bus_location
        - Uses same voltage level as connecting bus [kV]
        - Sets AC carrier type for electrical connection

        Line Creation:
        - Adds transmission line between WEC bus and grid
        - Uses hardcoded impedance values [Ohm]
        - Sets thermal rating [MVA]

        Generator Creation:
        - Adds WEC generator with wave energy carrier type
        - Initial power setpoint of 0.0 [MW]
        - PV control mode for voltage regulation

    TODO:
        Replace hardcoded line impedance values with calculated values
        based on farm specifications and connection distance.
    """
    try:
        self.network.add(
            "Bus",
            name=str(farm.bus_location),
            v_nom=self.network.buses.at[str(farm.connecting_bus), "v_nom"],
            carrier="AC",
        )
        self.network.add(
            "Line",
            name=f"WEC Line {farm.bus_location}",  # todo updat this to follow convention
            bus0=str(farm.bus_location),
            bus1=str(farm.connecting_bus),
            r=0.01,
            x=0.05,
            s_nom=130.00,
        )
        self.network.add(
            "Generator",
            name=f"W{farm.farm_id}",
            bus=str(farm.bus_location),
            carrier="wave",
            p_set=0.0,
            control="PV",
        )
        self.grid = (
            GridState()
        )  # TODO Reset state after adding farm but should be a bette way
        self.grid.software = "pypsa"
        self.grid.case = self.engine.case_name
        self.solve_powerflow()
        self.take_snapshot(
            timestamp=self.engine.time.start_time
        )  # Update grid state

        return True
    except Exception as e:
        print(f"[PyPSA ERROR]: Failed to add WEC Components: {e}")
        return False

import_raw_to_pypsa()

Import PSS®E case file and build PyPSA Network.

Builds a PyPSA Network from a parsed PSS®E RAW case file using the GRG parser. Converts PSS®E data structures and impedance values to PyPSA format, including buses, lines, generators, loads, transformers, and shunt impedances.

Returns:

Name Type Description
bool bool

True if case import is successful, False otherwise.

Raises:

Type Description
Exception

If case file parsing fails or case is invalid.

Notes

The import process includes:

Bus Data: - Bus numbers, names, and base voltages [kV] - Voltage magnitude setpoints and limits [pu] - Bus type mapping (PQ, PV, Slack)

Line Data: - Resistance and reactance converted from [pu] to [Ohm] - Conductance and susceptance converted from [pu] to [Siemens] - Thermal ratings [MVA]

Generator Data: - Active and reactive power setpoints [MW], [MVAr] - Power limits and control modes - Generator status and carrier type

Load Data: - Active and reactive power consumption [MW], [MVAr] - Load status and bus assignment

Transformer Data: - Impedance values normalized to transformer base [pu] - Tap ratios and phase shift angles [degrees] - Thermal ratings [MVA]

Shunt Data: - Conductance and susceptance [Siemens] - Status and bus assignment

Source code in src/wecgrid/modelers/power_system/pypsa.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
def import_raw_to_pypsa(self) -> bool:
    """Import PSS®E case file and build PyPSA Network.

    Builds a PyPSA Network from a parsed PSS®E RAW case file using the GRG parser.
    Converts PSS®E data structures and impedance values to PyPSA format, including
    buses, lines, generators, loads, transformers, and shunt impedances.

    Returns:
        bool: True if case import is successful, False otherwise.

    Raises:
        Exception: If case file parsing fails or case is invalid.

    Notes:
        The import process includes:

        Bus Data:
        - Bus numbers, names, and base voltages [kV]
        - Voltage magnitude setpoints and limits [pu]
        - Bus type mapping (PQ, PV, Slack)

        Line Data:
        - Resistance and reactance converted from [pu] to [Ohm]
        - Conductance and susceptance converted from [pu] to [Siemens]
        - Thermal ratings [MVA]

        Generator Data:
        - Active and reactive power setpoints [MW], [MVAr]
        - Power limits and control modes
        - Generator status and carrier type

        Load Data:
        - Active and reactive power consumption [MW], [MVAr]
        - Load status and bus assignment

        Transformer Data:
        - Impedance values normalized to transformer base [pu]
        - Tap ratios and phase shift angles [degrees]
        - Thermal ratings [MVA]

        Shunt Data:
        - Conductance and susceptance [Siemens]
        - Status and bus assignment
    """
    try:
        # Temporarily silence GRG's print_err
        original_print_err = grgio.print_err
        grgio.print_err = lambda *args, **kwargs: None

        self.parser = parse_psse_case_file(self.engine.case_file)

        # Restore original print_err
        grgio.print_err = original_print_err

        # Validate case
        if not self.parser or not self.parser.buses:
            print("[GRG ERROR] Parsed case is empty or invalid.")
            return False

        self.sbase = self.parser.sbase
        self.network = pypsa.Network(s_n_mva=self.sbase)

    except Exception as e:
        print(f"[GRG ERROR] Failed to parse case: {e}")
        return False

    self.parser.bus_lookup = {bus.i: bus for bus in self.parser.buses}

    # Mapping PSS/E bus types to PyPSA control types
    ide_to_ctrl = {1: "PQ", 2: "PV", 3: "Slack"}

    # --- Add Buses ---
    for bus in self.parser.buses:
        self.network.add(
            "Bus",
            name=str(bus.i),
            v_nom=bus.basekv,  # [kV]
            v_mag_pu_set=bus.vm,  # [pu]
            v_mag_pu_min=bus.nvlo,  # [pu]
            v_mag_pu_max=bus.nvhi,  # [pu]
            control=ide_to_ctrl.get(bus.ide, "PQ"),
        )

    # --- Add Lines (Branches) ---
    for idx, br in enumerate(self.parser.branches):
        line_name = f"L{idx}"
        S_base_MVA = self.parser.sbase
        V_base_kV = self.network.buses.at[str(br.i), "v_nom"]

        # Convert PSS®E p.u. values to physical units
        r_ohm = br.r * (V_base_kV**2) / S_base_MVA
        x_ohm = br.x * (V_base_kV**2) / S_base_MVA
        g_siemens = (br.gi + br.gj) * S_base_MVA / (V_base_kV**2)
        b_siemens = (br.bi + br.bj) * S_base_MVA / (V_base_kV**2)

        self.network.add(
            "Line",
            name=line_name,
            bus0=str(br.i),
            bus1=str(br.j),
            type="",
            r=r_ohm,
            x=x_ohm,
            g=g_siemens,
            b=b_siemens,
            s_nom=br.ratea,
            s_nom_extendable=False,
            length=br.len,
            v_ang_min=-inf,
            v_ang_max=inf,
        )

    # --- Add Generators ---
    for idx, g in enumerate(self.parser.generators):
        if g.stat != 1:
            continue
        gname = f"G{idx}"
        S_base_MVA = self.parser.sbase

        # Control type from IDE (bus type), fallback to "PQ"
        ctrl = ide_to_ctrl.get(self.parser.bus_lookup[g.i].ide, "PQ")

        # Active power limits and nominal power
        p_nom = g.pt  # pt (float): active power output upper bound (MW)
        p_nom_min = g.pb  # pb (float): active power output lower bound (MW)
        p_set = g.pg  # pg (float): active power output (MW)
        p_min_pu = g.pb / g.pt if g.pt != 0 else 0.0  # Avoid div by zero

        # Reactive setpoint
        q_set = g.qg  # qg (float): reactive power output (MVAr)

        # Optional: carrier type (e.g., detect wind)
        carrier = "wind" if getattr(g, "wmod", 0) != 0 else "other"

        self.network.add(
            "Generator",
            name=gname,
            bus=str(g.i),
            control=ctrl,
            p_nom=p_nom,
            p_nom_extendable=False,
            p_nom_min=p_nom_min,
            p_nom_max=p_nom,
            p_min_pu=p_min_pu,
            p_max_pu=1.0,
            p_set=p_set,
            q_set=q_set,
            carrier=carrier,
            efficiency=1.0,  # Default unless you have a better estimate
        )

    # --- Add Loads ---
    for idx, load in enumerate(self.parser.loads):
        if load.status != 1:
            continue  # Skip out-of-service loads

        lname = f"L{idx}"

        self.network.add(
            "Load",
            name=lname,
            bus=str(load.i),
            carrier="AC",  # Default for electrical loads
            p_set=load.pl,
            q_set=load.ql,
        )
    # --- Add Transformers ---
    for idx, tx in enumerate(self.parser.transformers):
        p1 = tx.p1
        p2 = tx.p2
        w1 = tx.w1
        w2 = tx.w2

        # Skip transformer if it's out of service (status not equal to 1 = fully in-service)
        if p1.stat != 1:
            continue

        # Transformer name and buses
        name = f"T{idx}"
        bus0 = str(p1.i)
        bus1 = str(p1.j)

        # Apparent power base (MVA)
        s_nom = w1.rata if w1.rata > 0.0 else p2.sbase12

        # Normalize impedance from sbase12 to s_nom
        r = p2.r12 * (p2.sbase12 / s_nom)
        x = p2.x12 * (p2.sbase12 / s_nom)

        # Optional magnetizing admittance (can be set to 0.0 if not used)
        g = p1.mag1 / s_nom if p1.mag1 != 0.0 else 0.0
        b = p1.mag2 / s_nom if p1.mag2 != 0.0 else 0.0

        # Tap ratio and angle shift
        tap_ratio = w1.windv
        phase_shift = w1.ang
        # --- Add Two-Winding Transformers ---
        self.network.add(
            "Transformer",
            name=name,
            bus0=bus0,
            bus1=bus1,
            type="",  # Use explicit parameters
            model="t",  # PyPSA's physical default
            r=r,
            x=x,
            g=g,
            b=b,
            s_nom=s_nom,
            s_nom_extendable=False,
            num_parallel=1,
            tap_ratio=tap_ratio,
            tap_side=0,
            phase_shift=phase_shift,
            # active      = True,
            v_ang_min=-180,
            v_ang_max=180,
        )

    # --- Add Shunt Impedances ---
    for idx, sh in enumerate(self.parser.switched_shunts):
        if sh.stat != 1:
            continue  # Skip out-of-service shunts

        # For switched shunts, calculate total susceptance from initial + all blocks
        # binit is in MVAr at 1.0 pu voltage on system base
        total_susceptance_mvar = sh.binit

        # Add all switched shunt blocks that are available
        blocks = [
            (sh.n1, sh.b1),
            (sh.n2, sh.b2),
            (sh.n3, sh.b3),
            (sh.n4, sh.b4),
            (sh.n5, sh.b5),
            (sh.n6, sh.b6),
            (sh.n7, sh.b7),
            (sh.n8, sh.b8),
        ]

        # For initial power flow, only use binit (fixed part)
        # Switchable blocks would be controlled during operation
        for n_steps, b_increment in blocks:
            if n_steps is not None and b_increment is not None and n_steps > 0:
                # Conservative: assume steps are off initially
                pass

        # Skip shunts with zero susceptance
        if abs(total_susceptance_mvar) < 1e-6:
            continue

        # Convert MVAr to Siemens
        # PSS®E shunt: binit is "MVAr per unit voltage"
        # This means: at 1.0 pu voltage (= V_base_kV), reactive power = binit MVAr
        # Formula: B_siemens = Q_MVAr_at_rated_voltage / V_base_kV^2
        v_base_kv = self.network.buses.at[str(sh.i), "v_nom"]

        # Convert: B = Q / V^2 (Siemens = MVAr / kV^2)
        b_siemens = total_susceptance_mvar / (v_base_kv**2)

        # Additional check for reasonable values
        if abs(b_siemens) > 1000:  # Very large susceptance values
            print(
                f"[WARNING] Large shunt susceptance at bus {sh.i}: {b_siemens:.6f} S"
            )
            continue

        shunt_name = f"Shunt_{idx}"

        self.network.add(
            "ShuntImpedance",
            name=shunt_name,
            bus=str(sh.i),
            g=0.0,  # Switched shunts typically don't have conductance
            b=b_siemens,
        )
    return 1

init_api()

Initialize the PyPSA environment and load the case.

This method sets up the PyPSA network by importing the PSS®E case file, creating the network structure, and performing initial power flow solution. It also takes an initial snapshot of the grid state.

Returns:

Name Type Description
bool bool

True if initialization is successful, False otherwise.

Raises:

Type Description
ImportError

If PyPSA or GRG dependencies are not found.

ValueError

If case file cannot be parsed or is invalid.

Notes

The initialization process includes:

  • Parsing PSS®E case file using GRG parser
  • Creating PyPSA Network with system base MVA [MVA]
  • Converting PSS®E impedance values to PyPSA format
  • Adding buses with voltage limits [kV] and control types
  • Adding lines with impedance [Ohm] and ratings [MVA]
  • Adding generators with power limits [MW], [MVAr]
  • Adding loads with power consumption [MW], [MVAr]
  • Adding transformers and shunt impedances
  • Solving initial power flow
Source code in src/wecgrid/modelers/power_system/pypsa.py
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
def init_api(self) -> bool:
    """Initialize the PyPSA environment and load the case.

    This method sets up the PyPSA network by importing the PSS®E case file,
    creating the network structure, and performing initial power flow solution.
    It also takes an initial snapshot of the grid state.

    Returns:
        bool: True if initialization is successful, False otherwise.

    Raises:
        ImportError: If PyPSA or GRG dependencies are not found.
        ValueError: If case file cannot be parsed or is invalid.

    Notes:
        The initialization process includes:

        - Parsing PSS®E case file using GRG parser
        - Creating PyPSA Network with system base MVA [MVA]
        - Converting PSS®E impedance values to PyPSA format
        - Adding buses with voltage limits [kV] and control types
        - Adding lines with impedance [Ohm] and ratings [MVA]
        - Adding generators with power limits [MW], [MVAr]
        - Adding loads with power consumption [MW], [MVAr]
        - Adding transformers and shunt impedances
        - Solving initial power flow
    """
    if not self.import_raw_to_pypsa():
        return False
    if not self.solve_powerflow():
        return False
    self.take_snapshot(timestamp=self.engine.time.start_time)  # populates self.grid
    # print("PyPSA software initialized")
    return True

simulate(load_curve=None)

Simulate the PyPSA grid over time with WEC farm updates.

Parameters:

Name Type Description Default
load_curve Optional[DataFrame]

DataFrame containing load values for each bus at each snapshot. Index should be snapshots, columns should be bus IDs. If None, loads remain constant.

None

Returns:

Name Type Description
bool bool

True if the simulation completes successfully.

Source code in src/wecgrid/modelers/power_system/pypsa.py
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
def simulate(self, load_curve=None) -> bool:
    """Simulate the PyPSA grid over time with WEC farm updates.

    Args:
        load_curve (Optional[pd.DataFrame]): DataFrame containing load values for
            each bus at each snapshot. Index should be snapshots, columns should
            be bus IDs. If None, loads remain constant.

    Returns:
        bool: True if the simulation completes successfully.
    """

    # Create clean bus-to-load mapping
    bus_to_load = {}
    for load_idx, bus_num in self.network.loads["bus"].items():
        bus_to_load[str(bus_num)] = load_idx

    # Map WEC farms to their generator names
    wec_generators = {}
    available_gens = list(self.network.generators.index)

    for farm in self.engine.wec_farms:
        # Try common WEC generator naming patterns
        possible_names = [f"W{farm.farm_id}", f"WEC_{farm.farm_id}", farm.farm_name]
        gen_name = next(
            (name for name in possible_names if name in available_gens), None
        )

        if gen_name is None:
            print(f"Error: WEC generator for farm {farm.farm_id} not found")
            return False

        wec_generators[farm.farm_id] = gen_name

    # Main simulation loop
    sim_start = time.time()

    for snapshot in tqdm(
        self.engine.time.snapshots, desc="PyPSA Simulating", unit="step"
    ):
        self.report.add_snapshot(snapshot)
        iter_start = time.time()

        # Update WEC generators
        for farm in self.engine.wec_farms:
            gen_name = wec_generators[farm.farm_id]
            power_pu = farm.power_at_snapshot(snapshot)
            power_mw = power_pu * self.sbase  # Convert pu -> MW
            # print(f"{farm.farm_name}: Seting {gen_name} - {power_mw} MW")
            self.network.generators.at[gen_name, "p_set"] = power_mw

        # Update loads if provided
        if load_curve is not None and snapshot in load_curve.index:
            for bus_str in load_curve.columns:
                if str(bus_str) in bus_to_load:
                    load_name = bus_to_load[str(bus_str)]
                    load_pu = load_curve.loc[snapshot, bus_str]
                    if not pd.isna(load_pu):
                        load_mw = float(load_pu) * self.sbase
                        self.network.loads.at[load_name, "p_set"] = load_mw

        # Solve power flow
        results = self.solve_powerflow(log=True)
        if results:
            snap_start = time.time()
            self.take_snapshot(timestamp=snapshot)
            self.report.add_snapshot_data(time.time() - snap_start)
        else:
            raise Exception(f"Powerflow failed at snapshot {snapshot}")

        self.report.add_iteration_time(time.time() - iter_start)

    # log simulation end
    self.report.simulation_time = time.time() - sim_start
    return True

snapshot_buses()

Capture current bus state from PyPSA.

Builds a Pandas DataFrame of the current bus state for the loaded PyPSA network. The DataFrame is formatted according to the GridState specification and includes bus voltage, power injection, and control data.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag, angle_deg, Vbase. Index represents individual buses.

Notes

The following PyPSA network data is used to create bus snapshots:

link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#bus

Bus Information: - Bus names and numbers "name" (converted from string indices) [dimensionless] - Bus control types "type"(PQ, PV, Slack) [string] - Base voltage levels "v_nom" [kV]

Electrical Quantities: - Active and reactive power injections "p", "q" [MW], [MVAr] → [pu] - Voltage magnitude "v_mag_pu" [pu] of v_nom - Voltage angle "v_ang" [radians] → [degrees]

Time Series Data: - Uses latest snapshot from network.snapshots - Defaults to steady-state values if no time series available

Source code in src/wecgrid/modelers/power_system/pypsa.py
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
def snapshot_buses(self) -> pd.DataFrame:
    """Capture current bus state from PyPSA.

    Builds a Pandas DataFrame of the current bus state for the loaded PyPSA network.
    The DataFrame is formatted according to the GridState specification and includes
    bus voltage, power injection, and control data.

    Returns:
        pd.DataFrame: DataFrame with columns: bus, bus_name, type, p, q, v_mag,
            angle_deg, Vbase. Index represents individual buses.

    Notes:
        The following PyPSA network data is used to create bus snapshots:

        link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#bus

        Bus Information:
        - Bus names and numbers "name" (converted from string indices) [dimensionless]
        - Bus control types "type"(PQ, PV, Slack) [string]
        - Base voltage levels "v_nom" [kV]

        Electrical Quantities:
        - Active and reactive power injections "p", "q" [MW], [MVAr] → [pu]
        - Voltage magnitude "v_mag_pu" [pu] of v_nom
        - Voltage angle "v_ang" [radians] → [degrees]

        Time Series Data:
        - Uses latest snapshot from network.snapshots
        - Defaults to steady-state values if no time series available
    """
    n = self.network
    buses = n.buses  # index = bus names (strings)

    # choose the latest snapshot (or change to a passed-in timestamp)
    if len(n.snapshots) > 0:
        ts = n.snapshots[-1]
        p_MW = (
            getattr(n.buses_t, "p", pd.DataFrame())
            .reindex(index=[ts], columns=buses.index)
            .iloc[0]
            .fillna(0.0)
        )
        q_MVAr = (
            getattr(n.buses_t, "q", pd.DataFrame())
            .reindex(index=[ts], columns=buses.index)
            .iloc[0]
            .fillna(0.0)
        )
        vmag_pu = (
            getattr(n.buses_t, "v_mag_pu", pd.DataFrame())
            .reindex(index=[ts], columns=buses.index)
            .iloc[0]
            .fillna(1.0)
        )
        vang_rad = (
            getattr(n.buses_t, "v_ang", pd.DataFrame())
            .reindex(index=[ts], columns=buses.index)
            .iloc[0]
            .fillna(0.0)
        )
    else:
        # no time series yet
        idx = buses.index
        p_MW = pd.Series(0.0, index=idx)
        q_MVAr = pd.Series(0.0, index=idx)
        vmag_pu = pd.Series(1.0, index=idx)
        vang_rad = pd.Series(0.0, index=idx)

    df = pd.DataFrame(
        {
            "bus": buses.index.astype(int),
            "bus_name": [f"Bus_{int(bus_id)}" for bus_id in buses.index],
            "type": buses.get("control", pd.Series("PQ", index=buses.index)).fillna(
                "PQ"
            ),
            "p": (p_MW / self.sbase).astype(float),
            "q": (q_MVAr / self.sbase).astype(float),
            "v_mag": vmag_pu.astype(float),
            "angle_deg": np.degrees(vang_rad.astype(float)),
            "vbase": buses.get(
                "v_nom", pd.Series(np.nan, index=buses.index)
            ).astype(float),
        }
    )

    df.attrs["df_type"] = "BUS"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_generators()

Capture current generator state from PyPSA.

Builds a Pandas DataFrame of the current generator state for the loaded PyPSA network. The DataFrame includes generator power output, base power, and status information.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: gen, bus, p, q, base, status. Generator names are formatted as "bus_count" (e.g., "1_1", "1_2").

Notes

The following PyPSA network data is used to create generator snapshots: link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#generator

Generator Information: - Generator names and bus assignments [dimensionless] - Active and reactive power output [MW], [MVAr] → [pu] - Generator status and availability [dimensionless]

Time Series Data: - Uses latest snapshot from generators_t for power values - Uses generator 'active' attribute for status if available - Per-bus counter for consistent naming convention

Power Conversion: - All power values converted to per-unit on system base - System base MVA used for normalization [MVA]

Source code in src/wecgrid/modelers/power_system/pypsa.py
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
def snapshot_generators(self) -> pd.DataFrame:
    """Capture current generator state from PyPSA.

    Builds a Pandas DataFrame of the current generator state for the loaded PyPSA network.
    The DataFrame includes generator power output, base power, and status information.

    Returns:
        pd.DataFrame: DataFrame with columns: gen, bus, p, q, base, status.
            Generator names are formatted as "bus_count" (e.g., "1_1", "1_2").

    Notes:
        The following PyPSA network data is used to create generator snapshots:
        link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#generator

        Generator Information:
        - Generator names and bus assignments [dimensionless]
        - Active and reactive power output [MW], [MVAr] → [pu]
        - Generator status and availability [dimensionless]

        Time Series Data:
        - Uses latest snapshot from generators_t for power values
        - Uses generator 'active' attribute for status if available
        - Per-bus counter for consistent naming convention

        Power Conversion:
        - All power values converted to per-unit on system base
        - System base MVA used for normalization [MVA]
    """

    n = self.network
    gens = n.generators
    sbase = self.sbase

    if len(n.snapshots) > 0:
        ts = n.snapshots[-1]
        p_MW = (
            getattr(n.generators_t, "p", pd.DataFrame())
            .reindex(index=[ts], columns=gens.index)
            .iloc[0]
            .fillna(0.0)
        )
        q_MVAr = (
            getattr(n.generators_t, "q", pd.DataFrame())
            .reindex(index=[ts], columns=gens.index)
            .iloc[0]
            .fillna(0.0)
        )
        stat = (
            getattr(n.generators_t, "status", pd.DataFrame())
            .reindex(index=[ts], columns=gens.index)
            .iloc[0]
        )
        if stat.isna().all() and "active" in gens.columns:
            stat = gens["active"].astype(int).reindex(gens.index).fillna(1)
        else:
            stat = stat.fillna(1).astype(int)
    else:
        idx = gens.index
        p_MW = pd.Series(0.0, index=idx)
        q_MVAr = pd.Series(0.0, index=idx)
        stat = pd.Series(1, index=idx, dtype=int)

    # Counter per bus for naming

    bus_nums = []
    gen_ids = []
    gen_names = []

    for i, bus in enumerate(gens["bus"]):
        try:
            bus_num = int(bus)
        except Exception:
            bus_num = bus
        bus_nums.append(bus_num)
        gen_ids.append(i + 1)
        gen_names.append(f"Gen_{i+1}")

    df = pd.DataFrame(
        {
            "gen": gen_ids,
            "gen_name": gen_names,
            "bus": bus_nums,
            "p": (p_MW / sbase).astype(float),
            "q": (q_MVAr / sbase).astype(float),
            "Mbase": 0.0,  # MBASE not avaiable
            "status": stat.astype(int),
        }
    )

    df.attrs["df_type"] = "GEN"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_lines()

Capture current transmission line state from PyPSA.

Builds a Pandas DataFrame of the current transmission line state for the loaded PyPSA network. The DataFrame includes line loading percentages and connection information.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: line, ibus, jbus, line_pct, status. Line names are formatted as "Line_ibus_jbus_count".

Notes

The following PyPSA network data is used to create line snapshots:

Line Information: - Line bus connections (bus0, bus1) [dimensionless] - Line thermal ratings (s_nom) [MVA] - Line status (assumed active = 1)

Power Flow Data: - Active power flow at both ends [MW] - Reactive power flow at both ends [MVAr] - Apparent power calculated from P and Q [MVA] - Line loading as percentage of thermal rating [%]

Naming Convention: - Lines named as "Line_ibus_jbus_count" for consistency - Per-bus-pair counter for multiple parallel lines - Bus numbers converted from PyPSA string indices

Source code in src/wecgrid/modelers/power_system/pypsa.py
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
def snapshot_lines(self) -> pd.DataFrame:
    """Capture current transmission line state from PyPSA.

    Builds a Pandas DataFrame of the current transmission line state for the loaded
    PyPSA network. The DataFrame includes line loading percentages and connection
    information.

    Returns:
        pd.DataFrame: DataFrame with columns: line, ibus, jbus, line_pct, status.
            Line names are formatted as "Line_ibus_jbus_count".

    Notes:
        The following PyPSA network data is used to create line snapshots:

        Line Information:
        - Line bus connections (bus0, bus1) [dimensionless]
        - Line thermal ratings (s_nom) [MVA]
        - Line status (assumed active = 1)

        Power Flow Data:
        - Active power flow at both ends [MW]
        - Reactive power flow at both ends [MVAr]
        - Apparent power calculated from P and Q [MVA]
        - Line loading as percentage of thermal rating [%]

        Naming Convention:
        - Lines named as "Line_ibus_jbus_count" for consistency
        - Per-bus-pair counter for multiple parallel lines
        - Bus numbers converted from PyPSA string indices
    """

    n = self.network

    # choose latest snapshot if available
    if len(n.snapshots) > 0:
        ts = n.snapshots[-1]
        p0 = (
            getattr(n.lines_t, "p0", pd.DataFrame())
            .reindex(index=[ts], columns=n.lines.index)
            .iloc[0]
            .fillna(0.0)
        )
        q0 = (
            getattr(n.lines_t, "q0", pd.DataFrame())
            .reindex(index=[ts], columns=n.lines.index)
            .iloc[0]
            .fillna(0.0)
        )
        p1 = (
            getattr(n.lines_t, "p1", pd.DataFrame())
            .reindex(index=[ts], columns=n.lines.index)
            .iloc[0]
            .fillna(0.0)
        )
        q1 = (
            getattr(n.lines_t, "q1", pd.DataFrame())
            .reindex(index=[ts], columns=n.lines.index)
            .iloc[0]
            .fillna(0.0)
        )
    else:
        # no time series → assume zero flow
        idx = n.lines.index
        p0 = pd.Series(0.0, index=idx)
        q0 = pd.Series(0.0, index=idx)
        p1 = pd.Series(0.0, index=idx)
        q1 = pd.Series(0.0, index=idx)

    rows = []

    for i, (line_name, line) in enumerate(n.lines.iterrows()):
        ibus_name, jbus_name = line.bus0, line.bus1

        ibus = int(ibus_name)
        jbus = int(jbus_name)

        line_id = i + 1

        # apparent power (MVA) at each end
        S0 = np.hypot(p0[line_name], q0[line_name])
        S1 = np.hypot(p1[line_name], q1[line_name])
        Smax = max(S0, S1)

        s_nom = float(line.s_nom) if pd.notna(line.s_nom) else np.nan
        line_pct = float(100.0 * Smax / s_nom) if s_nom and s_nom > 0 else np.nan

        rows.append(
            {
                "line": line_id,
                "line_name": f"Line_{line_id}",
                "ibus": ibus,
                "jbus": jbus,
                "line_pct": line_pct,  # % of s_nom at latest snapshot
                "status": 1,  # hard coded
            }
        )

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "LINE"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

snapshot_loads()

Capture current load state from PyPSA.

Builds a Pandas DataFrame of the current load state for the loaded PyPSA network. The DataFrame includes load power consumption and status information for all buses with loads.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status. Load names are formatted as "Load_bus_count".

Notes

The following PyPSA network data is used to create load snapshots:

link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#load

Load Information: - Load names and bus assignments [dimensionless] - Active and reactive power consumption [MW], [MVAr] → [pu] - Load status from 'active' attribute [dimensionless]

Time Series Data: - Uses latest snapshot from loads_t for power values - Defaults to steady-state values if no time series available - Per-bus counter for consistent naming convention

Power Conversion: - All power values converted to per-unit on system base - System base MVA used for normalization [MVA]

Source code in src/wecgrid/modelers/power_system/pypsa.py
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def snapshot_loads(self) -> pd.DataFrame:
    """Capture current load state from PyPSA.

    Builds a Pandas DataFrame of the current load state for the loaded PyPSA network.
    The DataFrame includes load power consumption and status information for all
    buses with loads.

    Returns:
        pd.DataFrame: DataFrame with columns: load, bus, p, q, base, status.
            Load names are formatted as "Load_bus_count".

    Notes:
        The following PyPSA network data is used to create load snapshots:

        link - https://pypsa.readthedocs.io/en/stable/user-guide/components.html#load

        Load Information:
        - Load names and bus assignments [dimensionless]
        - Active and reactive power consumption [MW], [MVAr] → [pu]
        - Load status from 'active' attribute [dimensionless]

        Time Series Data:
        - Uses latest snapshot from loads_t for power values
        - Defaults to steady-state values if no time series available
        - Per-bus counter for consistent naming convention

        Power Conversion:
        - All power values converted to per-unit on system base
        - System base MVA used for normalization [MVA]
    """
    n = self.network
    sbase = float(self.sbase)

    # latest snapshot values (MW / MVAr)
    if len(n.snapshots) and hasattr(n.loads_t, "p") and hasattr(n.loads_t, "q"):
        ts = n.snapshots[-1]
        p_MW = (
            n.loads_t.p.reindex(index=[ts], columns=n.loads.index)
            .iloc[0]
            .fillna(0.0)
        )
        q_MVAr = (
            n.loads_t.q.reindex(index=[ts], columns=n.loads.index)
            .iloc[0]
            .fillna(0.0)
        )
    else:
        idx = n.loads.index
        p_MW = pd.Series(0.0, index=idx)
        q_MVAr = pd.Series(0.0, index=idx)

    # status: use 'active' if present, else assume in-service
    has_active = "active" in getattr(n.loads, "columns", [])
    status_series = (
        n.loads["active"].astype(bool)
        if has_active
        else pd.Series(True, index=n.loads.index)
    )

    rows = []
    count = 1
    for load_name, rec in n.loads.iterrows():
        bus = int(rec.bus)

        rows.append(
            {
                "load": count,
                "load_name": f"Load_{count}",
                "bus": bus,
                "p": float(p_MW.get(load_name, 0.0)) / sbase,
                "q": float(q_MVAr.get(load_name, 0.0)) / sbase,
                "status": 1 if bool(status_series.get(load_name, True)) else 0,
            }
        )
        count += 1

    df = pd.DataFrame(rows)
    df.attrs["df_type"] = "LOAD"
    df.index = pd.RangeIndex(start=0, stop=len(df))
    return df

solve_powerflow(log=False)

Run power flow solution and check convergence.

Executes the PyPSA power flow solver with suppressed logging output and verifies that the solution converged successfully for all snapshots.

Returns:

Name Type Description
bool bool

True if power flow converged for all snapshots, False otherwise.

Notes

The power flow solution process:

  • Temporarily suppresses PyPSA logging to reduce output
  • Calls network.pf() for power flow calculation
  • Checks convergence status for all snapshots
  • Reports any failed snapshots for debugging
Example

if modeler.solve_powerflow(): ... print("Power flow converged successfully") ... else: ... print("Power flow failed to converge")

Source code in src/wecgrid/modelers/power_system/pypsa.py
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
def solve_powerflow(self, log: bool = False) -> bool:
    """Run power flow solution and check convergence.

    Executes the PyPSA power flow solver with suppressed logging output
    and verifies that the solution converged successfully for all snapshots.

    Returns:
        bool: True if power flow converged for all snapshots, False otherwise.

    Notes:
        The power flow solution process:

        - Temporarily suppresses PyPSA logging to reduce output
        - Calls ``network.pf()`` for power flow calculation
        - Checks convergence status for all snapshots
        - Reports any failed snapshots for debugging

    Example:
        >>> if modeler.solve_powerflow():
        ...     print("Power flow converged successfully")
        ... else:
        ...     print("Power flow failed to converge")
    """

    # Suppress PyPSA logging
    logger = logging.getLogger("pypsa")
    previous_level = logger.level
    logger.setLevel(logging.WARNING)

    try:
        # Optional: suppress stdout too, just in case
        with io.StringIO() as buf, contextlib.redirect_stdout(buf):
            # === Power Flow Solution ===
            pf_start = time.time()
            results = self.network.pf()
            pf_time = time.time() - pf_start

    except Exception as e:
        if log:
            self.report.add_pf_solve_data(
                solve_time=0.0, iterations=0, converged=0, msg=e
            )
        return 0

    if log:
        self.report.add_pf_solve_data(
            solve_time=pf_time,
            iterations=results.n_iter.iloc[0][0],
            converged=1,
            msg="converged",
        )
    return 1

take_snapshot(timestamp)

Take a snapshot of the current grid state.

Captures the current state of all grid components (buses, generators, lines, and loads) at the specified timestamp and updates the grid state object.

Parameters:

Name Type Description Default
timestamp datetime

The timestamp for the snapshot.

required

Returns:

Type Description
None

None

Note

This method calls individual snapshot methods for each component type and updates the internal grid state with time-series data.

Source code in src/wecgrid/modelers/power_system/pypsa.py
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
def take_snapshot(self, timestamp: datetime) -> None:
    """Take a snapshot of the current grid state.

    Captures the current state of all grid components (buses, generators, lines,
    and loads) at the specified timestamp and updates the grid state object.

    Args:
        timestamp (datetime): The timestamp for the snapshot.

    Returns:
        None

    Note:
        This method calls individual snapshot methods for each component type
        and updates the internal grid state with time-series data.
    """
    self.grid.update("bus", timestamp, self.snapshot_buses())
    self.grid.update("gen", timestamp, self.snapshot_generators())
    self.grid.update("line", timestamp, self.snapshot_lines())
    self.grid.update("load", timestamp, self.snapshot_loads())

WEC-Sim Runner

Interface for running WEC-Sim device-level simulations via MATLAB engine.

Simplified runner that manages MATLAB engine, executes WEC-Sim models from their native directories, and stores results in WEC-Grid database.

Attributes:

Name Type Description
wec_sim_path str

Path to WEC-Sim MATLAB installation.

database WECGridDB

Database interface for simulation data storage.

matlab_engine MatlabEngine

Active MATLAB engine.

Source code in src/wecgrid/modelers/wec_sim/runner.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
class WECSimRunner:
    """Interface for running WEC-Sim device-level simulations via MATLAB engine.

    Simplified runner that manages MATLAB engine, executes WEC-Sim models from
    their native directories, and stores results in WEC-Grid database.

    Attributes:
        wec_sim_path (str, optional): Path to WEC-Sim MATLAB installation.
        database (WECGridDB): Database interface for simulation data storage.
        matlab_engine (matlab.engine.MatlabEngine, optional): Active MATLAB engine.
    """

    def __init__(self, database: WECGridDB):
        """Initialize a WEC-Sim runner tied to a database connection.

        Args:
            database (WECGridDB): Interface used to store and retrieve
                simulation data.

        Attributes:
            wec_sim_path (Optional[str]): Location of the WEC-Sim MATLAB
                installation. Defaults to ``None`` and may be populated from
                configuration.
            matlab_engine (Optional[matlab.engine.MatlabEngine]): Handle to the
                MATLAB engine. ``None`` until :meth:`start_matlab` is called.

        Side Effects:
            Loads ``wecsim_config.json`` to populate ``wec_sim_path`` if a
            configuration exists.
        """
        self.wec_sim_path: Optional[str] = None
        self.database: WECGridDB = database
        self.matlab_engine = None

    def _load_config(self) -> None:
        """Load WEC-Sim configuration from JSON file."""
        try:
            if os.path.exists(_CONFIG_FILE):
                with open(_CONFIG_FILE, "r") as f:
                    config = json.load(f)
                    self.wec_sim_path = config.get("wec_sim_path")
        except Exception as e:
            print(f"Warning: Could not load WEC-Sim config: {e}")

    def _save_config(self) -> None:
        """Save WEC-Sim configuration to JSON file."""
        try:
            config = {"wec_sim_path": self.wec_sim_path}
            with open(_CONFIG_FILE, "w") as f:
                json.dump(config, f, indent=2)
            print(f"Saved WEC-Sim configuration to: {_CONFIG_FILE}")
        except Exception as e:
            print(f"Warning: Could not save WEC-Sim config: {e}")

    def set_wec_sim_path(self, path: str) -> None:
        """Configure the WEC-Sim MATLAB framework installation path.

        Args:
            path (str): Filesystem location of the WEC-Sim MATLAB installation.

        Raises:
            FileNotFoundError: If the supplied ``path`` does not exist.
        """
        if not os.path.exists(path):
            raise FileNotFoundError(f"WEC-SIM path does not exist: {path}")
        self.wec_sim_path = path
        self._save_config()

    def get_wec_sim_path(self) -> Optional[str]:
        """Get the currently configured WEC-Sim path.

        Returns:
            Optional[str]: Absolute path to the WEC-Sim installation or ``None``
            if no path has been configured.
        """
        return self.wec_sim_path

    def show_config(self) -> None:
        """Display current WEC-Sim configuration.

        Prints the currently configured WEC-Sim path along with the location of
        the configuration file used to persist this setting.
        """
        print(f"WEC-Sim Configuration:")
        print(f"  Path: {self.wec_sim_path or 'Not configured'}")
        print(f"  Config file: {_CONFIG_FILE}")
        print(f"  Config exists: {os.path.exists(_CONFIG_FILE)}")

    def start_matlab(self) -> bool:
        """Initialize MATLAB engine and configure WEC-Sim framework paths.

        Returns:
            bool: ``True`` if the MATLAB engine was started, ``False`` if the
            engine was already running or the MATLAB Python API is unavailable.
        """
        self._load_config()
        if self.wec_sim_path is None:
            print("\n" + "=" * 60)
            print("WEC-Sim Path not set")
            print("=" * 60)
            print(
                "Please set the WEC-Sim path here or using the wecsim.set_wec_sim_path() method."
            )
            path = input("Enter the WEC-Sim path: ")
            self.set_wec_sim_path(path)
        try:
            import matlab.engine
        except ImportError:
            print("MATLAB python API not installed. ")
            print(
                "https://www.mathworks.com/help/matlab/matlab_external/install-the-matlab-engine-for-python.html"
            )
            return False
        if self.matlab_engine is None:
            print(f"Starting MATLAB Engine... ", end="")
            self.matlab_engine = matlab.engine.start_matlab()
            print("MATLAB engine started.")

            if self.wec_sim_path is None:
                raise ValueError(
                    "WEC-SIM path is not configured. Please set it using set_wec_sim_path()"
                )

            if not os.path.exists(self.wec_sim_path):
                raise FileNotFoundError(
                    f"WEC-SIM path does not exist: {self.wec_sim_path}"
                )
            print(f"Adding WEC-SIM to path... ", end="")
            matlab_path = self.matlab_engine.genpath(str(self.wec_sim_path), nargout=1)
            self.matlab_engine.addpath(matlab_path, nargout=0)
            print("WEC-SIM path added.")

            self.out = io.StringIO()
            self.err = io.StringIO()
            return True
        else:
            print("MATLAB engine is already running.")
            return False

    def stop_matlab(self) -> bool:
        """Shutdown the MATLAB engine and free system resources.

        Returns:
            bool: ``True`` if the engine was stopped, ``False`` if no engine was
            running.
        """
        if self.matlab_engine is not None:
            self.matlab_engine.quit()
            self.matlab_engine = None
            print("MATLAB engine stopped.")
            self.out = None
            self.err = None
            return True
        print("MATLAB engine is not running.")
        return False

    def sim_results(self, df_power, model, wec_sim_id):
        """Generate visualization plots for WEC-Sim simulation results.

        Args:
            df_power (pd.DataFrame): Power and optional wave elevation time
                series produced by WEC-Sim.
            model (str): Name of the WEC-Sim model used for the simulation.
            wec_sim_id (int): Database identifier for the WEC-Sim run.
        """
        if df_power.empty:
            print("No power data available for visualization")
            return

        # Plot
        fig, ax1 = plt.subplots(figsize=(10, 5))

        # Secondary y-axis: Wave elevation (m) — drawn first for background
        ax2 = ax1.twinx()
        ax2.set_ylabel("Wave Elevation (m)")
        if "eta" in df_power.columns:
            ax2.plot(
                df_power["time"],
                df_power["eta"],
                color="tab:blue",
                alpha=0.3,
                linewidth=1,
                label="Wave Elevation",
            )

        # Primary y-axis: Active power W
        ax1.set_xlabel("Time (s)")
        ax1.set_ylabel("Active Power W")
        ax1.plot(
            df_power["time"],
            df_power["p"],
            color="tab:red",
            label="Power Output",
            linewidth=1.5,
        )

        # Title + layout
        fig.suptitle(f"WEC-SIM Output — Model: {model}, WEC Sim ID: {wec_sim_id}")
        fig.tight_layout()

        # Combine legends
        lines_1, labels_1 = ax1.get_legend_handles_labels()
        lines_2, labels_2 = ax2.get_legend_handles_labels()
        ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc="upper right")

        plt.show()

    def __call__(
        self,
        model_path: str,
        sim_length: int = 3600 * 24,  # 24 hours
        delta_time: float = 0.1,
        spectrum_type: str = "PM",
        wave_class: str = "irregular",
        wave_height: float = 2.5,
        wave_period: float = 8.0,
        wave_seed: int = random.randint(1, 100),
    ) -> Optional[int]:
        """Execute a complete WEC-Sim device simulation with specified parameters.

        Args:
            model_path (str): Path to WEC model directory containing simulation files.
            sim_length (int, optional): Simulation duration in seconds. Defaults to 86400 (24 hours).
            delta_time (float, optional): Simulation time step in seconds. Defaults to 0.1.
            spectrum_type (str, optional): Wave spectrum type. Defaults to 'PM'.
            wave_class (str, optional): Wave type classification. Defaults to 'irregular'.
            wave_height (float, optional): Significant wave height in meters. Defaults to 2.5.
            wave_period (float, optional): Peak wave period in seconds. Defaults to 8.0.
            wave_seed (int, optional): Random seed for wave generation. Defaults to random 1-100.

        Returns:
            int: wec_sim_id from database if successful, None if failed.
        """
        print(
            r"""

            ⠀ WEC-SIM⠀⠀⠀⠀     ⣠⣴⣶⠾⠿⠿⠯⣷⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣾⠛⠁⠀⠀⠀⠀⠀⠀⠈⢻⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⠿⠁⠀⠀⠀⢀⣤⣾⣟⣛⣛⣶⣬⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⠟⠃⠀⠀⠀⠀⠀⣾⣿⠟⠉⠉⠉⠉⠛⠿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⡟⠋⠀⠀⠀⠀⠀⠀⠀⣿⡏⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⠀⠀⣠⡿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣷⡍⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
            ⠀⠀⠀⠀⠀⣠⣼⡏⠀⠀           ⠈⠙⠷⣤⣤⣠⣤⣤⡤⡶⣶⢿⠟⠹⠿⠄⣿⣿⠏⠀⣀⣤⡦⠀⠀⠀⠀⣀⡄
            ⢀⣄⣠⣶⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠓⠚⠋⠉⠀⠀⠀⠀⠀⠀⠈⠛⡛⡻⠿⠿⠙⠓⢒⣺⡿⠋⠁
            ⠉⠉⠉⠛⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠁⠀
            """
        )

        try:
            # Validate model path
            if not os.path.exists(model_path):
                raise FileNotFoundError(f"WEC model path '{model_path}' does not exist")

            model_name = os.path.basename(model_path)

            if self.start_matlab():
                print("Starting WEC-SIM simulation...")
                print(
                    f"\t Model: {model_name}\n"
                    f"\t Model Path: {model_path}\n"
                    f"\t Simulation Length: {sim_length} seconds\n"
                    f"\t Time Step: {delta_time} seconds\n"
                    f"\t Wave class: {wave_class}\n"
                    f"\t Wave Height: {wave_height} m\n"
                    f"\t Wave Period: {wave_period} s\n"
                )

                # Change to model directory - this is the key change from the old version
                self.matlab_engine.cd(str(model_path))

                # Set simulation parameters in MATLAB workspace
                self.matlab_engine.workspace["simLength"] = sim_length
                self.matlab_engine.workspace["dt"] = delta_time
                self.matlab_engine.workspace["spectrumType"] = spectrum_type
                self.matlab_engine.workspace["waveClassType"] = wave_class
                self.matlab_engine.workspace["waveHeight"] = wave_height
                self.matlab_engine.workspace["wavePeriod"] = wave_period
                self.matlab_engine.workspace["waveSeed"] = int(wave_seed)
                self.matlab_engine.workspace["DB_PATH"] = self.database.db_path
                out = io.StringIO()
                err = io.StringIO()

                # Run the WEC-SIM function from the model directory
                self.matlab_engine.eval(
                    "[m2g_out] = w2gSim(simLength,dt,spectrumType,waveClassType,waveHeight,wavePeriod,waveSeed);",
                    nargout=0,
                    stdout=out,
                    stderr=err,
                )
                print(
                    f"simulation complete... writing to database at \n\t{self.database.db_path}"
                )

                self.matlab_engine.eval("formatter", nargout=0, stdout=out, stderr=err)

                # Get the wec_sim_id that was created by the database
                wec_sim_id = self.matlab_engine.workspace["wec_sim_id_result"]
                wec_sim_id = int(wec_sim_id)  # Convert from MATLAB double to Python int

                print(
                    f"WEC-SIM complete: model = {model_name}, wec_sim_id = {wec_sim_id}, duration = {sim_length}s"
                )

                # Query power results for visualization
                power_query = """
                    SELECT time_sec as time, p_w as p, wave_elevation_m as eta 
                    FROM wec_power_results 
                    WHERE wec_sim_id = ? 
                    ORDER BY time_sec
                """
                df_power = self.database.query(
                    power_query, params=(wec_sim_id,), return_type="df"
                )
                print("MATLAB Output:")
                print("=" * 10)
                print(out.getvalue())
                print("=" * 10)
                self.stop_matlab()

                if not df_power.empty:
                    self.sim_results(df_power, model_name, wec_sim_id)

                return wec_sim_id

            print("Failed to start MATLAB engine.")
            return None

        except Exception as e:
            print(f"[WEC-SIM ERROR] model_path={model_path}{e}")
            print("=" * 10)
            print("MATLAB Output:")
            print(out.getvalue())
            print("MATLAB Errors:")
            print(err.getvalue())
            print("=" * 10)

            self.stop_matlab()
            return None

get_wec_sim_path()

Get the currently configured WEC-Sim path.

Returns:

Type Description
Optional[str]

Optional[str]: Absolute path to the WEC-Sim installation or None

Optional[str]

if no path has been configured.

Source code in src/wecgrid/modelers/wec_sim/runner.py
 94
 95
 96
 97
 98
 99
100
101
def get_wec_sim_path(self) -> Optional[str]:
    """Get the currently configured WEC-Sim path.

    Returns:
        Optional[str]: Absolute path to the WEC-Sim installation or ``None``
        if no path has been configured.
    """
    return self.wec_sim_path

set_wec_sim_path(path)

Configure the WEC-Sim MATLAB framework installation path.

Parameters:

Name Type Description Default
path str

Filesystem location of the WEC-Sim MATLAB installation.

required

Raises:

Type Description
FileNotFoundError

If the supplied path does not exist.

Source code in src/wecgrid/modelers/wec_sim/runner.py
80
81
82
83
84
85
86
87
88
89
90
91
92
def set_wec_sim_path(self, path: str) -> None:
    """Configure the WEC-Sim MATLAB framework installation path.

    Args:
        path (str): Filesystem location of the WEC-Sim MATLAB installation.

    Raises:
        FileNotFoundError: If the supplied ``path`` does not exist.
    """
    if not os.path.exists(path):
        raise FileNotFoundError(f"WEC-SIM path does not exist: {path}")
    self.wec_sim_path = path
    self._save_config()

show_config()

Display current WEC-Sim configuration.

Prints the currently configured WEC-Sim path along with the location of the configuration file used to persist this setting.

Source code in src/wecgrid/modelers/wec_sim/runner.py
103
104
105
106
107
108
109
110
111
112
def show_config(self) -> None:
    """Display current WEC-Sim configuration.

    Prints the currently configured WEC-Sim path along with the location of
    the configuration file used to persist this setting.
    """
    print(f"WEC-Sim Configuration:")
    print(f"  Path: {self.wec_sim_path or 'Not configured'}")
    print(f"  Config file: {_CONFIG_FILE}")
    print(f"  Config exists: {os.path.exists(_CONFIG_FILE)}")

sim_results(df_power, model, wec_sim_id)

Generate visualization plots for WEC-Sim simulation results.

Parameters:

Name Type Description Default
df_power DataFrame

Power and optional wave elevation time series produced by WEC-Sim.

required
model str

Name of the WEC-Sim model used for the simulation.

required
wec_sim_id int

Database identifier for the WEC-Sim run.

required
Source code in src/wecgrid/modelers/wec_sim/runner.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
def sim_results(self, df_power, model, wec_sim_id):
    """Generate visualization plots for WEC-Sim simulation results.

    Args:
        df_power (pd.DataFrame): Power and optional wave elevation time
            series produced by WEC-Sim.
        model (str): Name of the WEC-Sim model used for the simulation.
        wec_sim_id (int): Database identifier for the WEC-Sim run.
    """
    if df_power.empty:
        print("No power data available for visualization")
        return

    # Plot
    fig, ax1 = plt.subplots(figsize=(10, 5))

    # Secondary y-axis: Wave elevation (m) — drawn first for background
    ax2 = ax1.twinx()
    ax2.set_ylabel("Wave Elevation (m)")
    if "eta" in df_power.columns:
        ax2.plot(
            df_power["time"],
            df_power["eta"],
            color="tab:blue",
            alpha=0.3,
            linewidth=1,
            label="Wave Elevation",
        )

    # Primary y-axis: Active power W
    ax1.set_xlabel("Time (s)")
    ax1.set_ylabel("Active Power W")
    ax1.plot(
        df_power["time"],
        df_power["p"],
        color="tab:red",
        label="Power Output",
        linewidth=1.5,
    )

    # Title + layout
    fig.suptitle(f"WEC-SIM Output — Model: {model}, WEC Sim ID: {wec_sim_id}")
    fig.tight_layout()

    # Combine legends
    lines_1, labels_1 = ax1.get_legend_handles_labels()
    lines_2, labels_2 = ax2.get_legend_handles_labels()
    ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc="upper right")

    plt.show()

start_matlab()

Initialize MATLAB engine and configure WEC-Sim framework paths.

Returns:

Name Type Description
bool bool

True if the MATLAB engine was started, False if the

bool

engine was already running or the MATLAB Python API is unavailable.

Source code in src/wecgrid/modelers/wec_sim/runner.py
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
def start_matlab(self) -> bool:
    """Initialize MATLAB engine and configure WEC-Sim framework paths.

    Returns:
        bool: ``True`` if the MATLAB engine was started, ``False`` if the
        engine was already running or the MATLAB Python API is unavailable.
    """
    self._load_config()
    if self.wec_sim_path is None:
        print("\n" + "=" * 60)
        print("WEC-Sim Path not set")
        print("=" * 60)
        print(
            "Please set the WEC-Sim path here or using the wecsim.set_wec_sim_path() method."
        )
        path = input("Enter the WEC-Sim path: ")
        self.set_wec_sim_path(path)
    try:
        import matlab.engine
    except ImportError:
        print("MATLAB python API not installed. ")
        print(
            "https://www.mathworks.com/help/matlab/matlab_external/install-the-matlab-engine-for-python.html"
        )
        return False
    if self.matlab_engine is None:
        print(f"Starting MATLAB Engine... ", end="")
        self.matlab_engine = matlab.engine.start_matlab()
        print("MATLAB engine started.")

        if self.wec_sim_path is None:
            raise ValueError(
                "WEC-SIM path is not configured. Please set it using set_wec_sim_path()"
            )

        if not os.path.exists(self.wec_sim_path):
            raise FileNotFoundError(
                f"WEC-SIM path does not exist: {self.wec_sim_path}"
            )
        print(f"Adding WEC-SIM to path... ", end="")
        matlab_path = self.matlab_engine.genpath(str(self.wec_sim_path), nargout=1)
        self.matlab_engine.addpath(matlab_path, nargout=0)
        print("WEC-SIM path added.")

        self.out = io.StringIO()
        self.err = io.StringIO()
        return True
    else:
        print("MATLAB engine is already running.")
        return False

stop_matlab()

Shutdown the MATLAB engine and free system resources.

Returns:

Name Type Description
bool bool

True if the engine was stopped, False if no engine was

bool

running.

Source code in src/wecgrid/modelers/wec_sim/runner.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def stop_matlab(self) -> bool:
    """Shutdown the MATLAB engine and free system resources.

    Returns:
        bool: ``True`` if the engine was stopped, ``False`` if no engine was
        running.
    """
    if self.matlab_engine is not None:
        self.matlab_engine.quit()
        self.matlab_engine = None
        print("MATLAB engine stopped.")
        self.out = None
        self.err = None
        return True
    print("MATLAB engine is not running.")
    return False

WEC Components

WEC Device

Individual Wave Energy Converter device with time-series power output data.

Represents a single wave energy converter with simulation results, grid connection parameters, and metadata. Contains time-series power output data from WEC-Sim hydrodynamic simulations for realistic renewable generation modeling.

Attributes:

Name Type Description
name str

Unique device identifier, typically "{model}{sim_id}".

dataframe DataFrame

Primary time-series data for grid integration at 5-minute intervals. Columns: time, p [MW], q [MVAr], base [MVA].

dataframe_full DataFrame

High-resolution simulation data with complete WEC-Sim output including wave elevation and device states.

base float

Base power rating [MVA] for per-unit calculations.

bus_location int

Power system bus number for grid connection.

model str

WEC device model type ("RM3", "LUPA", etc.).

sim_id int

Database simulation identifier for traceability.

Example

power_data = pd.DataFrame({ ... 'p': [2.5, 3.1, 2.8], # MW ... 'q': [0.0, 0.0, 0.0], # MVAr ... 'base': [100.0] * 3 # MVA ... }) device = WECDevice( ... name="RM3_101_0", ... dataframe=power_data, ... base=100.0, ... bus_location=14, ... model="RM3" ... )

Notes
  • Variable power output based on wave conditions
  • Typically operates at unity power factor (zero reactive power)
  • Primary dataframe at 5-minute resolution for grid compatibility
  • Full dataframe contains high-resolution WEC-Sim results
Source code in src/wecgrid/wec/device.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
@dataclass
class WECDevice:
    """Individual Wave Energy Converter device with time-series power output data.

    Represents a single wave energy converter with simulation results, grid connection
    parameters, and metadata. Contains time-series power output data from WEC-Sim
    hydrodynamic simulations for realistic renewable generation modeling.

    Attributes:
        name (str): Unique device identifier, typically "{model}_{sim_id}_{index}".
        dataframe (pd.DataFrame): Primary time-series data for grid integration at
            5-minute intervals. Columns: time, p [MW], q [MVAr], base [MVA].
        dataframe_full (pd.DataFrame): High-resolution simulation data with complete
            WEC-Sim output including wave elevation and device states.
        base (float, optional): Base power rating [MVA] for per-unit calculations.
        bus_location (int, optional): Power system bus number for grid connection.
        model (str, optional): WEC device model type ("RM3", "LUPA", etc.).
        sim_id (int, optional): Database simulation identifier for traceability.

    Example:
        >>> power_data = pd.DataFrame({
        ...     'p': [2.5, 3.1, 2.8],  # MW
        ...     'q': [0.0, 0.0, 0.0],  # MVAr
        ...     'base': [100.0] * 3    # MVA
        ... })
        >>> device = WECDevice(
        ...     name="RM3_101_0",
        ...     dataframe=power_data,
        ...     base=100.0,
        ...     bus_location=14,
        ...     model="RM3"
        ... )

    Notes:
        - Variable power output based on wave conditions
        - Typically operates at unity power factor (zero reactive power)
        - Primary dataframe at 5-minute resolution for grid compatibility
        - Full dataframe contains high-resolution WEC-Sim results
    """

    name: str
    dataframe: pd.DataFrame = field(default_factory=pd.DataFrame)
    bus_location: Optional[int] = None
    model: Optional[str] = None
    wec_sim_id: Optional[int] = None

    def __repr__(self) -> str:
        """Return a formatted string representation of the WEC device configuration.

        Provides a hierarchical display of key device parameters for debugging,
        logging, and user information. The format shows essential device
        characteristics including identification, grid connection, and data status.

        Returns:
            str: Formatted multi-line string with device configuration details.
                Includes device name, model type, grid connection parameters,
                simulation metadata, base power rating, and data size in a
                tree-like structure for easy reading.

        Example:
            >>> device = WECDevice(
            ...     name="RM3_101_0",
            ...     model="RM3",
            ...     bus_location=14,
            ...     sim_id=101,
            ...     dataframe=power_data  # 288 rows
            ... )
            >>> print(device)
            WECDevice:
            ├─ name: 'RM3_101_0'
            ├─ model: 'RM3'
            ├─ bus_location: 14
            ├─ sim_id: 101
            └─ rows: 288

        Display Format:
            - **Tree structure**: Uses Unicode box-drawing characters
            - **Device identification**: Name and model type in quotes
            - **Grid parameters**: Bus location and simulation ID as integers
            - **Data size**: Number of time-series data points

        Information Categories:
            - **Identity**: Device name (typically includes model and index)
            - **Type**: WEC model for hydrodynamic characteristics
            - **Grid connection**: Bus location for electrical network modeling
            - **Simulation link**: Database ID for traceability
            - **Data status**: Time-series length for validation

        Use Cases:
            - **Interactive debugging**: Quick device configuration inspection
            - **Jupyter notebooks**: Clean display in research environments
            - **Logging output**: Structured device information for log files
            - **Data validation**: Verify device setup and data availability
            - **Farm inspection**: Review individual devices in large collections

        Notes:
            - Name and model shown in quotes to distinguish strings
            - Row count reflects primary dataframe length (grid integration data)
            - Missing values displayed as None for optional parameters
            - Unicode characters may not display properly in all terminals
            - Format consistent with WECFarm.__repr__() for visual coherence

        See Also:
            WECFarm.__repr__: Similar formatting for farm-level display
        """
        return f"""WECDevice:
    ├─ name: {self.name!r}
    ├─ model: {self.model!r}
    ├─ bus_location: {self.bus_location}
    ├─ sim_id: {self.wec_sim_id}
    └─ rows: {len(self.dataframe)}
    """

WEC Farm

Collection of Wave Energy Converter devices at a common grid connection.

Manages multiple identical WEC devices sharing a grid connection bus. Aggregates device power outputs and coordinates time-series data for power system integration studies.

Attributes:

Name Type Description
farm_name str

Human-readable farm identifier.

database

Database interface for WEC simulation data.

time

Time manager for simulation synchronization.

wec_sim_id int

Database simulation ID for WEC data retrieval.

model str

WEC device model type (e.g., "RM3").

bus_location int

Grid bus number for farm connection.

connecting_bus int

Network topology connection bus.

id str

Unique generator identifier for power system integration.

size int

Number of identical WEC devices in farm.

config Dict

Configuration parameters for the farm.

wec_devices List[WECDevice]

Collection of individual WEC devices.

BASE float

Base power rating [MVA] for per-unit calculations.

Example

farm = WECFarm( ... farm_name="Oregon Coast Farm", ... database=db, ... time=time_mgr, ... sim_id=101, ... model="RM3", ... bus_location=14, ... size=5 ... ) total_power = farm.power_at_snapshot(timestamp)

Notes
  • All devices use identical power profiles from WEC-Sim data
  • Power scales linearly with farm size
  • Requires WEC-Sim simulation data in database
  • Base power typically 100 MVA for utility-scale installations
TODO
  • Add heterogeneous device support for different models
  • Implement smart farm control and optimization
Source code in src/wecgrid/wec/farm.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
class WECFarm:
    """Collection of Wave Energy Converter devices at a common grid connection.

    Manages multiple identical WEC devices sharing a grid connection bus.
    Aggregates device power outputs and coordinates time-series data for
    power system integration studies.

    Attributes:
        farm_name (str): Human-readable farm identifier.
        database: Database interface for WEC simulation data.
        time: Time manager for simulation synchronization.
        wec_sim_id (int): Database simulation ID for WEC data retrieval.
        model (str): WEC device model type (e.g., "RM3").
        bus_location (int): Grid bus number for farm connection.
        connecting_bus (int): Network topology connection bus.
        id (str): Unique generator identifier for power system integration.
        size (int): Number of identical WEC devices in farm.
        config (Dict): Configuration parameters for the farm.
        wec_devices (List[WECDevice]): Collection of individual WEC devices.
        BASE (float): Base power rating [MVA] for per-unit calculations.

    Example:
        >>> farm = WECFarm(
        ...     farm_name="Oregon Coast Farm",
        ...     database=db,
        ...     time=time_mgr,
        ...     sim_id=101,
        ...     model="RM3",
        ...     bus_location=14,
        ...     size=5
        ... )
        >>> total_power = farm.power_at_snapshot(timestamp)

    Notes:
        - All devices use identical power profiles from WEC-Sim data
        - Power scales linearly with farm size
        - Requires WEC-Sim simulation data in database
        - Base power typically 100 MVA for utility-scale installations

    TODO:
        - Add heterogeneous device support for different models
        - Implement smart farm control and optimization
    """

    def __init__(
        self,
        farm_name: str,
        database,
        time: Any,
        wec_sim_id: int,
        bus_location: int,
        connecting_bus: int = 1,
        gen_name: str = "",
        size: int = 1,
        farm_id: int = None,
        sbase: float = 100.0,
        scaling_factor: float = 1.0,
    ):
        """Initialize WEC farm with specified configuration.

        Args:
            farm_name (str): Human-readable WEC farm identifier.
            database: Database interface for WEC simulation data access.
            time: Time management object for simulation synchronization.
            wec_sim_id (int): Database simulation ID for WEC data retrieval.
            bus_location (int): Grid bus number for farm connection.
            connecting_bus (int, optional): Network topology connection bus. Defaults to 1.
            gen_name (str, optional): Generator name for power system integration. Defaults to ''.
            size (int, optional): Number of WEC devices in farm. Defaults to 1.
            farm_id (int, optional): Unique farm identifier. Defaults to None.
            sbase (float, optional): Base power rating [MVA] for per-unit calculations. Defaults to 100.0.
            scaling_factor (float, optional): Linear power scaling factor for aggregated output. Defaults to 1.0.

        Raises:
            RuntimeError: If WEC simulation data not found in database.
            ValueError: If database query returns empty results.

        Example:
            >>> farm = WECFarm(
            ...     farm_name="Newport Array",
            ...     database=db,
            ...     time=time_mgr,
            ...     wec_sim_id=101,
            ...     bus_location=14,
            ...     size=5
            ... )

        Notes:
            - Creates identical WECDevice objects for all farm devices
            - Retrieves WEC-Sim data from database using wec_sim_id
            - Sets up per-unit base power from simulation data
        """

        self.farm_name: str = farm_name
        self.database = database  # TODO make this a WECGridDB data type
        self.time = time  # todo might need to update time to be SimulationTime type
        self.wec_sim_id: int = wec_sim_id
        self.model: str = ""
        self.bus_location: int = bus_location
        self.connecting_bus: int = (
            connecting_bus  # todo this should default to swing bus
        )
        self.farm_id: int = farm_id
        self.size: int = size
        self.config: Dict = None
        self.wec_devices: List[WECDevice] = []
        self.sbase: float = sbase
        self.scaling_factor: float = scaling_factor
        self.gen_name = gen_name
        # todo don't need the base here anymore
        # todo: add bus voltage.
        # todo: connecting line

        self._prepare_farm()

    def __repr__(self) -> str:
        """Return a formatted string representation of the WEC farm configuration.

        Provides a hierarchical display of key farm parameters for debugging,
        logging, and user information. The format is designed for readability
        and includes essential configuration details.

        Returns:
            str: Formatted multi-line string with farm configuration details.
                Includes farm name, size, model, grid connections, simulation ID,
                and base power rating in a tree-like structure.

        Example:
            >>> farm = WECFarm("Test Farm", db, time_mgr, 101, "RM3", 14, size=5)
            >>> print(farm)
            WECFarm:
            ├─ name: 'Test Farm'
            ├─ size: 5
            ├─ model: 'RM3'
            ├─ bus_location: 14
            ├─ connecting_bus: 1
            └─ wec_sim_id: 101

            Base: 100.0 MVA

        Display Format:
            - **Tree structure**: Uses Unicode box-drawing characters
            - **Key parameters**: Farm name, device count, model type
            - **Grid connections**: Bus locations for power system integration
            - **Simulation link**: Database simulation identifier
            - **Base power**: MVA rating for per-unit calculations

        Use Cases:
            - **Interactive debugging**: Quick farm configuration inspection
            - **Logging output**: Structured information for log files
            - **Jupyter notebooks**: Clean display in research environments
            - **Configuration validation**: Verify farm setup parameters

        Notes:
            - Size displays actual number of created devices (len(wec_devices))
            - Farm name shown in quotes to distinguish from other identifiers
            - Base power extracted from WEC simulation data during initialization
            - Unicode characters may not display properly in all terminals
        """
        return f"""WECFarm:
        ├─ name: {self.farm_name!r}
        ├─ size: {len(self.wec_devices)}
        ├─ model: {self.model!r}
        ├─ bus_location: {self.bus_location}
        ├─ connecting_bus: {self.connecting_bus}
        └─ sim_id: {self.wec_sim_id}

        Base: {self.sbase} MVA

    """

    def _prepare_farm(self):
        """Load WEC simulation data from database and create individual device objects.

        Internal method that handles the core initialization logic for the WEC farm.
        Validates database content, retrieves simulation results, processes time
        indexing, and instantiates the specified number of WEC device objects.

        Returns:
            None: Populates self.wec_devices with configured WECDevice objects.

        Raises:
            RuntimeError: If WEC simulation data not found or loading fails.
                Provides guidance on running WEC-Sim simulations first.
            ValueError: If database returns empty or invalid data.
            KeyError: If required columns missing from simulation data.

        Data Loading Process:
            1. **Table Existence Check**: Verify simulation tables exist in database
            2. **Grid Data Retrieval**: Load downsampled results for power system integration
            3. **Full Data Retrieval**: Load high-resolution results for detailed analysis
            4. **Time Index Creation**: Generate pandas datetime index for time series
            5. **Base Power Extraction**: Get MVA rating from simulation data
            6. **Device Instantiation**: Create specified number of WECDevice objects

        Database Table Schema:
            **Integration table** (`WECSIM_{model}_{sim_id}`):
            - time: Simulation time [s]
            - p: Active power output [MW]
            - q: Reactive power output [MVAr] (typically zero)
            - base: Base power rating [MVA]

            **Full resolution table** (`WECSIM_{model}_{sim_id}_full`):
            - time: Simulation time [s] (high frequency)
            - p: Active power output [MW]
            - eta: Wave surface elevation [m]
            - Additional WEC-Sim variables

        Time Series Processing:
            - **5-minute intervals**: Standard grid integration time step
            - **Datetime indexing**: Converts simulation seconds to timestamp format
            - **Start time alignment**: Uses time manager's configured start time
            - **Pandas integration**: Creates DataFrame index for efficient querying

        Device Creation:
            Each WECDevice configured with:
            - **Unique naming**: "{model}_{sim_id}_{index}" pattern
            - **Shared data**: Copy of power time series for each device
            - **Common parameters**: Bus location, base power, model type
            - **Individual objects**: Separate instance for potential customization

        Error Handling:
            - **Missing tables**: Clear error message with WEC-Sim guidance
            - **Empty data**: Validation of successful data retrieval
            - **Data integrity**: Checks for required columns and valid values
            - **Graceful failure**: Informative error messages for troubleshooting

        Performance Considerations:
            - **Single query**: Minimizes database access for efficiency
            - **Data copying**: Each device gets independent DataFrame copy
            - **Memory usage**: Scales with farm size and simulation duration
            - **Time indexing**: One-time conversion for all devices

        Notes:
            - Called automatically during farm initialization
            - Assumes WEC-Sim simulation completed successfully
            - Base power typically 100 MVA for utility-scale installations
            - All devices share identical power profiles (homogeneous farm)
            - TODO: Add data validation and integrity checks
            - TODO: Support for custom time intervals beyond 5 minutes

        See Also:
            WECDevice: Individual device objects created by this method
            WECSimRunner: Generates the simulation data loaded here
            WECGridTime: Provides time configuration for indexing
        """
        # First get model type from wec_simulations table using wec_sim_id
        model_query = "SELECT model_type FROM wec_simulations WHERE wec_sim_id = ?"
        model_result = self.database.query(model_query, params=(self.wec_sim_id,))

        if not model_result:
            raise RuntimeError(
                f"[Farm] No simulation metadata found for wec_sim_id={self.wec_sim_id}"
            )

        # Update self.model from database
        if isinstance(model_result, list) and len(model_result) > 0:
            self.model = (
                model_result[0][0]
                if isinstance(model_result[0], (list, tuple))
                else model_result[0]["model_type"]
            )
        else:
            raise RuntimeError(
                f"[Farm] Invalid model data returned for wec_sim_id={self.wec_sim_id}"
            )

        # print(f"[Farm] Loaded WEC model '{self.model}' for simulation ID {self.wec_sim_id}")

        # Check if WEC simulation data exists in new schema
        sim_check_query = "SELECT wec_sim_id FROM wec_simulations WHERE wec_sim_id = ?"
        sim_result = self.database.query(sim_check_query, params=(self.wec_sim_id,))

        if not sim_result:
            raise RuntimeError(
                f"[Farm] No WEC simulation found for wec_sim_id={self.wec_sim_id}. Run WEC-SIM first."
            )

        # Load WEC power data from new database schema
        power_query = """
            SELECT time_sec as time, p_w as p, q_var as q, wave_elevation_m as eta 
            FROM wec_power_results 
            WHERE wec_sim_id = ? 
            ORDER BY time_sec
        """
        df_full = self.database.query(
            power_query, params=(self.wec_sim_id,), return_type="df"
        )

        if df_full is None or df_full.empty:
            raise RuntimeError(
                f"[Farm] No WEC power data found for wec_sim_id={self.wec_sim_id}"
            )

        df_full.p = self.scaling_factor * df_full.p  # scale active power
        df_full.q = self.scaling_factor * df_full.q  # scale reactive power

        # Downsample the full resolution data for grid integration
        df_downsampled = self.down_sample(df_full, self.time.delta_time)

        # Apply time index at 5 min resolution using start time
        df_downsampled["snapshots"] = pd.date_range(
            start=self.time.start_time,
            periods=df_downsampled.shape[0],
            freq=self.time.freq,
        )

        # apply the snapshots
        df_downsampled.set_index("snapshots", inplace=True)

        # Convert Watts to per-unit of sbase MVA
        # WEC data is stored in Watts, need to convert to MW then to per-unit
        # Conversion: Watts → MW (÷1e6) → per-unit (÷sbase_MVA)
        df_downsampled["p"] = df_downsampled["p"] / (self.sbase * 1e6)  # Watts to pu
        df_downsampled["q"] = df_downsampled["q"] / (self.sbase * 1e6)  # Watts to pu

        for i in range(self.size):
            name = f"{self.model}_{self.wec_sim_id}_{i}"
            device = WECDevice(
                name=name,
                dataframe=df_downsampled.copy(),  # Use downsampled data for grid integration
                bus_location=self.bus_location,
                model=self.model,
                wec_sim_id=self.wec_sim_id,
            )
            self.wec_devices.append(device)

    def down_sample(
        self, wec_df: pd.DataFrame, new_sample_period: float, timeshift: int = 0
    ) -> pd.DataFrame:
        """Downsample WEC time-series data to a coarser time resolution.

        Converts high-frequency WEC simulation data to lower frequency suitable for
        power system integration studies. Averages data over specified time windows
        to maintain energy conservation while reducing computational overhead.

        Based on MATLAB DownSampleTS function with pandas DataFrame implementation.

        Args:
            wec_df (pd.DataFrame): Original high-frequency WEC data with 'time' column.
                Must contain time series data with consistent time step.
            new_sample_period (float): New sampling period [seconds] for downsampled data.
                Typically 300s (5 minutes) for grid integration studies.
            timeshift (int, optional): Time alignment option. Defaults to 0.
                - 0: Samples at end of averaging period
                - 1: Samples centered within averaging period

        Returns:
            pd.DataFrame: Downsampled DataFrame with same columns as input.
                Time column adjusted to new sampling frequency.
                Data columns contain averaged values over sampling windows.

        Raises:
            ValueError: If new_sample_period is smaller than original time step.
            KeyError: If 'time' column not found in input DataFrame.

        Example:
            >>> # Downsample 0.1s WEC data to 5-minute intervals
            >>> df_original = pd.DataFrame({
            ...     'time': np.arange(0, 1000, 0.1),  # 0.1s timestep
            ...     'p': np.random.rand(10000),        # Power data
            ...     'eta': np.random.rand(10000)       # Wave elevation
            ... })
            >>> df_downsampled = farm.down_sample(df_original, 300.0)  # 5min
            >>> print(f"Original: {len(df_original)} points")
            >>> print(f"Downsampled: {len(df_downsampled)} points")
            Original: 10000 points
            Downsampled: 33 points

        Averaging Process:
            1. **Calculate sample ratio**: How many original points per new point
            2. **Determine new time grid**: Based on sample period and alignment
            3. **Window averaging**: Mean value over each time window
            4. **Energy conservation**: Maintains total energy content

        Time Alignment Options:
            **timeshift = 0** (End-aligned):
            - New timestamps at end of averaging window
            - t_new = [T, 2T, 3T, ...] where T = new_sample_period

            **timeshift = 1** (Center-aligned):
            - New timestamps at center of averaging window
            - t_new = [T/2, T+T/2, 2T+T/2, ...] where T = new_sample_period

        Data Processing:
            - **First window**: Averages from start to first sample point
            - **Subsequent windows**: Averages over fixed-width windows
            - **Missing data**: Handles partial windows at end of series
            - **Column preservation**: Maintains all non-time columns

        Performance Considerations:
            - **Memory efficient**: Uses vectorized pandas operations
            - **Flexible windows**: Handles non-integer sample ratios
            - **Large datasets**: Suitable for long WEC simulations
            - **Numerical stability**: Robust averaging implementation

        Grid Integration Usage:
            - **PSS®E studies**: 5-minute resolution for stability analysis
            - **Economic dispatch**: Hourly or 15-minute intervals
            - **Load forecasting**: Daily or weekly aggregation
            - **Resource assessment**: Monthly or seasonal averages

        Wave Energy Applications:
            - **Power smoothing**: Reduces high-frequency fluctuations
            - **Grid compliance**: Matches utility data requirements
            - **Forecast validation**: Aligns with meteorological predictions
            - **Storage sizing**: Determines energy storage requirements

        Notes:
            - Preserves energy content through proper averaging
            - Original time step must be consistent (fixed timestep)
            - New sample period should be multiple of original timestep
            - Returns DataFrame with same structure as input
            - Time column values updated to new sampling frequency

        See Also:
            _prepare_farm: Uses this method for WEC data preprocessing
            WECGridTime: Provides target sampling frequencies
            pandas.DataFrame.resample: Alternative pandas resampling method
        """
        if "time" not in wec_df.columns:
            raise KeyError("DataFrame must contain 'time' column for downsampling")

        # Calculate original time step (assuming fixed timestep)
        time_values = wec_df["time"].values
        if len(time_values) < 2:
            return wec_df.copy()  # Return original if too few points

        old_dt = time_values[1] - time_values[0]

        if new_sample_period <= old_dt:
            raise ValueError(
                f"New sample period ({new_sample_period}s) must be larger than original timestep ({old_dt}s)"
            )

        # Calculate sampling parameters
        t_sample = int(new_sample_period / old_dt)  # Points per new sample
        new_sample_size = int((time_values[-1] - time_values[0]) / new_sample_period)

        if new_sample_size <= 0:
            return wec_df.copy()  # Return original if downsampling not possible

        # Create new time grid
        if timeshift == 1:
            # Center-aligned timestamps
            new_times = np.arange(
                new_sample_period / 2,
                new_sample_size * new_sample_period + new_sample_period / 2,
                new_sample_period,
            )
        else:
            # End-aligned timestamps
            new_times = np.arange(
                new_sample_period,
                (new_sample_size + 1) * new_sample_period,
                new_sample_period,
            )

        # Ensure we don't exceed the original time range
        new_times = new_times[new_times <= time_values[-1]]
        new_sample_size = len(new_times)

        # Initialize downsampled DataFrame
        downsampled_data = {"time": new_times}

        # Downsample each data column (excluding time)
        data_columns = [col for col in wec_df.columns if col != "time"]

        for col in data_columns:
            downsampled_values = np.zeros(new_sample_size)

            for i in range(new_sample_size):
                if i == 0:
                    # First window: from start to first sample point
                    start_idx = 0
                    end_idx = min(t_sample, len(wec_df))
                else:
                    # Subsequent windows: fixed-width windows
                    start_idx = (i - 1) * t_sample
                    end_idx = min(i * t_sample, len(wec_df))

                if start_idx < len(wec_df) and end_idx > start_idx:
                    downsampled_values[i] = wec_df[col].iloc[start_idx:end_idx].mean()
                else:
                    downsampled_values[i] = 0.0  # Handle edge cases

            downsampled_data[col] = downsampled_values

        return pd.DataFrame(downsampled_data)

    def power_at_snapshot(self, timestamp: pd.Timestamp) -> float:
        """Calculate total farm power output at a specific simulation time.

        Aggregates active power output from all WEC devices in the farm at the
        specified timestamp. This method provides the primary interface for
        power system integration, enabling time-varying renewable generation
        modeling in grid simulations.

        Args:
            timestamp (pd.Timestamp): Simulation time to query for power output.
                Must exist in the device DataFrame time index. Typically corresponds
                to grid simulation snapshots at 5-minute intervals.

        Returns:
            float: Total active power output from all farm devices in per-unit on
                the farm's ``sbase``. Sum of individual device outputs at the
                specified time. Returns 0.0 if no valid data available at
                timestamp.

        Raises:
            KeyError: If timestamp not found in device data index.
            AttributeError: If device DataFrame not properly initialized.

        Example:
            >>> # Get power at specific simulation time
            >>> timestamp = pd.Timestamp("2023-01-01 12:00:00")
            >>> power_pu = farm.power_at_snapshot(timestamp)
            >>> print(f"Farm output at noon: {power_pu:.4f} pu")
            Farm output at noon: 0.1575 pu

            >>> # Time series power extraction
            >>> time_series = []
            >>> for snapshot in time_manager.snapshots:
            ...     power_pu = farm.power_at_snapshot(snapshot)
            ...     time_series.append(power_pu)
            >>>
            >>> import matplotlib.pyplot as plt
            >>> plt.plot(time_manager.snapshots, time_series)
            >>> plt.ylabel("Farm Power Output [pu]")

        Power Aggregation:
            - **Linear summation**: Total = Σ(device_power[i] at timestamp)
            - **Homogeneous devices**: All devices have identical power profiles
            - **Realistic scaling**: Based on actual WEC device physics
            - **Wave correlation**: Devices respond to same ocean conditions

        Data Requirements:
            - **Valid timestamp**: Must exist in device DataFrame index
            - **Initialized devices**: All WECDevice objects must be properly created
            - **Power column**: Device data must contain "p" column for active power
            - **Time alignment**: Timestamp must match grid simulation schedule

        Error Handling:
            - **Missing data warning**: Prints warning for devices with no data
            - **Graceful degradation**: Continues calculation with available devices
            - **Zero fallback**: Returns 0.0 if no devices have valid data
            - **Timestamp validation**: Checks for existence in device index

        Performance Considerations:
            - **O(n) complexity**: Scales linearly with number of devices
            - **DataFrame lookup**: Efficient pandas indexing for time queries
            - **Memory efficiency**: No data copying, direct access to device data
            - **Repeated calls**: Suitable for time-series iteration

        Grid Integration Usage:
            - **PSS®E integration**: Provides generator output at each time step
            - **PyPSA integration**: Supplies renewable generation time series
            - **Load flow studies**: Time-varying injection for stability analysis
            - **Economic dispatch**: Variable renewable generation modeling

        Wave Energy Characteristics:
            - **Intermittent output**: Power varies with wave conditions
            - **Predictable patterns**: Follows ocean wave statistics
            - **Seasonal variation**: Higher output in winter storm seasons
            - **Capacity factor**: Typically 20-40% for ocean wave resources

        Notes:
            - Output is in per-unit on the farm's ``sbase``; multiply by
              ``sbase`` for MW
            - Power output includes WEC device efficiency and control effects
            - All devices share identical profiles (same wave field assumption)
            - Negative power values possible during reactive conditions
            - Zero output during calm conditions or device maintenance
            - Farm total limited by grid connection capacity

        See Also:
            WECDevice.dataframe: Individual device power time series
            Engine.simulate: Uses this method for grid integration
            WECGridPlotter.plot_wec_analysis: Visualizes farm power output
        """
        total_power = 0.0
        for device in self.wec_devices:
            if (
                device.dataframe is not None
                and not device.dataframe.empty
                and timestamp in device.dataframe.index
            ):
                power = device.dataframe.at[timestamp, "p"]
                total_power += power
            else:
                print(f"[WARNING] Missing data for {device.name} at {timestamp}")
        return total_power

down_sample(wec_df, new_sample_period, timeshift=0)

Downsample WEC time-series data to a coarser time resolution.

Converts high-frequency WEC simulation data to lower frequency suitable for power system integration studies. Averages data over specified time windows to maintain energy conservation while reducing computational overhead.

Based on MATLAB DownSampleTS function with pandas DataFrame implementation.

Parameters:

Name Type Description Default
wec_df DataFrame

Original high-frequency WEC data with 'time' column. Must contain time series data with consistent time step.

required
new_sample_period float

New sampling period [seconds] for downsampled data. Typically 300s (5 minutes) for grid integration studies.

required
timeshift int

Time alignment option. Defaults to 0. - 0: Samples at end of averaging period - 1: Samples centered within averaging period

0

Returns:

Type Description
DataFrame

pd.DataFrame: Downsampled DataFrame with same columns as input. Time column adjusted to new sampling frequency. Data columns contain averaged values over sampling windows.

Raises:

Type Description
ValueError

If new_sample_period is smaller than original time step.

KeyError

If 'time' column not found in input DataFrame.

Example

Downsample 0.1s WEC data to 5-minute intervals

df_original = pd.DataFrame({ ... 'time': np.arange(0, 1000, 0.1), # 0.1s timestep ... 'p': np.random.rand(10000), # Power data ... 'eta': np.random.rand(10000) # Wave elevation ... }) df_downsampled = farm.down_sample(df_original, 300.0) # 5min print(f"Original: {len(df_original)} points") print(f"Downsampled: {len(df_downsampled)} points") Original: 10000 points Downsampled: 33 points

Averaging Process
  1. Calculate sample ratio: How many original points per new point
  2. Determine new time grid: Based on sample period and alignment
  3. Window averaging: Mean value over each time window
  4. Energy conservation: Maintains total energy content
Time Alignment Options

timeshift = 0 (End-aligned): - New timestamps at end of averaging window - t_new = [T, 2T, 3T, ...] where T = new_sample_period

timeshift = 1 (Center-aligned): - New timestamps at center of averaging window - t_new = [T/2, T+T/2, 2T+T/2, ...] where T = new_sample_period

Data Processing
  • First window: Averages from start to first sample point
  • Subsequent windows: Averages over fixed-width windows
  • Missing data: Handles partial windows at end of series
  • Column preservation: Maintains all non-time columns
Performance Considerations
  • Memory efficient: Uses vectorized pandas operations
  • Flexible windows: Handles non-integer sample ratios
  • Large datasets: Suitable for long WEC simulations
  • Numerical stability: Robust averaging implementation
Grid Integration Usage
  • PSS®E studies: 5-minute resolution for stability analysis
  • Economic dispatch: Hourly or 15-minute intervals
  • Load forecasting: Daily or weekly aggregation
  • Resource assessment: Monthly or seasonal averages
Wave Energy Applications
  • Power smoothing: Reduces high-frequency fluctuations
  • Grid compliance: Matches utility data requirements
  • Forecast validation: Aligns with meteorological predictions
  • Storage sizing: Determines energy storage requirements
Notes
  • Preserves energy content through proper averaging
  • Original time step must be consistent (fixed timestep)
  • New sample period should be multiple of original timestep
  • Returns DataFrame with same structure as input
  • Time column values updated to new sampling frequency
See Also

_prepare_farm: Uses this method for WEC data preprocessing WECGridTime: Provides target sampling frequencies pandas.DataFrame.resample: Alternative pandas resampling method

Source code in src/wecgrid/wec/farm.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
def down_sample(
    self, wec_df: pd.DataFrame, new_sample_period: float, timeshift: int = 0
) -> pd.DataFrame:
    """Downsample WEC time-series data to a coarser time resolution.

    Converts high-frequency WEC simulation data to lower frequency suitable for
    power system integration studies. Averages data over specified time windows
    to maintain energy conservation while reducing computational overhead.

    Based on MATLAB DownSampleTS function with pandas DataFrame implementation.

    Args:
        wec_df (pd.DataFrame): Original high-frequency WEC data with 'time' column.
            Must contain time series data with consistent time step.
        new_sample_period (float): New sampling period [seconds] for downsampled data.
            Typically 300s (5 minutes) for grid integration studies.
        timeshift (int, optional): Time alignment option. Defaults to 0.
            - 0: Samples at end of averaging period
            - 1: Samples centered within averaging period

    Returns:
        pd.DataFrame: Downsampled DataFrame with same columns as input.
            Time column adjusted to new sampling frequency.
            Data columns contain averaged values over sampling windows.

    Raises:
        ValueError: If new_sample_period is smaller than original time step.
        KeyError: If 'time' column not found in input DataFrame.

    Example:
        >>> # Downsample 0.1s WEC data to 5-minute intervals
        >>> df_original = pd.DataFrame({
        ...     'time': np.arange(0, 1000, 0.1),  # 0.1s timestep
        ...     'p': np.random.rand(10000),        # Power data
        ...     'eta': np.random.rand(10000)       # Wave elevation
        ... })
        >>> df_downsampled = farm.down_sample(df_original, 300.0)  # 5min
        >>> print(f"Original: {len(df_original)} points")
        >>> print(f"Downsampled: {len(df_downsampled)} points")
        Original: 10000 points
        Downsampled: 33 points

    Averaging Process:
        1. **Calculate sample ratio**: How many original points per new point
        2. **Determine new time grid**: Based on sample period and alignment
        3. **Window averaging**: Mean value over each time window
        4. **Energy conservation**: Maintains total energy content

    Time Alignment Options:
        **timeshift = 0** (End-aligned):
        - New timestamps at end of averaging window
        - t_new = [T, 2T, 3T, ...] where T = new_sample_period

        **timeshift = 1** (Center-aligned):
        - New timestamps at center of averaging window
        - t_new = [T/2, T+T/2, 2T+T/2, ...] where T = new_sample_period

    Data Processing:
        - **First window**: Averages from start to first sample point
        - **Subsequent windows**: Averages over fixed-width windows
        - **Missing data**: Handles partial windows at end of series
        - **Column preservation**: Maintains all non-time columns

    Performance Considerations:
        - **Memory efficient**: Uses vectorized pandas operations
        - **Flexible windows**: Handles non-integer sample ratios
        - **Large datasets**: Suitable for long WEC simulations
        - **Numerical stability**: Robust averaging implementation

    Grid Integration Usage:
        - **PSS®E studies**: 5-minute resolution for stability analysis
        - **Economic dispatch**: Hourly or 15-minute intervals
        - **Load forecasting**: Daily or weekly aggregation
        - **Resource assessment**: Monthly or seasonal averages

    Wave Energy Applications:
        - **Power smoothing**: Reduces high-frequency fluctuations
        - **Grid compliance**: Matches utility data requirements
        - **Forecast validation**: Aligns with meteorological predictions
        - **Storage sizing**: Determines energy storage requirements

    Notes:
        - Preserves energy content through proper averaging
        - Original time step must be consistent (fixed timestep)
        - New sample period should be multiple of original timestep
        - Returns DataFrame with same structure as input
        - Time column values updated to new sampling frequency

    See Also:
        _prepare_farm: Uses this method for WEC data preprocessing
        WECGridTime: Provides target sampling frequencies
        pandas.DataFrame.resample: Alternative pandas resampling method
    """
    if "time" not in wec_df.columns:
        raise KeyError("DataFrame must contain 'time' column for downsampling")

    # Calculate original time step (assuming fixed timestep)
    time_values = wec_df["time"].values
    if len(time_values) < 2:
        return wec_df.copy()  # Return original if too few points

    old_dt = time_values[1] - time_values[0]

    if new_sample_period <= old_dt:
        raise ValueError(
            f"New sample period ({new_sample_period}s) must be larger than original timestep ({old_dt}s)"
        )

    # Calculate sampling parameters
    t_sample = int(new_sample_period / old_dt)  # Points per new sample
    new_sample_size = int((time_values[-1] - time_values[0]) / new_sample_period)

    if new_sample_size <= 0:
        return wec_df.copy()  # Return original if downsampling not possible

    # Create new time grid
    if timeshift == 1:
        # Center-aligned timestamps
        new_times = np.arange(
            new_sample_period / 2,
            new_sample_size * new_sample_period + new_sample_period / 2,
            new_sample_period,
        )
    else:
        # End-aligned timestamps
        new_times = np.arange(
            new_sample_period,
            (new_sample_size + 1) * new_sample_period,
            new_sample_period,
        )

    # Ensure we don't exceed the original time range
    new_times = new_times[new_times <= time_values[-1]]
    new_sample_size = len(new_times)

    # Initialize downsampled DataFrame
    downsampled_data = {"time": new_times}

    # Downsample each data column (excluding time)
    data_columns = [col for col in wec_df.columns if col != "time"]

    for col in data_columns:
        downsampled_values = np.zeros(new_sample_size)

        for i in range(new_sample_size):
            if i == 0:
                # First window: from start to first sample point
                start_idx = 0
                end_idx = min(t_sample, len(wec_df))
            else:
                # Subsequent windows: fixed-width windows
                start_idx = (i - 1) * t_sample
                end_idx = min(i * t_sample, len(wec_df))

            if start_idx < len(wec_df) and end_idx > start_idx:
                downsampled_values[i] = wec_df[col].iloc[start_idx:end_idx].mean()
            else:
                downsampled_values[i] = 0.0  # Handle edge cases

        downsampled_data[col] = downsampled_values

    return pd.DataFrame(downsampled_data)

power_at_snapshot(timestamp)

Calculate total farm power output at a specific simulation time.

Aggregates active power output from all WEC devices in the farm at the specified timestamp. This method provides the primary interface for power system integration, enabling time-varying renewable generation modeling in grid simulations.

Parameters:

Name Type Description Default
timestamp Timestamp

Simulation time to query for power output. Must exist in the device DataFrame time index. Typically corresponds to grid simulation snapshots at 5-minute intervals.

required

Returns:

Name Type Description
float float

Total active power output from all farm devices in per-unit on the farm's sbase. Sum of individual device outputs at the specified time. Returns 0.0 if no valid data available at timestamp.

Raises:

Type Description
KeyError

If timestamp not found in device data index.

AttributeError

If device DataFrame not properly initialized.

Example

Get power at specific simulation time

timestamp = pd.Timestamp("2023-01-01 12:00:00") power_pu = farm.power_at_snapshot(timestamp) print(f"Farm output at noon: {power_pu:.4f} pu") Farm output at noon: 0.1575 pu

Time series power extraction

time_series = [] for snapshot in time_manager.snapshots: ... power_pu = farm.power_at_snapshot(snapshot) ... time_series.append(power_pu)

import matplotlib.pyplot as plt plt.plot(time_manager.snapshots, time_series) plt.ylabel("Farm Power Output [pu]")

Power Aggregation
  • Linear summation: Total = Σ(device_power[i] at timestamp)
  • Homogeneous devices: All devices have identical power profiles
  • Realistic scaling: Based on actual WEC device physics
  • Wave correlation: Devices respond to same ocean conditions
Data Requirements
  • Valid timestamp: Must exist in device DataFrame index
  • Initialized devices: All WECDevice objects must be properly created
  • Power column: Device data must contain "p" column for active power
  • Time alignment: Timestamp must match grid simulation schedule
Error Handling
  • Missing data warning: Prints warning for devices with no data
  • Graceful degradation: Continues calculation with available devices
  • Zero fallback: Returns 0.0 if no devices have valid data
  • Timestamp validation: Checks for existence in device index
Performance Considerations
  • O(n) complexity: Scales linearly with number of devices
  • DataFrame lookup: Efficient pandas indexing for time queries
  • Memory efficiency: No data copying, direct access to device data
  • Repeated calls: Suitable for time-series iteration
Grid Integration Usage
  • PSS®E integration: Provides generator output at each time step
  • PyPSA integration: Supplies renewable generation time series
  • Load flow studies: Time-varying injection for stability analysis
  • Economic dispatch: Variable renewable generation modeling
Wave Energy Characteristics
  • Intermittent output: Power varies with wave conditions
  • Predictable patterns: Follows ocean wave statistics
  • Seasonal variation: Higher output in winter storm seasons
  • Capacity factor: Typically 20-40% for ocean wave resources
Notes
  • Output is in per-unit on the farm's sbase; multiply by sbase for MW
  • Power output includes WEC device efficiency and control effects
  • All devices share identical profiles (same wave field assumption)
  • Negative power values possible during reactive conditions
  • Zero output during calm conditions or device maintenance
  • Farm total limited by grid connection capacity
See Also

WECDevice.dataframe: Individual device power time series Engine.simulate: Uses this method for grid integration WECGridPlotter.plot_wec_analysis: Visualizes farm power output

Source code in src/wecgrid/wec/farm.py
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
def power_at_snapshot(self, timestamp: pd.Timestamp) -> float:
    """Calculate total farm power output at a specific simulation time.

    Aggregates active power output from all WEC devices in the farm at the
    specified timestamp. This method provides the primary interface for
    power system integration, enabling time-varying renewable generation
    modeling in grid simulations.

    Args:
        timestamp (pd.Timestamp): Simulation time to query for power output.
            Must exist in the device DataFrame time index. Typically corresponds
            to grid simulation snapshots at 5-minute intervals.

    Returns:
        float: Total active power output from all farm devices in per-unit on
            the farm's ``sbase``. Sum of individual device outputs at the
            specified time. Returns 0.0 if no valid data available at
            timestamp.

    Raises:
        KeyError: If timestamp not found in device data index.
        AttributeError: If device DataFrame not properly initialized.

    Example:
        >>> # Get power at specific simulation time
        >>> timestamp = pd.Timestamp("2023-01-01 12:00:00")
        >>> power_pu = farm.power_at_snapshot(timestamp)
        >>> print(f"Farm output at noon: {power_pu:.4f} pu")
        Farm output at noon: 0.1575 pu

        >>> # Time series power extraction
        >>> time_series = []
        >>> for snapshot in time_manager.snapshots:
        ...     power_pu = farm.power_at_snapshot(snapshot)
        ...     time_series.append(power_pu)
        >>>
        >>> import matplotlib.pyplot as plt
        >>> plt.plot(time_manager.snapshots, time_series)
        >>> plt.ylabel("Farm Power Output [pu]")

    Power Aggregation:
        - **Linear summation**: Total = Σ(device_power[i] at timestamp)
        - **Homogeneous devices**: All devices have identical power profiles
        - **Realistic scaling**: Based on actual WEC device physics
        - **Wave correlation**: Devices respond to same ocean conditions

    Data Requirements:
        - **Valid timestamp**: Must exist in device DataFrame index
        - **Initialized devices**: All WECDevice objects must be properly created
        - **Power column**: Device data must contain "p" column for active power
        - **Time alignment**: Timestamp must match grid simulation schedule

    Error Handling:
        - **Missing data warning**: Prints warning for devices with no data
        - **Graceful degradation**: Continues calculation with available devices
        - **Zero fallback**: Returns 0.0 if no devices have valid data
        - **Timestamp validation**: Checks for existence in device index

    Performance Considerations:
        - **O(n) complexity**: Scales linearly with number of devices
        - **DataFrame lookup**: Efficient pandas indexing for time queries
        - **Memory efficiency**: No data copying, direct access to device data
        - **Repeated calls**: Suitable for time-series iteration

    Grid Integration Usage:
        - **PSS®E integration**: Provides generator output at each time step
        - **PyPSA integration**: Supplies renewable generation time series
        - **Load flow studies**: Time-varying injection for stability analysis
        - **Economic dispatch**: Variable renewable generation modeling

    Wave Energy Characteristics:
        - **Intermittent output**: Power varies with wave conditions
        - **Predictable patterns**: Follows ocean wave statistics
        - **Seasonal variation**: Higher output in winter storm seasons
        - **Capacity factor**: Typically 20-40% for ocean wave resources

    Notes:
        - Output is in per-unit on the farm's ``sbase``; multiply by
          ``sbase`` for MW
        - Power output includes WEC device efficiency and control effects
        - All devices share identical profiles (same wave field assumption)
        - Negative power values possible during reactive conditions
        - Zero output during calm conditions or device maintenance
        - Farm total limited by grid connection capacity

    See Also:
        WECDevice.dataframe: Individual device power time series
        Engine.simulate: Uses this method for grid integration
        WECGridPlotter.plot_wec_analysis: Visualizes farm power output
    """
    total_power = 0.0
    for device in self.wec_devices:
        if (
            device.dataframe is not None
            and not device.dataframe.empty
            and timestamp in device.dataframe.index
        ):
            power = device.dataframe.at[timestamp, "p"]
            total_power += power
        else:
            print(f"[WARNING] Missing data for {device.name} at {timestamp}")
    return total_power