From 85d1c6c66b81158c0b3d713e175d51acbf673449 Mon Sep 17 00:00:00 2001 From: Luke Lowery Date: Wed, 4 Feb 2026 01:37:33 -0600 Subject: [PATCH 1/5] Rm Unused deps --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0223178..4271d8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,6 @@ dependencies = [ "numpy<2.0", "scipy", "pywin32; sys_platform == 'win32'", - "geopandas", - "matplotlib" ] [project.optional-dependencies] @@ -67,6 +65,7 @@ test = [ ] dev = [ "matplotlib", + "geopandas", "pytest>=7.0", "pytest-cov>=4.0", "pytest-order>=1.2" @@ -77,7 +76,8 @@ docs = [ "sphinx-copybutton", "nbsphinx", "ipykernel", - "matplotlib" + "matplotlib", + "geopandas" ] [project.urls] From ca16c3dff440e72e0b6428ffa4d8de0c65183045 Mon Sep 17 00:00:00 2001 From: Luke Lowery Date: Fri, 6 Feb 2026 19:08:06 -0600 Subject: [PATCH 2/5] BusCat helper --- CHANGELOG.md | 30 +- docs/api/saw.rst | 468 +++++++------------------------ docs/api/utils.rst | 13 + docs/api/workbench.rst | 26 ++ docs/dev/tests.rst | 4 + esapp/saw/__init__.py | 12 + esapp/saw/_enums.py | 374 ++++++++++++++++++------ esapp/saw/atc.py | 12 +- esapp/saw/fault.py | 6 +- esapp/saw/general.py | 19 +- esapp/saw/gic.py | 6 +- esapp/saw/matrices.py | 33 +-- esapp/saw/powerflow.py | 8 +- esapp/saw/pv.py | 6 +- esapp/saw/qv.py | 6 +- esapp/saw/topology.py | 19 +- esapp/saw/transient.py | 8 +- esapp/utils/__init__.py | 6 +- esapp/utils/buscat.py | 305 ++++++++++++++++++++ esapp/utils/dynamics.py | 3 +- esapp/utils/gic.py | 4 +- esapp/workbench.py | 19 +- tests/test_buscat_unit.py | 185 ++++++++++++ tests/test_integration_buscat.py | 184 ++++++++++++ 24 files changed, 1230 insertions(+), 526 deletions(-) create mode 100644 esapp/utils/buscat.py create mode 100644 tests/test_buscat_unit.py create mode 100644 tests/test_integration_buscat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40315ea..adf11e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +[0.1.4] - Unreleased +-------------------- + +**Added** +- `BusCat` module (`esapp.utils.buscat`) for bus type classification and Jacobian structure analysis +- `BusType`, `BusCtrl`, `Role` enums for type-safe bus classification +- API documentation for BusCat, embedded modules, and new enums + [0.1.3] - 2026-02-03 -------------------- @@ -9,28 +17,24 @@ -------------------- **Changed** -- SimAuto Wrapper implementations' -- GridWorkbench is now PowerWorld -- Misc Performance Improvements +- Completed SimAuto Wrapper mixin implementations for full API coverage +- Renamed GridWorkbench to PowerWorld +- Miscellaneous performance improvements **Added** -- TS Field Helpers -- GICOption and SolveOption Helpers +- Transient stability field helpers (TS class with IDE intellisense) +- GICOption and SolverOption descriptor classes **Removed** -- Old specific application code - +- Legacy application-specific code [0.1.1] - 2026-01-25 -------------------- **Changed** - -- Improved the component dev tool -- More helper functions -- Coverage -- Misc. Still in Beta +- Improved component generation tool +- Added helper functions for data conversion +- Expanded test coverage **Added** - - SubData helper functions diff --git a/docs/api/saw.rst b/docs/api/saw.rst index f56965f..a01ef73 100644 --- a/docs/api/saw.rst +++ b/docs/api/saw.rst @@ -158,413 +158,147 @@ enums instead of raw strings provides IDE autocomplete, type checking, and preve .. code-block:: python - from esapp.saw import SolverMethod, FilterKeyword, LinearMethod + from esapp.saw import SolverMethod, FilterKeyword, BusType - # Use enums for type-safe parameters saw.SolvePowerFlow(SolverMethod.RECTNEWT) - saw.GetParametersMultipleElement("Bus", ["BusNum", "BusPUVolt"], FilterKeyword.ALL) + saw.GetParametersMultipleElement("Bus", ["BusNum"], FilterKeyword.ALL) -**SolverMethod** - Power flow solution algorithms - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``RECTNEWT`` - - Rectangular Newton-Raphson (default) - * - ``POLARNEWT`` - - Polar Newton-Raphson - * - ``GAUSSSEIDEL`` - - Gauss-Seidel iterative method - * - ``FASTDEC`` - - Fast Decoupled method - * - ``ROBUST`` - - Robust solver for difficult cases - * - ``DC`` - - DC power flow (linear approximation) - -**LinearMethod** - Sensitivity analysis methods (PTDF, LODF, shift factors) - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``DC`` - - DC linear method (most common default) - * - ``AC`` - - AC linear method - * - ``DCPS`` - - DC linear with post-solution adjustment - -**JacobianForm** - Jacobian matrix coordinate forms - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``RECTANGULAR`` - - AC Jacobian in Rectangular coordinates ("R") - * - ``POLAR`` - - AC Jacobian in Polar coordinates ("P") - * - ``DC`` - - B' matrix / DC approximation - -**FilterKeyword** - Special filter keywords (passed unquoted) - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``ALL`` - - Select all objects of the type - * - ``SELECTED`` - - Only objects currently selected in PowerWorld - * - ``AREAZONE`` - - Objects in the active area/zone filter - -**YesNo** - Boolean flags for PowerWorld commands - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``YES`` - - Affirmative / enable option - * - ``NO`` - - Negative / disable option - -Use ``YesNo.from_bool(value)`` to convert Python booleans. - -**ObjectType** - PowerWorld object type identifiers - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``BUS`` - - Bus/node - * - ``BRANCH`` - - Branch (line or transformer) - * - ``GEN`` - - Generator - * - ``LOAD`` - - Load - * - ``SHUNT`` - - Shunt device - * - ``AREA`` - - Control area - * - ``ZONE`` - - Zone - * - ``OWNER`` - - Owner - * - ``INTERFACE`` - - Interface (flowgate) - * - ``INJECTIONGROUP`` - - Injection group - * - ``BUSSHUNT`` - - Bus shunt - * - ``SUPERBUS`` - - Super bus (aggregated) - * - ``TRANSFORMER`` - - Transformer specifically - * - ``LINE`` - - Transmission line specifically - * - ``SUPERAREA`` - - Super area (aggregated) - -**KeyFieldType** - Result output key field types - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``PRIMARY`` - - Primary key fields (e.g., BusNum) - * - ``SECONDARY`` - - Secondary key fields (e.g., BusName) - * - ``LABEL`` - - Label-based identification - -**FileFormat** - Import/export file formats - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``CSV`` - - Comma-separated values - * - ``CSVCOLHEADER`` - - CSV with column headers - * - ``CSVNOHEADER`` - - CSV without headers - * - ``AUX`` - - PowerWorld auxiliary format - * - ``AUXCSV`` - - Hybrid auxiliary/CSV format - * - ``TAB`` - - Tab-separated format - * - ``PTI`` - - PTI/PSS-E format - * - ``TXT`` - - Text format - * - ``PWB`` - - PowerWorld case format - * - ``AXD`` - - Oneline diagram format - * - ``GE`` - - GE EPC format - * - ``CF`` - - Custom format - * - ``UCTE`` - - UCTE format - * - ``AREVAHDB`` - - AREVA HDB format - * - ``OPENNETEMS`` - - OPENNET EMS format - -**ObjectIDHandling** - Contingency export object ID modes - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``NO`` - - Standard object references - * - ``YES_MS_3W`` - - Include multi-section and 3-winding IDs - -**BranchDistanceMeasure** - Distance metrics for topology analysis - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``REACTANCE`` - - Use reactance (X) as distance measure - * - ``IMPEDANCE`` - - Use impedance magnitude (Z) as distance measure - -**BranchFilterMode** - Branch filter modes for topology traversal - -.. list-table:: - :header-rows: 1 - :widths: 25 75 - - * - Value - - Description - * - ``ALL`` - - All branches - * - ``SELECTED`` - - Only selected branches - * - ``CLOSED`` - - Only closed branches +.. currentmodule:: esapp.saw -**IslandReference** - Island reference options +Power Flow & Matrices +~~~~~~~~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: SolverMethod + :members: + :undoc-members: - * - Value - - Description - * - ``EXISTING`` - - Use existing island configuration - * - ``NO`` - - No area reference +.. autoclass:: LinearMethod + :members: + :undoc-members: -**ScalingBasis** - Load/generation scaling basis +.. autoclass:: JacobianForm + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +Bus Classification +~~~~~~~~~~~~~~~~~~ - * - Value - - Description - * - ``MW`` - - Absolute MW/MVAR values - * - ``FACTOR`` - - Multiplier factor +.. autoclass:: BusType + :members: + :undoc-members: -**InterfaceLimitSetting** - Interface limit configuration +.. autoclass:: BusCtrl + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: Role + :members: + :undoc-members: - * - Value - - Description - * - ``AUTO`` - - Automatic limit calculation - * - ``NONE`` - - No limit applied +Data Filters & Object Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**ShuntModel** - Shunt model types for line tapping +.. autoclass:: FilterKeyword + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: ObjectType + :members: + :undoc-members: - * - Value - - Description - * - ``CAPACITANCE`` - - Capacitive shunt model - * - ``INDUCTANCE`` - - Inductive shunt model +.. autoclass:: KeyFieldType + :members: + :undoc-members: -**BranchDeviceType** - Branch device types for bus splitting +Boolean & Option Flags +~~~~~~~~~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: YesNo + :members: - * - Value - - Description - * - ``Line`` - - Transmission line - * - ``Breaker`` - - Circuit breaker +File Formats +~~~~~~~~~~~~ -**StarBusHandling** - Star bus handling for case append +.. autoclass:: FileFormat + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +Transient Stability +~~~~~~~~~~~~~~~~~~~ - * - Value - - Description - * - ``NEAR`` - - Map to nearest bus (default) - * - ``MAX`` - - Map to maximum impedance bus +.. autoclass:: TSGetResultsMode + :members: + :undoc-members: -**MultiSectionLineHandling** - Multi-section line handling for case append +Topology & Sensitivity +~~~~~~~~~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: BranchDistanceMeasure + :members: + :undoc-members: - * - Value - - Description - * - ``MAINTAIN`` - - Maintain multisection line structure (default) - * - ``EQUIVALENCE`` - - Convert to equivalent circuits +.. autoclass:: BranchFilterMode + :members: + :undoc-members: -**OnelineLinkMode** - Oneline diagram linking modes +.. autoclass:: ScalingBasis + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: IslandReference + :members: + :undoc-members: - * - Value - - Description - * - ``LABELS`` - - Link objects by labels (default) - * - ``NUMBERS`` - - Link objects by numbers +.. autoclass:: InterfaceLimitSetting + :members: + :undoc-members: -**RatingSetPrecedence** - Rating set precedence for weather-based ratings +Case Operations +~~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: StarBusHandling + :members: + :undoc-members: - * - Value - - Description - * - ``NORMAL`` - - Use normal rating set - * - ``CTG`` - - Use contingency rating set +.. autoclass:: MultiSectionLineHandling + :members: + :undoc-members: -**RatingSet** - Rating set identifiers (A-O, DEFAULT, NO) +.. autoclass:: OnelineLinkMode + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: ShuntModel + :members: + :undoc-members: - * - Value - - Description - * - ``DEFAULT`` - - Use default rating - * - ``NO`` - - Don't update rating - * - ``A`` - ``O`` - - Rating sets A through O +.. autoclass:: BranchDeviceType + :members: + :undoc-members: -**FieldListColumn** - Column names for ``GetFieldList`` results +.. autoclass:: ObjectIDHandling + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +Ratings +~~~~~~~ - * - Value - - Description - * - ``KEY_FIELD`` - - Whether the field is a key field - * - ``INTERNAL_FIELD_NAME`` - - PowerWorld internal field name - * - ``FIELD_DATA_TYPE`` - - Data type of the field - * - ``DESCRIPTION`` - - Human-readable description - * - ``DISPLAY_NAME`` - - Display name in PowerWorld UI - * - ``ENTERABLE`` - - Whether the field can be edited - -**SpecificFieldListColumn** - Column names for ``GetSpecificFieldList`` results +.. autoclass:: RatingSetPrecedence + :members: + :undoc-members: -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: RatingSet + :members: + :undoc-members: - * - Value - - Description - * - ``VARIABLENAME_LOCATION`` - - Variable name with location - * - ``FIELD`` - - Field identifier - * - ``COLUMN_HEADER`` - - Column header label - * - ``FIELD_DESCRIPTION`` - - Human-readable description - * - ``ENTERABLE`` - - Whether the field can be edited - -**TSGetResultsMode** - Transient stability results save mode +Field Metadata +~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 25 75 +.. autoclass:: FieldListColumn + :members: - * - Value - - Description - * - ``SINGLE`` - - Single combined output file - * - ``SEPARATE`` - - Separate files per object - * - ``JSIS`` - - JSIS format output +.. autoclass:: SpecificFieldListColumn + :members: Helper Functions ---------------- diff --git a/docs/api/utils.rst b/docs/api/utils.rst index d5313cb..ef049e6 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -41,6 +41,19 @@ Network graph analysis including incidence matrices, Laplacians, and path calcul .. autoclass:: BranchType :members: +Bus Classification +------------------ + +Bus type classification engine for analyzing power flow Jacobian structure and voltage control. + +.. currentmodule:: esapp.utils.buscat + +.. autoclass:: BusCat + :members: + :show-inheritance: + +.. autofunction:: parse_buscat + Dynamics -------- diff --git a/docs/api/workbench.rst b/docs/api/workbench.rst index 70a9711..ed67dd9 100644 --- a/docs/api/workbench.rst +++ b/docs/api/workbench.rst @@ -9,6 +9,32 @@ SimAuto with a Pythonic interface for case management, data access, and analysis .. autoclass:: PowerWorld :members: +Embedded Analysis Modules +-------------------------- + +``PowerWorld`` hosts three embedded analysis modules that share the parent's +SimAuto connection: + +.. list-table:: + :widths: 20 80 + :header-rows: 0 + + * - ``pw.network`` + - Network topology analysis (incidence, Laplacian, paths). See :class:`~esapp.utils.network.Network`. + * - ``pw.gic`` + - GIC calculations and sensitivity. See :class:`~esapp.utils.gic.GIC`. + * - ``pw.buscat`` + - Bus type classification for Jacobian structure. See :class:`~esapp.utils.buscat.BusCat`. + +.. code-block:: python + + pw = PowerWorld("case.pwb") + pw.pflow() + + bc = pw.buscat.refresh() + pv_buses = bc.pv_idx() + v_set = bc.v_setpoints() + Descriptors ----------- diff --git a/docs/dev/tests.rst b/docs/dev/tests.rst index 976f073..8b51fd1 100644 --- a/docs/dev/tests.rst +++ b/docs/dev/tests.rst @@ -27,6 +27,8 @@ Test Coverage - Dynamics module: ContingencyBuilder, SimAction enum * - ``test_utils.py`` - Utility modules: timing decorator, B3D file format + * - ``test_buscat_unit.py`` + - BusCat string parsing: all known PowerWorld BusCat variants (Slack, PV, PQ with controls/roles/limits) **Integration Tests** — Require PowerWorld @@ -44,6 +46,8 @@ Test Coverage - Power flow, matrices (Ybus, Jacobian), PTDF/LODF * - ``test_integration_saw_contingency.py`` - Contingency auto-insertion, solving, OTDF + * - ``test_integration_buscat.py`` + - BusCat module: refresh, index methods, voltage setpoints, classification DataFrame * - ``test_integration_network.py`` - Network topology, incidence matrices, graph analysis * - ``test_integration_saw_gic.py`` diff --git a/esapp/saw/__init__.py b/esapp/saw/__init__.py index f001bbc..6766ca8 100644 --- a/esapp/saw/__init__.py +++ b/esapp/saw/__init__.py @@ -69,9 +69,15 @@ class built from numerous mixins. Each mixin corresponds to a specific RatingSet, FieldListColumn, SpecificFieldListColumn, + PowerWorldMode, + FaultType, + GICCalcMode, format_filter, format_filter_selected_only, format_filter_areazone, + BusType, + BusCtrl, + Role, ) @@ -129,7 +135,13 @@ class built from numerous mixins. Each mixin corresponds to a specific "RatingSet", "FieldListColumn", "SpecificFieldListColumn", + "PowerWorldMode", + "FaultType", + "GICCalcMode", "format_filter", "format_filter_selected_only", "format_filter_areazone", + "BusType", + "BusCtrl", + "Role", ] diff --git a/esapp/saw/_enums.py b/esapp/saw/_enums.py index 5be2a7f..1aaa53a 100644 --- a/esapp/saw/_enums.py +++ b/esapp/saw/_enums.py @@ -4,18 +4,33 @@ the SAW module, replacing hardcoded strings with type-safe enumerations. """ -from enum import Enum +from enum import Enum, IntFlag, auto from typing import Union -class YesNo(str, Enum): +class _StrEnum(str, Enum): + """Base class for string-valued enums with correct ``str()`` behavior. + + Python 3.11+ changed ``str()`` on ``str, Enum`` to return + ``'ClassName.MEMBER'`` instead of the value. This base class + restores the expected behavior so enums can be passed directly + to ``_run_script`` and pandas comparisons. + """ + + def __str__(self): + return self.value + + +class YesNo(_StrEnum): """Boolean flag values for PowerWorld commands. PowerWorld uses "YES" and "NO" strings for boolean parameters in script commands rather than true/false. """ YES = "YES" + """Affirmative / enable option.""" NO = "NO" + """Negative / disable option.""" @classmethod def from_bool(cls, value: bool) -> "YesNo": @@ -33,183 +48,260 @@ def from_bool(cls, value: bool) -> "YesNo": """ return cls.YES if value else cls.NO - def __str__(self): - return self.value - -class FilterKeyword(str, Enum): +class FilterKeyword(_StrEnum): """Special filter keywords for PowerWorld commands. These keywords are passed unquoted to PowerWorld, unlike custom filter names which must be quoted. """ SELECTED = "SELECTED" + """Only objects currently selected in PowerWorld.""" AREAZONE = "AREAZONE" + """Objects in the active area/zone filter.""" ALL = "ALL" + """Select all objects of the type.""" -class SolverMethod(str, Enum): +class SolverMethod(_StrEnum): """Power flow solution methods. These are the available solver algorithms for the SolvePowerFlow command. """ - RECTNEWT = "RECTNEWT" # Rectangular Newton-Raphson (default) - POLARNEWT = "POLARNEWT" # Polar Newton-Raphson - GAUSSSEIDEL = "GAUSSSEIDEL" # Gauss-Seidel - FASTDEC = "FASTDEC" # Fast Decoupled - ROBUST = "ROBUST" # Robust solver - DC = "DC" # DC power flow - - -class LinearMethod(str, Enum): + RECTNEWT = "RECTNEWT" + """Rectangular Newton-Raphson (default).""" + POLARNEWT = "POLARNEWT" + """Polar Newton-Raphson.""" + GAUSSSEIDEL = "GAUSSSEIDEL" + """Gauss-Seidel iterative method.""" + FASTDEC = "FASTDEC" + """Fast Decoupled method.""" + ROBUST = "ROBUST" + """Robust solver for difficult cases.""" + DC = "DC" + """DC power flow (linear approximation).""" + + +class LinearMethod(_StrEnum): """Linear calculation methods for sensitivity analysis. Used in PTDF, LODF, shift factor, and related calculations. """ - DC = "DC" # DC linear method (most common default) - AC = "AC" # AC linear method - DCPS = "DCPS" # DC linear with post-solution adjustment + DC = "DC" + """DC linear method (most common default).""" + AC = "AC" + """AC linear method.""" + DCPS = "DCPS" + """DC linear with post-solution adjustment.""" -class FileFormat(str, Enum): +class FileFormat(_StrEnum): """File format types for import/export operations.""" - CSV = "CSV" # Comma-separated values - CSVCOLHEADER = "CSVCOLHEADER" # CSV with column headers - CSVNOHEADER = "CSVNOHEADER" # CSV without headers - AUX = "AUX" # PowerWorld auxiliary format - AUXCSV = "AUXCSV" # Hybrid auxiliary/CSV format - TAB = "TAB" # Tab-separated format - PTI = "PTI" # PTI/PSS-E format - TXT = "TXT" # Text format - PWB = "PWB" # PowerWorld case format - AXD = "AXD" # Oneline diagram format - GE = "GE" # GE EPC format - CF = "CF" # Custom format - UCTE = "UCTE" # UCTE format - AREVAHDB = "AREVAHDB" # AREVA HDB format - OPENNETEMS = "OPENNETEMS" # OPENNET EMS format - - -class InterfaceLimitSetting(str, Enum): + CSV = "CSV" + """Comma-separated values.""" + CSVCOLHEADER = "CSVCOLHEADER" + """CSV with column headers.""" + CSVNOHEADER = "CSVNOHEADER" + """CSV without headers.""" + AUX = "AUX" + """PowerWorld auxiliary format.""" + AUXCSV = "AUXCSV" + """Hybrid auxiliary/CSV format.""" + TAB = "TAB" + """Tab-separated format.""" + PTI = "PTI" + """PTI/PSS-E format.""" + TXT = "TXT" + """Text format.""" + PWB = "PWB" + """PowerWorld case format.""" + AXD = "AXD" + """Oneline diagram format.""" + GE = "GE" + """GE EPC format.""" + CF = "CF" + """Custom format.""" + UCTE = "UCTE" + """UCTE format.""" + AREVAHDB = "AREVAHDB" + """AREVA HDB format.""" + OPENNETEMS = "OPENNETEMS" + """OPENNET EMS format.""" + + +class InterfaceLimitSetting(_StrEnum): """Interface limit configuration values.""" - AUTO = "AUTO" # Automatic limit calculation - NONE = "NONE" # No limit applied + AUTO = "AUTO" + """Automatic limit calculation.""" + NONE = "NONE" + """No limit applied.""" -class ObjectType(str, Enum): +class ObjectType(_StrEnum): """PowerWorld object type identifiers. These are used for filtering and operations on specific element types. """ BUS = "BUS" + """Bus/node.""" BRANCH = "BRANCH" + """Branch (line or transformer).""" GEN = "GEN" + """Generator.""" LOAD = "LOAD" + """Load.""" SHUNT = "SHUNT" + """Shunt device.""" AREA = "AREA" + """Control area.""" ZONE = "ZONE" + """Zone.""" OWNER = "OWNER" + """Owner.""" INTERFACE = "INTERFACE" + """Interface (flowgate).""" INJECTIONGROUP = "INJECTIONGROUP" + """Injection group.""" BUSSHUNT = "BUSSHUNT" + """Bus shunt.""" SUPERBUS = "SUPERBUS" + """Super bus (aggregated).""" TRANSFORMER = "TRANSFORMER" + """Transformer specifically.""" LINE = "LINE" + """Transmission line specifically.""" SUPERAREA = "SUPERAREA" + """Super area (aggregated).""" -class KeyFieldType(str, Enum): +class KeyFieldType(_StrEnum): """Key field types for result output.""" PRIMARY = "PRIMARY" + """Primary key fields (e.g., BusNum).""" SECONDARY = "SECONDARY" + """Secondary key fields (e.g., BusName).""" LABEL = "LABEL" + """Label-based identification.""" -class StarBusHandling(str, Enum): +class StarBusHandling(_StrEnum): """Star bus handling options for case append operations.""" - NEAR = "NEAR" # Map to nearest bus (default) - MAX = "MAX" # Map to maximum impedance bus + NEAR = "NEAR" + """Map to nearest bus (default).""" + MAX = "MAX" + """Map to maximum impedance bus.""" -class MultiSectionLineHandling(str, Enum): +class MultiSectionLineHandling(_StrEnum): """Multi-section line handling options for case append operations.""" - MAINTAIN = "MAINTAIN" # Maintain multisection line structure (default) - EQUIVALENCE = "EQUIVALENCE" # Convert to equivalent circuits + MAINTAIN = "MAINTAIN" + """Maintain multisection line structure (default).""" + EQUIVALENCE = "EQUIVALENCE" + """Convert to equivalent circuits.""" -class IslandReference(str, Enum): +class IslandReference(_StrEnum): """Island reference options for sensitivity analysis.""" - EXISTING = "EXISTING" # Use existing island configuration - NO = "NO" # No area reference + EXISTING = "EXISTING" + """Use existing island configuration.""" + NO = "NO" + """No area reference.""" -class OnelineLinkMode(str, Enum): +class OnelineLinkMode(_StrEnum): """Oneline diagram linking modes.""" - LABELS = "LABELS" # Link objects by labels (default) - NUMBERS = "NUMBERS" # Link objects by numbers + LABELS = "LABELS" + """Link objects by labels (default).""" + NUMBERS = "NUMBERS" + """Link objects by numbers.""" -class ShuntModel(str, Enum): +class ShuntModel(_StrEnum): """Shunt model types for line tapping operations.""" CAPACITANCE = "CAPACITANCE" + """Capacitive shunt model.""" INDUCTANCE = "INDUCTANCE" + """Inductive shunt model.""" -class BranchDeviceType(str, Enum): +class BranchDeviceType(_StrEnum): """Branch device types for bus splitting operations.""" LINE = "Line" + """Transmission line.""" + TRANSFORMER = "Transformer" + """Transformer.""" BREAKER = "Breaker" + """Circuit breaker.""" -class TSGetResultsMode(str, Enum): +class TSGetResultsMode(_StrEnum): """Mode for saving transient stability results.""" SINGLE = "SINGLE" + """Single combined output file.""" SEPARATE = "SEPARATE" + """Separate files per object.""" JSIS = "JSIS" + """JSIS format output.""" -class JacobianForm(str, Enum): +class JacobianForm(_StrEnum): """Jacobian matrix coordinate forms.""" - RECTANGULAR = "R" # AC Jacobian in Rectangular coordinates - POLAR = "P" # AC Jacobian in Polar coordinates - DC = "DC" # B' matrix (DC approximation) + RECTANGULAR = "R" + """AC Jacobian in Rectangular coordinates.""" + POLAR = "P" + """AC Jacobian in Polar coordinates.""" + DC = "DC" + """B' matrix / DC approximation.""" -class BranchDistanceMeasure(str, Enum): +class BranchDistanceMeasure(_StrEnum): """Branch distance measurement types for topology analysis.""" - REACTANCE = "X" # Use reactance as distance measure - IMPEDANCE = "Z" # Use impedance magnitude as distance measure + REACTANCE = "X" + """Use reactance (X) as distance measure.""" + IMPEDANCE = "Z" + """Use impedance magnitude (Z) as distance measure.""" -class BranchFilterMode(str, Enum): +class BranchFilterMode(_StrEnum): """Branch filter modes for topology traversal.""" - ALL = "ALL" # All branches - SELECTED = "SELECTED" # Only selected branches - CLOSED = "CLOSED" # Only closed branches + ALL = "ALL" + """All branches.""" + SELECTED = "SELECTED" + """Only selected branches.""" + CLOSED = "CLOSED" + """Only closed branches.""" -class ScalingBasis(str, Enum): +class ScalingBasis(_StrEnum): """Scaling basis for load/generation scaling operations.""" - MW = "MW" # Absolute MW/MVAR values - FACTOR = "FACTOR" # Multiplier factor + MW = "MW" + """Absolute MW/MVAR values.""" + FACTOR = "FACTOR" + """Multiplier factor.""" -class ObjectIDHandling(str, Enum): +class ObjectIDHandling(_StrEnum): """Object ID handling modes for contingency export.""" - NO = "NO" # Standard object references - YES_MS_3W = "YES_MS_3W" # Include multi-section and 3-winding IDs + NO = "NO" + """Standard object references.""" + YES_MS_3W = "YES_MS_3W" + """Include multi-section and 3-winding IDs.""" -class RatingSetPrecedence(str, Enum): +class RatingSetPrecedence(_StrEnum): """Rating set precedence for weather-based ratings.""" - NORMAL = "NORMAL" # Use normal rating set - CTG = "CTG" # Use contingency rating set + NORMAL = "NORMAL" + """Use normal rating set.""" + CTG = "CTG" + """Use contingency rating set.""" -class RatingSet(str, Enum): +class RatingSet(_StrEnum): """Rating set identifiers for branch limits.""" - DEFAULT = "DEFAULT" # Use default rating - NO = "NO" # Don't update rating + DEFAULT = "DEFAULT" + """Use default rating.""" + NO = "NO" + """Don't update rating.""" A = "A" B = "B" C = "C" @@ -227,18 +319,24 @@ class RatingSet(str, Enum): O = "O" -class FieldListColumn(str, Enum): +class FieldListColumn(_StrEnum): """Column names for GetFieldList results. PowerWorld returns field metadata with these column headers. Different Simulator versions may return different subsets of these columns. """ KEY_FIELD = "key_field" + """Whether the field is a key field.""" INTERNAL_FIELD_NAME = "internal_field_name" + """PowerWorld internal field name.""" FIELD_DATA_TYPE = "field_data_type" + """Data type of the field.""" DESCRIPTION = "description" + """Human-readable description.""" DISPLAY_NAME = "display_name" + """Display name in PowerWorld UI.""" ENTERABLE = "enterable" + """Whether the field can be edited.""" @classmethod def base_columns(cls) -> list: @@ -274,16 +372,21 @@ def new_columns(cls) -> list: ] -class SpecificFieldListColumn(str, Enum): +class SpecificFieldListColumn(_StrEnum): """Column names for GetSpecificFieldList results. PowerWorld returns specific field metadata with these column headers. """ VARIABLENAME_LOCATION = "variablename:location" + """Variable name with location.""" FIELD = "field" + """Field identifier.""" COLUMN_HEADER = "column header" + """Column header label.""" FIELD_DESCRIPTION = "field description" + """Human-readable description.""" ENTERABLE = "enterable" + """Whether the field can be edited.""" @classmethod def base_columns(cls) -> list: @@ -397,3 +500,110 @@ def format_filter_areazone(filter_name: FilterType) -> str: return filter_name return f'"{filter_name}"' + + +class PowerWorldMode(_StrEnum): + """PowerWorld Simulator operating modes.""" + RUN = "RUN" + """Run mode for executing simulations.""" + EDIT = "EDIT" + """Edit mode for modifying case data.""" + + +class FaultType(_StrEnum): + """Fault types for fault analysis calculations.""" + SLG = "SLG" + """Single Line to Ground fault.""" + LL = "LL" + """Line to Line fault.""" + THREE_PHASE = "3PB" + """Three Phase Balanced fault.""" + DLG = "DLG" + """Double Line to Ground fault.""" + + +class GICCalcMode(_StrEnum): + """GIC calculation mode options.""" + SNAPSHOT = "SnapShot" + """Single time point calculation.""" + TIME_VARYING = "TimeVarying" + """Time series from uniform field.""" + NON_UNIFORM_TIME_VARYING = "NonUniformTimeVarying" + """Time series with spatial variation.""" + SPATIALLY_UNIFORM_TIME_VARYING = "SpatiallyUniformTimeVarying" + """Spatially uniform time series.""" + + +# --------------------------------------------------------------------------- +# Bus Category Classification +# --------------------------------------------------------------------------- + + +class BusType(_StrEnum): + """Power flow bus type from the BusCat field. + + The three fundamental bus types that determine which equations + a bus contributes to the power flow Jacobian. + + Attributes + ---------- + SLACK : str + Reference bus — fixes voltage magnitude and angle. + PV : str + Generator bus — specifies P injection and V magnitude. + PQ : str + Load bus — specifies P and Q injection. + """ + SLACK = "Slack" + PV = "PV" + PQ = "PQ" + + +class BusCtrl(IntFlag): + """Voltage control modifier flags from BusCat qualifiers. + + Bitwise-combinable flags describing how a bus participates in + voltage regulation. A remotely regulated bus with droop control + would have ``BusCtrl.REMOTE | BusCtrl.DROOP``. + + Attributes + ---------- + NONE : int + No special control. + REMOTE : int + Remote voltage regulation (controls voltage at another bus). + DROOP : int + Voltage droop control with deadband. + LDC : int + Line drop compensation. + TOL : int + Voltage setpoint tolerance band (PVTol mode). + """ + NONE = 0 + REMOTE = auto() + DROOP = auto() + LDC = auto() + TOL = auto() + + +class Role(_StrEnum): + """Bus role in a voltage regulation group. + + When multiple generators coordinate to regulate voltage at a + remote bus, each participating bus takes on a distinct role. + + Attributes + ---------- + NONE : str + Not part of a regulation group (local control only). + PRIMARY : str + Enforces the voltage equation at the regulated bus. + SECONDARY : str + Shares reactive power proportionally with the primary. + TARGET : str + The bus whose voltage is being regulated remotely. + """ + NONE = "None" + PRIMARY = "Primary" + SECONDARY = "Secondary" + TARGET = "Target" diff --git a/esapp/saw/atc.py b/esapp/saw/atc.py index b571560..901747b 100644 --- a/esapp/saw/atc.py +++ b/esapp/saw/atc.py @@ -1,8 +1,8 @@ """Available Transfer Capability (ATC) specific functions.""" import pandas as pd -from typing import List +from typing import List, Union -from ._enums import YesNo +from ._enums import YesNo, KeyFieldType, FileFormat from ._helpers import format_list, pack_args @@ -235,7 +235,7 @@ def ATCTakeMeToScenario(self, rl: int, g: int, i: int): """ return self._run_script("ATCTakeMeToScenario", rl, g, i) - def ATCDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: str = "PRIMARY"): + def ATCDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY): """Writes out all information related to ATC analysis to an auxiliary file. Saves the same information as the ATCWriteResultsAndOptions script command. @@ -267,7 +267,7 @@ def ATCDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_ app = YesNo.from_bool(append) return self._run_script("ATCDataWriteOptionsAndResults", f'"{filename}"', app, key_field) - def ATCWriteAllOptions(self, filename: str, append: bool = True, key_field: str = "PRIMARY"): + def ATCWriteAllOptions(self, filename: str, append: bool = True, key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY): """Writes out all information related to ATC analysis (deprecated name). .. deprecated:: @@ -348,7 +348,7 @@ def ATCWriteScenarioLog(self, filename: str, append: bool = False, filter_name: def ATCWriteScenarioMinMax( self, filename: str, - filetype: str = "CSV", + filetype: Union[FileFormat, str] = FileFormat.CSV, append: bool = False, fieldlist: List[str] = None, operation: str = "MIN", @@ -417,7 +417,7 @@ def ATCWriteToExcel(self, worksheet_name: str, fieldlist: List[str] = None): fields = ", " + format_list(fieldlist) if fieldlist else "" return self.RunScriptCommand(f'ATCWriteToExcel("{worksheet_name}"{fields});') - def ATCWriteToText(self, filename: str, filetype: str = "TAB", fieldlist: List[str] = None): + def ATCWriteToText(self, filename: str, filetype: Union[FileFormat, str] = FileFormat.TAB, fieldlist: List[str] = None): """Writes Multiple Scenario ATC analysis results to text files. Parameters diff --git a/esapp/saw/fault.py b/esapp/saw/fault.py index 915ab5d..36c2814 100644 --- a/esapp/saw/fault.py +++ b/esapp/saw/fault.py @@ -1,7 +1,9 @@ """Fault analysis specific functions.""" -from esapp.saw._enums import YesNo +from typing import Union + +from esapp.saw._enums import YesNo, FaultType class FaultMixin: @@ -10,7 +12,7 @@ class FaultMixin: def RunFault( self, element: str, - fault_type: str, + fault_type: Union[FaultType, str], r: float = 0.0, x: float = 0.0, location: float = None, diff --git a/esapp/saw/general.py b/esapp/saw/general.py index 1a1b8ac..5be28b4 100644 --- a/esapp/saw/general.py +++ b/esapp/saw/general.py @@ -3,7 +3,10 @@ import os, re import pandas as pd -from ._enums import YesNo, format_filter, format_filter_areazone +from ._enums import ( + YesNo, format_filter, format_filter_areazone, + PowerWorldMode, FileFormat, +) from ._helpers import (format_list, get_temp_filepath, parse_aux_content, build_aux_string) @@ -194,13 +197,14 @@ def SetCurrentDirectory(self, directory: str, create_if_not_found: bool = False) c = YesNo.from_bool(create_if_not_found) return self._run_script("SetCurrentDirectory", f'"{directory}"', c) - def EnterMode(self, mode: str) -> None: + def EnterMode(self, mode: Union[PowerWorldMode, str]) -> None: """Enters PowerWorld Simulator into a specific operating mode. Parameters ---------- - mode : str - The mode to enter. Must be either "RUN" or "EDIT". + mode : Union[PowerWorldMode, str] + The mode to enter. Must be PowerWorldMode.RUN, PowerWorldMode.EDIT, + or the equivalent strings "RUN" / "EDIT". Returns ------- @@ -213,9 +217,10 @@ def EnterMode(self, mode: str) -> None: PowerWorldError If the SimAuto call fails. """ - if mode.upper() not in ["RUN", "EDIT"]: + m = mode.value if isinstance(mode, PowerWorldMode) else mode.upper() + if m not in [PowerWorldMode.RUN.value, PowerWorldMode.EDIT.value]: raise ValueError("Mode must be either 'RUN' or 'EDIT'.") - return self._run_script("EnterMode", mode.upper()) + return self._run_script("EnterMode", m) def StoreState(self, statename: str) -> None: """Stores the current state of the PowerWorld case under a given name. @@ -303,7 +308,7 @@ def LoadAux(self, filename: str, create_if_not_found: bool = False): c = YesNo.from_bool(create_if_not_found) return self._run_script("LoadAux", f'"{filename}"', c) - def ImportData(self, filename: str, filetype: str, header_line: int = 1, create_if_not_found: bool = False): + def ImportData(self, filename: str, filetype: Union[FileFormat, str] = FileFormat.CSV, header_line: int = 1, create_if_not_found: bool = False): """Imports data from a file in various formats into PowerWorld Simulator. Parameters diff --git a/esapp/saw/gic.py b/esapp/saw/gic.py index e8b672d..507b9f3 100644 --- a/esapp/saw/gic.py +++ b/esapp/saw/gic.py @@ -1,7 +1,7 @@ """Geomagnetically Induced Current (GIC) specific functions.""" -from typing import List +from typing import List, Union -from ._enums import YesNo +from ._enums import YesNo, KeyFieldType class GICMixin: @@ -340,7 +340,7 @@ def GICWriteFilePTI(self, filename: str, use_filters: bool = False, version: int uf = YesNo.from_bool(use_filters) return self._run_script("GICWriteFilePTI", f'"{filename}"', uf, version) - def GICWriteOptions(self, filename: str, key_field: str = "PRIMARY"): + def GICWriteOptions(self, filename: str, key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY): """Writes the current GIC solution options to an auxiliary file. Parameters diff --git a/esapp/saw/matrices.py b/esapp/saw/matrices.py index d833e10..2b39f2d 100644 --- a/esapp/saw/matrices.py +++ b/esapp/saw/matrices.py @@ -6,7 +6,7 @@ import numpy as np from scipy.sparse import csr_matrix -from ._enums import YesNo +from ._enums import YesNo, JacobianForm from ._helpers import get_temp_filepath @@ -44,7 +44,7 @@ def get_ybus(self, full: bool = False, file: Union[str, None] = None) -> Union[n _cleanup = False else: _tempfile_path = get_temp_filepath(".mat") - self._run_script("SaveYbusInMatlabFormat", f'"{_tempfile_path}"', "NO") + self._run_script("SaveYbusInMatlabFormat", f'"{_tempfile_path}"', YesNo.NO) _cleanup = True try: with open(_tempfile_path, "r") as f: @@ -175,7 +175,7 @@ def get_gmatrix_with_ids(self, full: bool = False): os.unlink(g_matrix_path) os.unlink(id_file_path) - def get_jacobian(self, full: bool = False, form: str = 'R') -> Union[np.ndarray, csr_matrix]: + def get_jacobian(self, full: bool = False, form: Union[JacobianForm, str] = JacobianForm.RECTANGULAR) -> Union[np.ndarray, csr_matrix]: """Get the power flow Jacobian matrix. This method calls the `SaveJacobian` script command to write the Jacobian @@ -188,9 +188,8 @@ def get_jacobian(self, full: bool = False, form: str = 'R') -> Union[np.ndarray, full : bool, optional If True, returns a dense NumPy array. If False (default), returns a SciPy CSR sparse matrix. - form : str, optional - Jacobian coordinate form: 'R' for rectangular, 'P' for polar, - 'DC' for B' matrix. Defaults to 'R'. + form : Union[JacobianForm, str], optional + Jacobian coordinate form. Defaults to JacobianForm.RECTANGULAR. Returns ------- @@ -204,9 +203,10 @@ def get_jacobian(self, full: bool = False, form: str = 'R') -> Union[np.ndarray, FileNotFoundError If the temporary matrix file is not created. """ + f = form.value if isinstance(form, JacobianForm) else form jac_file_path, id_file_path = self._make_temp_matrix_files() try: - self._run_script("SaveJacobian", f'"{jac_file_path}"', f'"{id_file_path}"', "M", form) + self._run_script("SaveJacobian", f'"{jac_file_path}"', f'"{id_file_path}"', "M", f) with open(jac_file_path, "r") as f: mat_str = f.read() sparse_matrix = self._parse_real_matrix(mat_str, "Jac") @@ -215,7 +215,7 @@ def get_jacobian(self, full: bool = False, form: str = 'R') -> Union[np.ndarray, os.unlink(jac_file_path) os.unlink(id_file_path) - def get_jacobian_with_ids(self, full: bool = False, form: str = 'R'): + def get_jacobian_with_ids(self, full: bool = False, form: Union[JacobianForm, str] = JacobianForm.RECTANGULAR): """Get the power flow Jacobian matrix along with row/column ID mapping. Returns both the Jacobian matrix and a list of identifiers describing @@ -227,9 +227,8 @@ def get_jacobian_with_ids(self, full: bool = False, form: str = 'R'): full : bool, optional If True, returns a dense NumPy array. If False (default), returns a SciPy CSR sparse matrix. - form : str, optional - Jacobian coordinate form: 'R' for rectangular, 'P' for polar, - 'DC' for B' matrix. Defaults to 'R'. + form : Union[JacobianForm, str], optional + Jacobian coordinate form. Defaults to JacobianForm.RECTANGULAR. Returns ------- @@ -238,9 +237,10 @@ def get_jacobian_with_ids(self, full: bool = False, form: str = 'R'): - jacobian_matrix: The Jacobian as either dense array or sparse CSR matrix - row_ids: List of strings describing each row/column """ + f = form.value if isinstance(form, JacobianForm) else form jac_file_path, id_file_path = self._make_temp_matrix_files() try: - self._run_script("SaveJacobian", f'"{jac_file_path}"', f'"{id_file_path}"', "M", form) + self._run_script("SaveJacobian", f'"{jac_file_path}"', f'"{id_file_path}"', "M", f) with open(jac_file_path, "r") as f: mat_str = f.read() @@ -317,7 +317,7 @@ def _parse_real_matrix(self, mat_str, matrix_name="Jac"): data.append(float(real)) return csr_matrix((data, (np.asarray(row) - 1, np.asarray(col) - 1)), shape=(n, n)) - def SaveJacobian(self, jac_filename: str, jid_filename: str, file_type: str = "M", jac_form: str = "R"): + def SaveJacobian(self, jac_filename: str, jid_filename: str, file_type: str = "M", jac_form: Union[JacobianForm, str] = JacobianForm.RECTANGULAR): """Saves the Jacobian Matrix to a text file or a file formatted for use with Matlab. Parameters @@ -328,10 +328,11 @@ def SaveJacobian(self, jac_filename: str, jid_filename: str, file_type: str = "M File to save a description of what each row and column of the Jacobian represents. file_type : str, optional "M" for Matlab form, "TXT" for text file, "EXPM" for Matlab exponential form. Defaults to "M". - jac_form : str, optional - "R" for AC Jacobian in Rectangular coordinates, "P" for Polar, "DC" for B' matrix. Defaults to "R". + jac_form : Union[JacobianForm, str], optional + Jacobian coordinate form. Defaults to JacobianForm.RECTANGULAR. """ - return self._run_script("SaveJacobian", f'"{jac_filename}"', f'"{jid_filename}"', file_type, jac_form) + f = jac_form.value if isinstance(jac_form, JacobianForm) else jac_form + return self._run_script("SaveJacobian", f'"{jac_filename}"', f'"{jid_filename}"', file_type, f) def SaveYbusInMatlabFormat(self, filename: str, include_voltages: bool = False): """Saves the YBus to a file formatted for use with Matlab.""" diff --git a/esapp/saw/powerflow.py b/esapp/saw/powerflow.py index 427ede9..cce790b 100644 --- a/esapp/saw/powerflow.py +++ b/esapp/saw/powerflow.py @@ -2,7 +2,7 @@ import pandas as pd from ._exceptions import PowerWorldError -from ._enums import YesNo, SolverMethod, format_filter +from ._enums import YesNo, SolverMethod, FilterKeyword, KeyFieldType, FilterType, format_filter class PowerflowMixin: @@ -136,7 +136,7 @@ def ZeroOutMismatches(self, object_type: str = "BUSSHUNT"): """ return self._run_script("ZeroOutMismatches", object_type) - def ConditionVoltagePockets(self, voltage_threshold: float, angle_threshold: float, filter_name: str = "ALL"): + def ConditionVoltagePockets(self, voltage_threshold: float, angle_threshold: float, filter_name: FilterType = FilterKeyword.ALL): """Finds pockets of buses with bad initial voltage estimates and conditions them. Identifies pockets of buses bounded by branches that meet the condition that @@ -198,7 +198,7 @@ def DiffCaseSetAsBase(self): """Sets the present case as the base case for difference case comparison.""" return self._run_script("DiffCaseSetAsBase") - def DiffCaseKeyType(self, key_type: str): + def DiffCaseKeyType(self, key_type: Union[KeyFieldType, str]): """Changes the key type used when comparing fields in difference case mode. Parameters @@ -232,7 +232,7 @@ def DiffCaseRefresh(self): """ return self._run_script("DiffCaseRefresh") - def DiffCaseWriteCompleteModel(self, filename: str, append: bool = False, save_added: bool = True, save_removed: bool = True, save_both: bool = True, key_fields: str = "PRIMARY", export_format: str = "", use_area_zone: bool = False, use_data_maintainer: bool = False, assume_base_meet: bool = True, include_clear_pf_aids: bool = True, delete_branches_flip: bool = False): + def DiffCaseWriteCompleteModel(self, filename: str, append: bool = False, save_added: bool = True, save_removed: bool = True, save_both: bool = True, key_fields: Union[KeyFieldType, str] = KeyFieldType.PRIMARY, export_format: str = "", use_area_zone: bool = False, use_data_maintainer: bool = False, assume_base_meet: bool = True, include_clear_pf_aids: bool = True, delete_branches_flip: bool = False): """Creates an auxiliary file with difference case comparison information. Creates an auxiliary file containing information about objects that have been diff --git a/esapp/saw/pv.py b/esapp/saw/pv.py index fffb93f..e54e1fe 100644 --- a/esapp/saw/pv.py +++ b/esapp/saw/pv.py @@ -1,7 +1,9 @@ """PV (Power-Voltage) Analysis specific functions.""" -from esapp.saw._enums import YesNo +from typing import Union + +from esapp.saw._enums import YesNo, KeyFieldType class PVMixin: @@ -50,7 +52,7 @@ def PVRun(self, source: str, sink: str): """ return self._run_script("PVRun", source, sink) - def PVDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: str = "PRIMARY"): + def PVDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY): """ Write all PV analysis information to an auxiliary file. diff --git a/esapp/saw/qv.py b/esapp/saw/qv.py index 8843a0a..b7d3e9f 100644 --- a/esapp/saw/qv.py +++ b/esapp/saw/qv.py @@ -4,14 +4,16 @@ import pandas as pd -from esapp.saw._enums import YesNo +from typing import Union + +from esapp.saw._enums import YesNo, KeyFieldType from ._helpers import get_temp_filepath class QVMixin: """Mixin for QV analysis functions.""" - def QVDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: str = "PRIMARY"): + def QVDataWriteOptionsAndResults(self, filename: str, append: bool = True, key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY): """ Write all QV analysis information to an auxiliary file. diff --git a/esapp/saw/topology.py b/esapp/saw/topology.py index 8225681..a824da1 100644 --- a/esapp/saw/topology.py +++ b/esapp/saw/topology.py @@ -1,8 +1,13 @@ import os from pathlib import Path +from typing import Union import pandas as pd -from ._enums import YesNo, format_filter +from ._enums import ( + YesNo, format_filter, + BranchDistanceMeasure as _BranchDistanceMeasure, + BranchFilterMode, FileFormat, +) from ._helpers import format_list, get_temp_filepath @@ -11,8 +16,8 @@ class TopologyMixin: def DeterminePathDistance( self, start: str, - BranchDistMeas: str = "X", - BranchFilter: str = "ALL", + BranchDistMeas: Union[_BranchDistanceMeasure, str] = _BranchDistanceMeasure.REACTANCE, + BranchFilter: Union[BranchFilterMode, str] = BranchFilterMode.ALL, BusField="CustomFloat:1", ) -> pd.DataFrame: """ @@ -50,7 +55,7 @@ def DeterminePathDistance( self._run_script("DeterminePathDistance", start, BranchDistMeas, BranchFilter, BusField) def DetermineBranchesThatCreateIslands( - self, Filter: str = "ALL", StoreBuses: bool = True, SetSelectedOnLines: bool = False + self, Filter: Union[BranchFilterMode, str] = BranchFilterMode.ALL, StoreBuses: bool = True, SetSelectedOnLines: bool = False ) -> pd.DataFrame: """ Determine which branches, if opened, would create electrical islands. @@ -90,14 +95,14 @@ def DetermineBranchesThatCreateIslands( sb = YesNo.from_bool(StoreBuses) ssl = YesNo.from_bool(SetSelectedOnLines) try: - self._run_script("DetermineBranchesThatCreateIslands", Filter, sb, f'"{filename}"', ssl, "CSV") + self._run_script("DetermineBranchesThatCreateIslands", Filter, sb, f'"{filename}"', ssl, FileFormat.CSV) return pd.read_csv(filename, header=0) finally: if os.path.exists(filename): os.unlink(filename) def DetermineShortestPath( - self, start: str, end: str, BranchDistanceMeasure: str = "X", BranchFilter: str = "ALL" + self, start: str, end: str, BranchDistanceMeasure: Union[_BranchDistanceMeasure, str] = _BranchDistanceMeasure.REACTANCE, BranchFilter: Union[BranchFilterMode, str] = BranchFilterMode.ALL ) -> pd.DataFrame: """ Calculate the shortest path between two network locations. @@ -379,7 +384,7 @@ def ExpandBusTopology(self, bus_identifier: str, topology_type: str): """ return self._run_script("ExpandBusTopology", bus_identifier, topology_type) - def SaveConsolidatedCase(self, filename: str, filetype: str = "PWB", bus_format: str = "Number", truncate_ctg_labels: bool = False, add_comments: bool = False): + def SaveConsolidatedCase(self, filename: str, filetype: Union[FileFormat, str] = FileFormat.PWB, bus_format: str = "Number", truncate_ctg_labels: bool = False, add_comments: bool = False): """ Save the full topology model as a consolidated case file. diff --git a/esapp/saw/transient.py b/esapp/saw/transient.py index 1ca9fde..955e26b 100644 --- a/esapp/saw/transient.py +++ b/esapp/saw/transient.py @@ -3,7 +3,7 @@ import numpy as np import pandas as pd -from ._enums import YesNo, TSGetResultsMode +from ._enums import YesNo, TSGetResultsMode, KeyFieldType, FilterKeyword, FileFormat from ._exceptions import PowerWorldError from ._helpers import format_list, get_temp_filepath, load_ts_csv_results, pack_args @@ -131,7 +131,7 @@ def TSClearResultsFromRAM( This is a wrapper for the ``TSClearResultsFromRAM`` script command. """ - if ctg_name.upper() not in ["ALL", "SELECTED"] and not ctg_name.startswith('"'): + if ctg_name.upper() not in [FilterKeyword.ALL.value, FilterKeyword.SELECTED.value] and not ctg_name.startswith('"'): ctg_name = f'"{ctg_name}"' c_sum = YesNo.from_bool(clear_summary) @@ -227,7 +227,7 @@ def TSWriteOptions( save_plot_definitions: bool = True, save_transient_limit_monitors: bool = True, save_result_analyzer_time_window: bool = True, - key_field: str = "PRIMARY", + key_field: Union[KeyFieldType, str] = KeyFieldType.PRIMARY, ): """Save transient stability option settings to an auxiliary file.""" opts = [ @@ -483,7 +483,7 @@ def TSSetSelectedForTransientReferences( self._run_script("TSSetSelectedForTransientReferences", set_what, set_how, objs, models) def TSSaveDynamicModels( - self, filename: str, file_type: str, object_type: str, filter_name: str = "", append: bool = False + self, filename: str, file_type: Union[FileFormat, str] = "AUX", object_type: str = "", filter_name: str = "", append: bool = False ): """Save dynamics models for specified object types to file.""" app = YesNo.from_bool(append) diff --git a/esapp/utils/__init__.py b/esapp/utils/__init__.py index 99b82c9..cdbb774 100644 --- a/esapp/utils/__init__.py +++ b/esapp/utils/__init__.py @@ -3,7 +3,7 @@ Provides tools for: - Binary data formats (B3D electric field data) -- Analysis modules (GIC, network topology, dynamics, contingency) +- Analysis modules (GIC, network topology, bus classification, dynamics, contingency) - Function decorators for debugging and profiling """ @@ -15,6 +15,7 @@ from .contingency import ContingencyBuilder, SimAction from .network import Network, BranchType from .dynamics import TSWatch, process_ts_results, get_ts_results +from .buscat import BusCat, parse_buscat __all__ = [ # misc @@ -33,4 +34,7 @@ 'TSWatch', 'process_ts_results', 'get_ts_results', + # buscat + 'BusCat', + 'parse_buscat', ] diff --git a/esapp/utils/buscat.py b/esapp/utils/buscat.py new file mode 100644 index 0000000..a1fe8e0 --- /dev/null +++ b/esapp/utils/buscat.py @@ -0,0 +1,305 @@ +""" +Bus Category Classification +============================ + +Parses PowerWorld's ``BusCat`` field into structured bus types, +control flags, and regulation roles. Provides index-based access +for Jacobian construction and voltage control analysis. + +Classes +------- +BusCat + Parses BusCat strings and provides typed bus index access. + +Functions +--------- +parse_buscat + Parse a single BusCat string into a classification dict. + +See Also +-------- +esapp.saw._enums.BusType : Fundamental bus type enum. +esapp.saw._enums.BusCtrl : Voltage control modifier flags. +esapp.saw._enums.Role : Regulation group role enum. +esapp.utils.network : Network topology matrices. +""" +from __future__ import annotations + +from typing import Optional + +import pandas as pd +from pandas import DataFrame + +from ..saw._enums import BusType, BusCtrl, Role +from ..components import Bus + +__all__ = ['BusCat', 'parse_buscat'] + + +def parse_buscat(cat: str) -> dict: + """Parse a BusCat string into a bus classification dict. + + Extracts the base type (Slack/PV/PQ), control modifiers, + regulation role, limit status, and effective type from the + descriptive string PowerWorld returns in the BusCat field. + + Parameters + ---------- + cat : str + Raw BusCat string from PowerWorld, e.g. + ``"PQ (Remotely Regulated at Var Limit)"``. + + Returns + ------- + dict + Keys: + + - **Type** (*str*) -- Base bus type name (``"SLACK"``, + ``"PV"``, or ``"PQ"``). + - **Ctrl** (*str*) -- Control flags joined by ``"+"``, + e.g. ``"REMOTE+DROOP"`` or ``"NONE"``. + - **Role** (*str*) -- Regulation role name, or ``""`` + if the bus is not part of a regulation group. + - **Lim** (*bool*) -- True if the bus is at a reactive + power limit. + - **SVC** (*bool*) -- True if the bus has SVC or + continuous shunt control. + - **Eff** (*str*) -- Effective type after limit + enforcement (a PV bus at its limit becomes PQ). + - **Reg** (*bool*) -- True if the bus is actively + regulating voltage (PV/Slack and not limited). + """ + s = str(cat).lower() + + # --- Base type --- + if "slack" in s: typ = BusType.SLACK + elif s.startswith("pv"): typ = BusType.PV + else: typ = BusType.PQ + + # --- Control flags --- + ctrl = BusCtrl.NONE + if "remote" in s or "droop" in s: ctrl |= BusCtrl.REMOTE + if "droop" in s: ctrl |= BusCtrl.DROOP + if "line drop" in s: ctrl |= BusCtrl.LDC + if "tol" in s: ctrl |= BusCtrl.TOL + + # --- Regulation role --- + if "remotely regulated" in s or "droop reg bus" in s: + role = Role.TARGET + elif "secondary" in s or "droop remote bus" in s: + role = Role.SECONDARY + elif "primary" in s or "local/remote" in s: + role = Role.PRIMARY + else: + role = Role.NONE + + # --- Derived state --- + limited = "limit" in s + svc = "svc" in s or "continuous" in s + eff = BusType.PQ if (limited and typ == BusType.PV) else typ + active = typ in (BusType.PV, BusType.SLACK) and not limited + ctrl_str = "+".join( + f.name for f in BusCtrl if f in ctrl and f.name + ) or "NONE" + + return { + "Type": typ.name, + "Ctrl": ctrl_str, + "Role": role.name if role != Role.NONE else "", + "Lim": limited, + "SVC": svc, + "Eff": eff.name, + "Reg": active, + } + + +class BusCat: + """Parsed bus type classifications from a solved power flow case. + + Fetches the ``BusCat`` field from PowerWorld, parses each bus + into its type, control mode, regulation role, and limit status, + then provides index-based access for selecting bus subsets. + + Typical usage after solving power flow:: + + >>> pw = PowerWorld("case.pwb") + >>> pw.pflow() + >>> bc = pw.buscat.refresh() + >>> pv = bc.pv_idx() + >>> v_set = bc.v_setpoints() + >>> q_buses = bc.has_q_eqn_idx() + + The internal DataFrame is available via the :attr:`df` property + and contains one row per bus with columns: ``VSet``, ``LimLow``, + ``LimHigh``, ``Type``, ``Ctrl``, ``Role``, ``Lim``, ``SVC``, + ``Eff``, ``Reg``. + + Attributes + ---------- + df : DataFrame + Parsed classification data. Raises ``RuntimeError`` if + accessed before :meth:`refresh` is called. + """ + + _COL_MAP = { + "BusRGVoltSet": "VSet", + "BusVoltLimLow": "LimLow", + "BusVoltLimHigh": "LimHigh", + } + _FIELDS = [ + "BusCat", "BusRGVoltSet", "BusVoltLimLow", "BusVoltLimHigh", + ] + + def __init__(self, pw=None): + self._pw = pw + self._df: Optional[DataFrame] = None + + @property + def df(self) -> DataFrame: + """Parsed bus classification DataFrame. + + Raises + ------ + RuntimeError + If :meth:`refresh` has not been called yet. + """ + if self._df is None: + raise RuntimeError( + "No data. Call refresh() after solving power flow." + ) + return self._df + + def refresh(self) -> 'BusCat': + """Fetch BusCat from PowerWorld and rebuild classifications. + + Must be called after each power flow solve, since bus types + can change when generators hit reactive limits. + + Returns + ------- + BusCat + Self, for method chaining. + """ + raw = self._pw[Bus, self._FIELDS] + df = raw.rename(columns=self._COL_MAP) + parsed = DataFrame( + [parse_buscat(c) for c in df["BusCat"]], + index=raw.index, + ) + self._df = pd.concat([df, parsed], axis=1).drop(columns=["BusCat"]) + return self + + def _idx(self, mask) -> list: + """Return bus indices where mask is True.""" + return self.df.index[mask].tolist() + + def _mask_v_eqn(self): + """Boolean mask for buses with a voltage equation.""" + return self.df["Eff"].isin([BusType.PV.name, BusType.SLACK.name]) + + def slack_idx(self) -> list: + """Indices of Slack buses.""" + return self._idx(self.df["Type"] == BusType.SLACK.name) + + def pv_idx(self, active_only: bool = True) -> list: + """Indices of PV buses. + + Parameters + ---------- + active_only : bool, default True + If True, exclude PV buses that have hit a reactive + limit (they are effectively PQ). + """ + mask = self.df["Type"] == BusType.PV.name + return self._idx(mask & self.df["Reg"]) if active_only else self._idx(mask) + + def pq_idx(self) -> list: + """Indices of PQ buses (originally typed as PQ).""" + return self._idx(self.df["Type"] == BusType.PQ.name) + + def eff_pv_idx(self) -> list: + """Indices of buses effectively acting as PV.""" + return self._idx(self.df["Eff"] == BusType.PV.name) + + def eff_pq_idx(self) -> list: + """Indices of buses effectively acting as PQ (includes limited PV).""" + return self._idx(self.df["Eff"] == BusType.PQ.name) + + def has_p_eqn_idx(self) -> list: + """Buses with an active power balance equation (all except Slack).""" + return self._idx(self.df["Eff"] != BusType.SLACK.name) + + def has_q_eqn_idx(self) -> list: + """Buses with a reactive power balance equation (effective PQ only).""" + return self._idx(self.df["Eff"] == BusType.PQ.name) + + def no_q_eqn_idx(self) -> list: + """Buses without a Q equation (PV and Slack regulate voltage instead).""" + return self._idx(self.df["Eff"] != BusType.PQ.name) + + def has_v_eqn_idx(self) -> list: + """Buses with a voltage magnitude equation (effective PV and Slack).""" + return self._idx(self._mask_v_eqn()) + + def v_setpoints(self): + """Voltage setpoints for buses with a V equation. + + Returns values in the same order as :meth:`has_v_eqn_idx`. + + Returns + ------- + numpy.ndarray + Per-unit voltage setpoints. + """ + return self.df.loc[self._mask_v_eqn(), "VSet"].values + + def constrained_idx(self) -> list: + """Indices of buses at a reactive power limit.""" + return self._idx(self.df["Lim"]) + + def svc_idx(self) -> list: + """Indices of buses with SVC or continuous shunt control.""" + return self._idx(self.df["SVC"]) + + def regulating_idx(self) -> list: + """Indices of buses actively regulating voltage.""" + return self._idx(self.df["Reg"]) + + # --- By regulation role --- + + def primary_idx(self) -> list: + """Indices of primary regulating buses.""" + return self._idx(self.df["Role"] == Role.PRIMARY.name) + + def secondary_idx(self) -> list: + """Indices of secondary regulating buses.""" + return self._idx(self.df["Role"] == Role.SECONDARY.name) + + def target_idx(self) -> list: + """Indices of remotely regulated target buses.""" + return self._idx(self.df["Role"] == Role.TARGET.name) + + def local_only_idx(self) -> list: + """Indices of regulating buses with local control only (no remote/droop).""" + return self._idx((self.df["Role"] == "") & self.df["Reg"]) + + # --- DataFrame accessors --- + + def pv(self, active_only: bool = True) -> DataFrame: + """DataFrame of PV bus classifications. + + Parameters + ---------- + active_only : bool, default True + If True, only include PV buses still regulating. + """ + mask = self.df["Type"] == BusType.PV.name + return self.df[mask & self.df["Reg"]] if active_only else self.df[mask] + + def constrained(self) -> DataFrame: + """DataFrame of buses at reactive power limits.""" + return self.df[self.df["Lim"]] + + def remote_masters(self) -> DataFrame: + """DataFrame of primary regulating buses in remote control groups.""" + return self.df[self.df["Role"] == Role.PRIMARY.name] diff --git a/esapp/utils/dynamics.py b/esapp/utils/dynamics.py index 06b6d03..cdb2947 100644 --- a/esapp/utils/dynamics.py +++ b/esapp/utils/dynamics.py @@ -12,6 +12,7 @@ from pandas import DataFrame from ..components import GObject +from ..saw._enums import TSGetResultsMode logger = logging.getLogger(__name__) @@ -103,7 +104,7 @@ def get_ts_results(esa, ctg: str, fields: List[str]) -> Tuple[Optional[DataFrame Tuple[Optional[DataFrame], Optional[DataFrame]] Tuple of (Metadata DataFrame, Data DataFrame), or (None, None). """ - result = esa.TSGetResults("SEPARATE", [ctg], fields) + result = esa.TSGetResults(TSGetResultsMode.SEPARATE, [ctg], fields) if result is None: return None, None return result diff --git a/esapp/utils/gic.py b/esapp/utils/gic.py index 98595c3..3d3a6ff 100644 --- a/esapp/utils/gic.py +++ b/esapp/utils/gic.py @@ -17,6 +17,8 @@ from typing import Union, Optional +from ..saw._enums import GICCalcMode + import numpy as np from pandas import DataFrame, read_csv from scipy.sparse import csr_matrix, eye as speye, hstack, vstack, diags @@ -102,7 +104,7 @@ def configure( self, pf_include: bool = True, ts_include: bool = False, - calc_mode: str = 'SnapShot' + calc_mode: Union[GICCalcMode, str] = GICCalcMode.SNAPSHOT ) -> None: """ Configure GIC options with sensible defaults. diff --git a/esapp/workbench.py b/esapp/workbench.py index fd78fda..b7e1d5c 100644 --- a/esapp/workbench.py +++ b/esapp/workbench.py @@ -8,10 +8,12 @@ from .utils.gic import GIC from .utils.network import Network +from .utils.buscat import BusCat from .utils.dynamics import get_ts_results, process_ts_results from .indexable import Indexable from .components import Bus, Branch, Gen, Load, Shunt, Area, Zone, Sim_Solution_Options from .saw._helpers import create_object_string +from .saw._enums import JacobianForm, SolverMethod, LinearMethod, PowerWorldMode, BranchDeviceType from ._descriptors import SolverOption import tempfile @@ -33,6 +35,7 @@ def __init__(self, fname: Optional[str] = None): # Embedded application modules (back-reference to self) self.network = Network(self) self.gic = GIC(self) + self.buscat = BusCat(self) if fname: self.fname = fname @@ -213,7 +216,7 @@ def ybus(self, dense: bool = False): """ return self.esa.get_ybus(dense) - def jacobian(self, dense: bool = False, form: str = 'R', ids: bool = False): + def jacobian(self, dense: bool = False, form: Union[JacobianForm, str] = JacobianForm.RECTANGULAR, ids: bool = False): """ Get the power flow Jacobian matrix. @@ -271,7 +274,7 @@ def buscoords(self, astuple: bool = True): """ return self.network.buscoords(astuple) - def pflow(self, getvolts: bool = True, method: str = "POLARNEWT") -> Optional[Union[pd.Series, Tuple[pd.Series, pd.Series]]]: + def pflow(self, getvolts: bool = True, method: Union[SolverMethod, str] = SolverMethod.POLARNEWT) -> Optional[Union[pd.Series, Tuple[pd.Series, pd.Series]]]: """ Solve Power Flow. @@ -423,11 +426,11 @@ def close(self) -> None: def edit_mode(self) -> None: """Enter PowerWorld into EDIT mode.""" - self.esa.EnterMode("EDIT") + self.esa.EnterMode(PowerWorldMode.EDIT) def run_mode(self) -> None: """Enter PowerWorld into RUN mode.""" - self.esa.EnterMode("RUN") + self.esa.EnterMode(PowerWorldMode.RUN) # --- Data Retrieval --- @@ -474,7 +477,7 @@ def lines(self) -> DataFrame: All branch fields for branches with ``BranchDeviceType == "Line"``. """ branches = self[Branch, :] - return branches[branches["BranchDeviceType"] == "Line"] + return branches[branches["BranchDeviceType"] == BranchDeviceType.LINE] def transformers(self) -> DataFrame: """ @@ -486,7 +489,7 @@ def transformers(self) -> DataFrame: All branch fields for branches with ``BranchDeviceType == "Transformer"``. """ branches = self[Branch, :] - return branches[branches["BranchDeviceType"] == "Transformer"] + return branches[branches["BranchDeviceType"] == BranchDeviceType.TRANSFORMER] def areas(self) -> DataFrame: """ @@ -557,7 +560,7 @@ def overloads(self, threshold: float = 100.0) -> DataFrame: df = self.flows() return df[df["LinePercent"] > threshold] - def ptdf(self, seller: int, buyer: int, method: str = "DC") -> DataFrame: + def ptdf(self, seller: int, buyer: int, method: Union[LinearMethod, str] = LinearMethod.DC) -> DataFrame: """Calculate Power Transfer Distribution Factors. Parameters @@ -579,7 +582,7 @@ def ptdf(self, seller: int, buyer: int, method: str = "DC") -> DataFrame: self.esa.CalculatePTDF(seller_str, buyer_str, method) return self[Branch, ["LinePTDF"]] - def lodf(self, branch: tuple, method: str = "DC") -> DataFrame: + def lodf(self, branch: tuple, method: Union[LinearMethod, str] = LinearMethod.DC) -> DataFrame: """Calculate Line Outage Distribution Factors. Parameters diff --git a/tests/test_buscat_unit.py b/tests/test_buscat_unit.py new file mode 100644 index 0000000..f8b80f4 --- /dev/null +++ b/tests/test_buscat_unit.py @@ -0,0 +1,185 @@ +""" +Unit tests for parse_buscat string parsing logic. + +These are **unit tests** that do NOT require PowerWorld Simulator. +They test the pure-Python BusCat string parser against all known +BusCat string variants returned by PowerWorld. + +USAGE: + pytest tests/test_buscat_unit.py -v +""" +import pytest +from esapp.utils.buscat import parse_buscat + + +# ============================================================================= +# parse_buscat — exhaustive BusCat string test matrix +# ============================================================================= + + +class TestParseBuscat: + """Test parse_buscat against all known BusCat strings.""" + + # --- Slack --- + + def test_slack(self): + r = parse_buscat("Slack") + assert r["Type"] == "SLACK" + assert r["Ctrl"] == "NONE" + assert r["Role"] == "" + assert r["Lim"] is False + assert r["SVC"] is False + assert r["Eff"] == "SLACK" + assert r["Reg"] is True + + # --- PQ variants --- + + def test_pq_plain(self): + r = parse_buscat("PQ") + assert r["Type"] == "PQ" + assert r["Ctrl"] == "NONE" + assert r["Role"] == "" + assert r["Lim"] is False + assert r["SVC"] is False + assert r["Eff"] == "PQ" + assert r["Reg"] is False + + def test_pq_gens_at_var_limit(self): + r = parse_buscat("PQ (Gens at Var Limit)") + assert r["Type"] == "PQ" + assert r["Lim"] is True + assert r["SVC"] is False + assert r["Eff"] == "PQ" + + def test_pq_remotely_regulated(self): + r = parse_buscat("PQ (Remotely Regulated)") + assert r["Type"] == "PQ" + assert r["Ctrl"] == "REMOTE" + assert r["Role"] == "TARGET" + assert r["Lim"] is False + + def test_pq_remote_reg_secondary(self): + r = parse_buscat("PQ (Remote Reg Secondary)") + assert r["Type"] == "PQ" + assert r["Ctrl"] == "REMOTE" + assert r["Role"] == "SECONDARY" + + def test_pq_svc_at_limit(self): + r = parse_buscat("PQ (SVC at Limit)") + assert r["Lim"] is True + assert r["SVC"] is True + + def test_pq_continuous_shunts_at_var_limit(self): + r = parse_buscat("PQ (Continuous Shunts at Var Limit)") + assert r["Lim"] is True + assert r["SVC"] is True + + def test_pq_remotely_regulated_at_var_limit(self): + r = parse_buscat("PQ (Remotely Regulated at Var Limit)") + assert r["Ctrl"] == "REMOTE" + assert r["Role"] == "TARGET" + assert r["Lim"] is True + + def test_pq_line_drop_comp(self): + r = parse_buscat("PQ (Line Drop Comp)") + assert r["Ctrl"] == "LDC" + assert r["Role"] == "" + assert r["SVC"] is False + + def test_pq_svc_line_drop_comp(self): + r = parse_buscat("PQ (SVC Line Drop Comp)") + assert r["Ctrl"] == "LDC" + assert r["SVC"] is True + + def test_pq_voltage_droop_reg_bus(self): + r = parse_buscat("PQ (Voltage Droop Reg Bus)") + assert "REMOTE" in r["Ctrl"] + assert "DROOP" in r["Ctrl"] + assert r["Role"] == "TARGET" + + def test_pq_voltage_droop_remote_bus(self): + r = parse_buscat("PQ (Voltage Droop Remote Bus)") + assert "REMOTE" in r["Ctrl"] + assert "DROOP" in r["Ctrl"] + assert r["Role"] == "SECONDARY" + + def test_pq_voltage_droop_reg_bus_at_var_limit(self): + r = parse_buscat("PQ (Voltage Droop Reg Bus at Var Limit)") + assert "REMOTE" in r["Ctrl"] + assert "DROOP" in r["Ctrl"] + assert r["Role"] == "TARGET" + assert r["Lim"] is True + + def test_pq_voltage_droop_remote_bus_at_var_limit(self): + r = parse_buscat("PQ (Voltage Droop Remote Bus at Var Limit)") + assert "REMOTE" in r["Ctrl"] + assert "DROOP" in r["Ctrl"] + assert r["Role"] == "SECONDARY" + assert r["Lim"] is True + + # --- PV variants --- + + def test_pv_plain(self): + r = parse_buscat("PV") + assert r["Type"] == "PV" + assert r["Ctrl"] == "NONE" + assert r["Role"] == "" + assert r["Lim"] is False + assert r["SVC"] is False + assert r["Eff"] == "PV" + assert r["Reg"] is True + + def test_pv_remote_reg_primary(self): + r = parse_buscat("PV (Remote Reg Primary)") + assert r["Type"] == "PV" + assert r["Ctrl"] == "REMOTE" + assert r["Role"] == "PRIMARY" + + def test_pv_svc(self): + r = parse_buscat("PV (SVC)") + assert r["Type"] == "PV" + assert r["SVC"] is True + assert r["Reg"] is True + + def test_pv_local_remote_reg_primary(self): + r = parse_buscat("PV (Local/Remote Reg Primary)") + assert r["Type"] == "PV" + assert r["Ctrl"] == "REMOTE" + assert r["Role"] == "PRIMARY" + + def test_pvtol(self): + r = parse_buscat("PVTol") + assert r["Type"] == "PV" + assert r["Ctrl"] == "TOL" + assert r["Role"] == "" + assert r["Eff"] == "PV" + + def test_pvtol_remote_reg_primary(self): + r = parse_buscat("PVTol (Remote Reg Primary)") + assert r["Type"] == "PV" + assert "REMOTE" in r["Ctrl"] + assert "TOL" in r["Ctrl"] + assert r["Role"] == "PRIMARY" + + def test_pvtol_local_remote_reg_primary(self): + r = parse_buscat("PVTol (Local/Remote Reg Primary)") + assert r["Type"] == "PV" + assert "REMOTE" in r["Ctrl"] + assert "TOL" in r["Ctrl"] + assert r["Role"] == "PRIMARY" + + # --- Effective type and regulation --- + + def test_pq_at_limit_stays_pq(self): + """PQ buses at limit are still effectively PQ.""" + r = parse_buscat("PQ (Gens at Var Limit)") + assert r["Eff"] == "PQ" + + def test_pq_never_regulates(self): + """PQ buses never regulate voltage.""" + r = parse_buscat("PQ") + assert r["Reg"] is False + + def test_slack_always_regulates(self): + r = parse_buscat("Slack") + assert r["Reg"] is True diff --git a/tests/test_integration_buscat.py b/tests/test_integration_buscat.py new file mode 100644 index 0000000..346ffa3 --- /dev/null +++ b/tests/test_integration_buscat.py @@ -0,0 +1,184 @@ +""" +Integration tests for the BusCat class (esapp.utils.buscat). + +These are **integration tests** that require a live connection to PowerWorld +Simulator via the SimAuto COM interface. They test BusCat classification +against a real solved power flow case. + +REQUIREMENTS: + - PowerWorld Simulator installed with SimAuto COM registered + - A valid PowerWorld case file path set in ``tests/config_test.py`` + (variable ``SAW_TEST_CASE``) or via the ``SAW_TEST_CASE`` env variable + +USAGE: + pytest tests/test_integration_buscat.py -v +""" +import pytest +import pandas as pd + +from esapp.utils.buscat import BusCat +from esapp.components import Bus +from esapp.workbench import PowerWorld + +pytestmark = [ + pytest.mark.integration, + pytest.mark.requires_case, +] + + +@pytest.fixture(scope="module") +def pw(saw_session): + """PowerWorld instance connected to the session SAW.""" + pw = PowerWorld() + pw.esa = saw_session + return pw + + +@pytest.fixture(scope="module") +def bc(pw): + """BusCat instance after solving power flow and refreshing.""" + pw.pflow() + return pw.buscat.refresh() + + +class TestBusCat: + + @pytest.mark.order(7000) + def test_refresh_returns_self(self, pw): + result = pw.buscat.refresh() + assert result is pw.buscat + + @pytest.mark.order(7010) + def test_df_has_expected_columns(self, bc): + expected = {"VSet", "LimLow", "LimHigh", + "Type", "Ctrl", "Role", "Lim", "SVC", "Eff", "Reg"} + assert expected.issubset(set(bc.df.columns)) + + @pytest.mark.order(7020) + def test_df_row_count_matches_buses(self, bc, pw): + n_bus = len(pw[Bus]) + assert len(bc.df) == n_bus + + @pytest.mark.order(7100) + def test_type_partition_covers_all_buses(self, bc): + """slack + pv(all) + pq = all buses.""" + all_idx = sorted( + bc.slack_idx() + bc.pv_idx(active_only=False) + bc.pq_idx() + ) + expected = sorted(bc.df.index.tolist()) + assert all_idx == expected + + @pytest.mark.order(7110) + def test_equation_partition(self, bc): + """has_q_eqn + no_q_eqn = all buses (disjoint).""" + q = set(bc.has_q_eqn_idx()) + no_q = set(bc.no_q_eqn_idx()) + all_buses = set(bc.df.index.tolist()) + assert q | no_q == all_buses + assert q & no_q == set() + + @pytest.mark.order(7120) + def test_has_p_eqn_excludes_slack(self, bc): + p = set(bc.has_p_eqn_idx()) + slack = set(bc.slack_idx()) + assert p & slack == set() + assert p | slack == set(bc.df.index.tolist()) + + @pytest.mark.order(7130) + def test_has_v_eqn_matches_eff_pv_and_slack(self, bc): + v = set(bc.has_v_eqn_idx()) + pv_slack = set(bc.eff_pv_idx() + bc.slack_idx()) + assert v == pv_slack + + @pytest.mark.order(7200) + def test_at_least_one_slack(self, bc): + assert len(bc.slack_idx()) >= 1 + + @pytest.mark.order(7210) + def test_v_setpoints_length(self, bc): + assert len(bc.v_setpoints()) == len(bc.has_v_eqn_idx()) + + @pytest.mark.order(7220) + def test_v_setpoints_non_negative(self, bc): + """Voltage setpoints should be non-negative (per-unit).""" + v = bc.v_setpoints() + if len(v) > 0: + assert (v >= 0).all() + + @pytest.mark.order(7300) + def test_effective_type_partition(self, bc): + """eff_slack + eff_pv + eff_pq = all buses.""" + all_idx = sorted( + bc.slack_idx() + bc.eff_pv_idx() + bc.eff_pq_idx() + ) + expected = sorted(bc.df.index.tolist()) + assert all_idx == expected + + @pytest.mark.order(7400) + def test_regulating_subset_of_pv_and_slack(self, bc): + """Regulating buses must be PV or Slack type.""" + reg = set(bc.regulating_idx()) + pv_slack = set( + bc.pv_idx(active_only=False) + bc.slack_idx() + ) + assert reg.issubset(pv_slack) + + @pytest.mark.order(7500) + def test_constrained_have_lim_flag(self, bc): + """Every constrained bus should have Lim=True in the DataFrame.""" + for idx in bc.constrained_idx(): + assert bc.df.loc[idx, "Lim"] == True + + @pytest.mark.order(7600) + def test_pv_dataframe_accessor(self, bc): + pv_df = bc.pv() + assert isinstance(pv_df, pd.DataFrame) + if len(pv_df) > 0: + assert (pv_df["Type"] == "PV").all() + assert (pv_df["Reg"]).all() + + @pytest.mark.order(7610) + def test_remote_masters_have_primary_role(self, bc): + rm = bc.remote_masters() + if len(rm) > 0: + assert (rm["Role"] == "PRIMARY").all() + + @pytest.mark.order(7700) + def test_svc_idx(self, bc): + """svc_idx returns a list and all flagged buses have SVC=True.""" + svc = bc.svc_idx() + assert isinstance(svc, list) + for idx in svc: + assert bc.df.loc[idx, "SVC"] == True + + @pytest.mark.order(7710) + def test_primary_idx(self, bc): + """primary_idx returns only PRIMARY role buses.""" + for idx in bc.primary_idx(): + assert bc.df.loc[idx, "Role"] == "PRIMARY" + + @pytest.mark.order(7720) + def test_secondary_idx(self, bc): + """secondary_idx returns only SECONDARY role buses.""" + for idx in bc.secondary_idx(): + assert bc.df.loc[idx, "Role"] == "SECONDARY" + + @pytest.mark.order(7730) + def test_target_idx(self, bc): + """target_idx returns only TARGET role buses.""" + for idx in bc.target_idx(): + assert bc.df.loc[idx, "Role"] == "TARGET" + + @pytest.mark.order(7740) + def test_local_only_idx(self, bc): + """local_only buses are regulating with no remote/droop role.""" + for idx in bc.local_only_idx(): + assert bc.df.loc[idx, "Role"] == "" + assert bc.df.loc[idx, "Reg"] == True + + @pytest.mark.order(7750) + def test_constrained_dataframe(self, bc): + """constrained() DataFrame accessor matches constrained_idx().""" + c_df = bc.constrained() + assert isinstance(c_df, pd.DataFrame) + assert sorted(c_df.index.tolist()) == sorted(bc.constrained_idx()) From 8595658a8717329ca0a2e17362d89846c63e4e21 Mon Sep 17 00:00:00 2001 From: Wyatt Lowery Date: Fri, 5 Jun 2026 02:23:21 -0500 Subject: [PATCH 3/5] Fix Substation fields --- esapp/components/generate_components.py | 261 +++++++++++-------- esapp/components/grid.py | 322 ++++++++++++++++++++++++ tests/conftest.py | 12 +- tests/test_generate_components.py | 67 +++++ tests/test_grid_components.py | 16 ++ 5 files changed, 575 insertions(+), 103 deletions(-) create mode 100644 tests/test_generate_components.py diff --git a/esapp/components/generate_components.py b/esapp/components/generate_components.py index 9d949de..d0e394e 100644 --- a/esapp/components/generate_components.py +++ b/esapp/components/generate_components.py @@ -7,7 +7,7 @@ from collections import OrderedDict, defaultdict from dataclasses import dataclass, field from enum import Flag, auto -from typing import Optional, List, Dict, Set +from typing import Iterator, Optional, List, Dict, Set class FieldRole(Flag): @@ -99,15 +99,26 @@ class ComponentGenerator: # Manual field definitions for fields not properly defined in PWRaw # Format: {ObjectType: [FieldDefinition, ...]} MANUAL_FIELDS = { - 'Substation': [ + 'PlantController_REPCA1': [ FieldDefinition( - variable_name='GICUsedSubGroundOhms', - python_name='GICUsedSubGroundOhms', - concise_name='RgroundUsed', + variable_name='Dbd:3', + python_name='Dbd__3', + concise_name='dbdupper', data_type='Real', - description='Substation grounding ohms actually used in the geomagnetic induced current calculations.', - role=FieldRole.STANDARD, - enterable=False + description='Deadband upper in control', + role=FieldRole.STANDARD_FIELD, + enterable=True + ), + ], + 'PlantController_REPCTA1': [ + FieldDefinition( + variable_name='Dbd:3', + python_name='Dbd__3', + concise_name='dbdupper', + data_type='Real', + description='Deadband upper in control', + role=FieldRole.STANDARD_FIELD, + enterable=True ), ], } @@ -140,45 +151,23 @@ def _parse_components(self) -> None: """Parses object definitions for components.py.""" current_obj: Optional[ObjectTypeDefinition] = None - with open(self.raw_file_path, 'r', encoding='utf-8') as f: - next(f, None) # Skip header + for line in self._iter_raw_rows(): + parts = line.split('\t') - for line in f: - line = line.rstrip('\n') - if not line.strip(): + if self._is_object_header(parts): + obj_name = parts[0].strip() + + if obj_name in self.EXCLUDE_OBJECTS: + current_obj = None continue - parts = line.split('\t') - - if not line.startswith('\t'): - obj_name = parts[0].strip() - - if not obj_name or len(obj_name) <= 1 or obj_name in self.EXCLUDE_OBJECTS: - current_obj = None - continue - - subdata = self._get_column(parts, 1).lower() == 'yes' - current_obj = ObjectTypeDefinition(name=obj_name, subdata_allowed=subdata) - self.objects[obj_name] = current_obj - - elif current_obj is not None: - var_name = self._get_column(parts, 3) - if not var_name or var_name in self.EXCLUDE_FIELDS or '/' in var_name: - continue - - key_str = self._get_column(parts, 2) - enterable = self._parse_enterable(self._get_column(parts, 8)) - - field_def = FieldDefinition( - variable_name=var_name, - python_name=self._sanitize_for_python(var_name), - concise_name=self._get_column(parts, 4), - data_type=self._get_column(parts, 5), - description=self._get_column(parts, 6, strip_q=True), - role=self._parse_key_symbol(key_str), - enterable=enterable, - available_list=self._get_column(parts, 7, strip_q=True) - ) + subdata = self._get_column(parts, 1).lower() == 'yes' + current_obj = ObjectTypeDefinition(name=obj_name, subdata_allowed=subdata) + self.objects[obj_name] = current_obj + + elif current_obj is not None and self._is_field_row(line): + field_def = self._parse_field_definition(parts) + if field_def is not None: current_obj.fields.append(field_def) def _extract_ts_fields(self) -> None: @@ -186,65 +175,60 @@ def _extract_ts_fields(self) -> None: # Track seen python attributes per object type to avoid duplicates (e.g. from indexed fields) seen_attrs: Dict[str, Set[str]] = defaultdict(set) - with open(self.raw_file_path, 'r', encoding='utf-8') as f: - next(f, None) + for line in self._iter_raw_rows(): + if not self._is_field_row(line): + continue - for line in f: - line = line.rstrip('\n') - if not line.strip() or not line.startswith('\t'): - continue + parts = line.split('\t') + var_name = self._get_column(parts, 3) + if not var_name: + continue - parts = line.split('\t') - var_name = self._get_column(parts, 3) - if not var_name: - continue + matched_prefix = None + matched_type = None + for prefix, obj_type in self.TS_OBJECT_MAPPING.items(): + if var_name.startswith(prefix): + matched_prefix = prefix + matched_type = obj_type + break - # Identify Object Type - matched_type = None - for prefix, obj_type in self.TS_OBJECT_MAPPING.items(): - if var_name.startswith(prefix): - matched_type = obj_type - break - - if not matched_type: - continue + if not matched_prefix or not matched_type: + continue - if 'TSSave' in var_name or 'TSResult' in var_name: - continue + if 'TSSave' in var_name or 'TSResult' in var_name: + continue - # Handle Indexed Fields (e.g. TSBusInput:1 -> TSBusInput) - # We strip the index to create a base field. - # The generated TSField class will support indexing. - base_var_name = re.sub(r':\d+$', '', var_name) - - # Determine Python Attribute Name - # Remove prefix (e.g. TSBus) - prefix_len = len([p for p in self.TS_OBJECT_MAPPING.keys() if base_var_name.startswith(p)][0]) - python_attr = base_var_name[prefix_len:] - python_attr = self._sanitize_for_python(python_attr) - - if not python_attr: - continue + # Handle Indexed Fields (e.g. TSBusInput:1 -> TSBusInput) + # We strip the index to create a base field. + # The generated TSField class will support indexing. + base_var_name = re.sub(r':\d+$', '', var_name) - if python_attr in seen_attrs[matched_type]: - continue - - seen_attrs[matched_type].add(python_attr) + # Determine Python Attribute Name + # Remove prefix (e.g. TSBus) + python_attr = self._sanitize_for_python(base_var_name[len(matched_prefix):]) - concise_name = self._get_column(parts, 4) - description = self._get_column(parts, 6, strip_q=True) + if not python_attr: + continue - field_def = TSFieldDefinition( - pw_field_name=base_var_name, - concise_name=concise_name, - description=description, - python_attr=python_attr, - object_type=matched_type - ) + if python_attr in seen_attrs[matched_type]: + continue - if matched_type not in self.ts_fields: - self.ts_fields[matched_type] = [] - self.ts_fields[matched_type].append(field_def) + seen_attrs[matched_type].add(python_attr) + + concise_name = self._get_column(parts, 4) + description = self._get_column(parts, 6, strip_q=True) + + field_def = TSFieldDefinition( + pw_field_name=base_var_name, + concise_name=concise_name, + description=description, + python_attr=python_attr, + object_type=matched_type + ) + + if matched_type not in self.ts_fields: + self.ts_fields[matched_type] = [] + self.ts_fields[matched_type].append(field_def) def generate_components(self, output_path: str) -> None: """Writes grid.py to the components module.""" @@ -263,14 +247,10 @@ def generate_components(self, output_path: str) -> None: cls_name = self._sanitize_for_python(obj_name.split(" ")[0]) f.write(f'\n\nclass {cls_name}(GObject):') - # Inject manual fields for this object type - if obj_name in self.MANUAL_FIELDS: - for manual_field in self.MANUAL_FIELDS[obj_name]: - obj_def.fields.append(manual_field) - - obj_def.fields.sort(key=self._get_sort_key) + fields = self._fields_with_manual_fields(obj_name, obj_def.fields) + fields.sort(key=self._get_sort_key) - for field_def in obj_def.fields: + for field_def in fields: dtype = self.DTYPE_MAP.get(field_def.data_type, "str") pw_name = self._fix_pw_string(field_def.python_name) flags = self._build_field_priority_flags(field_def) @@ -354,6 +334,82 @@ class TS: # --- Helpers --- + def _iter_raw_rows(self) -> Iterator[str]: + """Yields logical PWRaw rows, joining wrapped quoted field rows.""" + with open(self.raw_file_path, 'r', encoding='utf-8') as f: + next(f, None) # Skip header + yield from self._join_continuation_lines(f) + + @classmethod + def _join_continuation_lines(cls, lines) -> Iterator[str]: + current = None + for raw_line in lines: + line = raw_line.rstrip('\n') + if not line.strip(): + continue + + if current is None: + current = line + continue + + if cls._starts_logical_row(line): + yield current + current = line + else: + current = f"{current}\n{line}" + + if current is not None: + yield current + + @classmethod + def _starts_logical_row(cls, line: str) -> bool: + return cls._is_field_row(line) or cls._is_object_header(line.split('\t')) + + @staticmethod + def _is_field_row(line: str) -> bool: + return line.startswith('\t') + + @classmethod + def _is_object_header(cls, parts: list) -> bool: + obj_name = cls._get_column(parts, 0) + if not obj_name or len(obj_name) <= 1: + return False + + subdata_value = cls._get_column(parts, 1).lower() + maintainer_value = cls._get_column(parts, 9).lower() + return subdata_value in {'yes', 'no'} or maintainer_value in {'yes', 'no', 'inheritalways'} + + def _parse_field_definition(self, parts: list) -> Optional[FieldDefinition]: + var_name = self._get_column(parts, 3) + if not var_name or var_name in self.EXCLUDE_FIELDS or '/' in var_name: + return None + + key_str = self._get_column(parts, 2) + return FieldDefinition( + variable_name=var_name, + python_name=self._sanitize_for_python(var_name), + concise_name=self._get_column(parts, 4), + data_type=self._get_column(parts, 5), + description=self._get_column(parts, 6, strip_q=True), + role=self._parse_key_symbol(key_str), + enterable=self._parse_enterable(self._get_column(parts, 8)), + available_list=self._get_column(parts, 7, strip_q=True) + ) + + def _fields_with_manual_fields(self, obj_name: str, fields: List[FieldDefinition]) -> List[FieldDefinition]: + merged = list(fields) + seen_pw_names = {field.variable_name for field in merged} + seen_python_names = {field.python_name for field in merged} + + for manual_field in self.MANUAL_FIELDS.get(obj_name, []): + if manual_field.variable_name in seen_pw_names or manual_field.python_name in seen_python_names: + continue + merged.append(manual_field) + seen_pw_names.add(manual_field.variable_name) + seen_python_names.add(manual_field.python_name) + + return merged + @staticmethod def _get_column(parts: list, index: int, strip_q: bool = False) -> str: value = parts[index].strip() if index < len(parts) else "" @@ -382,6 +438,7 @@ def _fix_pw_string(name: str) -> str: @staticmethod def _sanitize_description(desc: str) -> str: desc = desc.replace("\\", "/") + desc = desc.replace("\r\n", "\\n").replace("\n", "\\n") desc = desc.replace('"""', r'\"\"\"') desc = desc.replace('"', r'\"') return desc @@ -447,4 +504,4 @@ def _build_field_priority_flags(field_def: FieldDefinition) -> str: print(f"Successfully generated -> grid.py\n") generator.generate_ts_fields(TS_OUTPUT_PATH) - print(f"Successfully generated -> ts_fields.py\n") \ No newline at end of file + print(f"Successfully generated -> ts_fields.py\n") diff --git a/esapp/components/grid.py b/esapp/components/grid.py index 29e599c..c22e82e 100644 --- a/esapp/components/grid.py +++ b/esapp/components/grid.py @@ -121143,6 +121143,8 @@ class PlantController_REPCA1(GObject): """It is possible for the terminal bus to belong to a different zone than the device belongs. This is the Zone number of the Generator""" ZoneNum__1 = ("ZoneNum:1", int, FieldPriority.OPTIONAL) """It is possible for the terminal bus to belong to a different zone than the device belongs. This is the Zone number of the bus""" + Dbd__3 = ("Dbd:3", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Deadband upper in control""" ObjectString = 'PlantController_REPCA1' @@ -121679,6 +121681,8 @@ class PlantController_REPCTA1(GObject): """It is possible for the terminal bus to belong to a different zone than the device belongs. This is the Zone number of the Generator""" ZoneNum__1 = ("ZoneNum:1", int, FieldPriority.OPTIONAL) """It is possible for the terminal bus to belong to a different zone than the device belongs. This is the Zone number of the bus""" + Dbd__3 = ("Dbd:3", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Deadband upper in control""" ObjectString = 'PlantController_REPCTA1' @@ -154695,6 +154699,10 @@ class Subnet(GObject): class Substation(GObject): + SubNum = ("SubNum", int, FieldPriority.PRIMARY) + """Substation Num""" + SubName = ("SubName", str, FieldPriority.SECONDARY | FieldPriority.EDITABLE) + """Substation Name""" ThreeWXFNum = ("3WXFNum", int, FieldPriority.OPTIONAL) """Number of three-winding transformers that connect to the group""" AggrMVAOverload = ("AggrMVAOverload", float, FieldPriority.OPTIONAL) @@ -155223,8 +155231,322 @@ class Substation(GObject): """Custom earth resistivity region hotspot scaling for the substation; set to -1 to use the default region value""" GICGeoMagGraphicScalar = ("GICGeoMagGraphicScalar", float, FieldPriority.OPTIONAL) """Product of the geomagnetic latitude and earth resistivity region scalars for the substation's location""" + GICGLatScalar = ("GICGLatScalar", float, FieldPriority.OPTIONAL) + """Geomagnetic latitude scalar for the substation buses""" + GICQLosses = ("GICQLosses", float, FieldPriority.OPTIONAL) + """Total GIC induced Mvar losses for the substation's transformers""" + GICSubDCGroundVolt = ("GICSubDCGroundVolt", float, FieldPriority.OPTIONAL) + """Geomagnetically induced DC voltage [in Volts] on the ground point underneath the subsation""" + GICSubDCNeutralVolt = ("GICSubDCNeutralVolt", float, FieldPriority.OPTIONAL) + """Geomagnetically induced DC neutral voltage [in Volts] at the substation""" + GICSubGroundOhms = ("GICSubGroundOhms", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Resistance [in Ohms] between the substation neutral and ground.""" + GICSubRDP = ("GICSubRDP", float, FieldPriority.OPTIONAL) + """GIC driving point resistance (in ohms)""" + GICSubRGFlag = ("GICSubRGFlag", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Description of method used to get the grounding resistance (such as assumed)""" + GICSubRTH = ("GICSubRTH", float, FieldPriority.OPTIONAL) + """GIC Thevenin resistance (in Ohms) looking into network""" + GICSubScriptR = ("GICSubScriptR", float, FieldPriority.OPTIONAL) + """GIC Thevenin Ratio""" + GICSubVTH = ("GICSubVTH", float, FieldPriority.OPTIONAL) + """GIC Thevenin voltage (volts)""" GICUsedSubGroundOhms = ("GICUsedSubGroundOhms", float, FieldPriority.OPTIONAL) """Substation grounding ohms actually used in the geomagnetic induced current calculations.""" + GICXFIEffective1Max = ("GICXFIEffective1Max", float, FieldPriority.OPTIONAL) + """Maximum Ieffective considering all transformers with buses in the group; in amps per phase""" + GICXFNeutralAmps3Max = ("GICXFNeutralAmps3Max", float, FieldPriority.OPTIONAL) + """Maximum neutral current considering all tranformers with buses in the group; in amps total """ + HarmonicsFloat = ("HarmonicsFloat", float, FieldPriority.OPTIONAL) + """THDv Max for Group""" + HarmonicsString = ("HarmonicsString", str, FieldPriority.OPTIONAL) + """THDv Valid for Group""" + InjGrpNum = ("InjGrpNum", int, FieldPriority.OPTIONAL) + """Number of injection groups that have participation points that belong to the group""" + IslandNumber = ("IslandNumber", int, FieldPriority.OPTIONAL) + """Number of viable islands that are contained in the group""" + Label = ("Label", str, FieldPriority.OPTIONAL) + """This field is when reading from an auxiliary file (or pasting from a spreadsheet). The label field will automatically be used as the identifying field for the object. When shown in the user interface, the field will always shown the primary label""" + LabelAppend = ("LabelAppend", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """A write-only field which appends a new label to the current list""" + LabelAppend__1 = ("LabelAppend:1", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Used to set or change the primary label for an object""" + LabelAppend__2 = ("LabelAppend:2", int, FieldPriority.OPTIONAL) + """A read-only field showing the number of labels assigned to the object""" + LabelAppend__3 = ("LabelAppend:3", str, FieldPriority.OPTIONAL) + """A read-only field showing a comma-delimited list of all labels except for the primary label""" + Latitude = ("Latitude", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Geographic Latitude in decimal degrees. Note: negative values represent the southern hemisphere""" + LatitudeString = ("LatitudeString", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Geographic Latitude using a string of the form DDD:MM:SS followed by a N for north or S for south""" + LineMVR = ("LineMVR", float, FieldPriority.OPTIONAL) + """The amount of Mvar flow going through (sum of the tie-line flows into the area with the positive generation, load, bus shunt, and switched shunt injections)""" + LineMW = ("LineMW", float, FieldPriority.OPTIONAL) + """The amount of MW flow going through (sum of the tie-line flows into the area with the positive generation, load, bus shunt, and switched shunt injections)""" + LineShuntNum = ("LineShuntNum", int, FieldPriority.OPTIONAL) + """Number of line shunts that are in the group""" + LoadNetMvar = ("LoadNetMvar", float, FieldPriority.OPTIONAL) + """Load Net Mvar. Equal to the Load Mvar - Distributed Gen Mvar.""" + LoadNetMW = ("LoadNetMW", float, FieldPriority.OPTIONAL) + """Load Net MW. Equal to the Load MW - Distributed Gen MW.""" + Longitude = ("Longitude", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Geographic Longitude in decimal degrees. Note: negative values represent the western hemisphere""" + LongitudeString = ("LongitudeString", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Geographic Longitude using a string of the form DDD:MM:SS followed by a E for east or W for west""" + MinKVOfHighestNomkV = ("MinKVOfHighestNomkV", float, FieldPriority.OPTIONAL) + """Returns the lowest kV voltage at a bus which is one of the highest nominal kV buses in the substation""" + MinPUOfHighestNomkV = ("MinPUOfHighestNomkV", float, FieldPriority.OPTIONAL) + """Returns the lowest per unit voltage at a bus which is one of the highest nominal kV buses in the substation""" + MSLineNum = ("MSLineNum", int, FieldPriority.OPTIONAL) + """Number of multi-section lines that connect to the group""" + MTDCNum = ("MTDCNum", int, FieldPriority.OPTIONAL) + """Number of multi-terminal DC line networks that connect to the group""" + MWDistance = ("MWDistance", float, FieldPriority.OPTIONAL) + """MW*Distance""" + NERCCIP14AggWeight = ("NERCCIP14AggWeight", int, FieldPriority.OPTIONAL) + """A substation with agg. weight > 3000 meets the critieria in NERC CIP-14-01. Each tielines' weight is determined from their Nominal kV [= 500, = 300, = 200], which maps to their respective weight values [3100, 1300, 700].""" + NumberOfConnections = ("NumberOfConnections", int, FieldPriority.OPTIONAL) + """Sum of the Number of connections field over all buses""" + ObjectGroup = ("ObjectGroup", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """ObjectGroup names assigned by user input as a comma-delimited list of ObjectGroup names""" + ObjectGroup__1 = ("ObjectGroup:1", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """To append to more ObjectGroup, enter a comma-delimited list of ObjectGroup names to append""" + ObjectID = ("ObjectID", str, FieldPriority.OPTIONAL) + """When writing out this field uses option key fields to use in SUBDATA Section to identify the object either by primary, secondary, or label identifier. When reading from an AUX file we will attempt to use any of these identifiers to identify the object""" + ObjectMemo = ("ObjectMemo", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """One custom memo may also be specified for each object. This respresents multiple lines of text and can be seen on many dialogs""" + ODObjectString = ("ODObjectString", str, FieldPriority.OPTIONAL) + """Id for the OpenDSS object""" + ODObjectString__1 = ("ODObjectString:1", str, FieldPriority.OPTIONAL) + """If yes then the object is included in the GICHarm analysis; this is set on an area basis, with options for some neighboring buses to be included""" + OpenDSSFloat = ("OpenDSSFloat", float, FieldPriority.OPTIONAL) + """Latitude""" + OpenDSSFloat__1 = ("OpenDSSFloat:1", float, FieldPriority.OPTIONAL) + """Longitude""" + ReferenceDistance = ("ReferenceDistance", float, FieldPriority.OPTIONAL) + """Distance to the case reference point in miles (blank if locations not defined)""" + ReferenceDistance__1 = ("ReferenceDistance:1", float, FieldPriority.OPTIONAL) + """Distance to the case reference point in km (blank if locations not defined)""" + RegionInteger = ("RegionInteger", int, FieldPriority.OPTIONAL) + """Count of the geographic regions that contain the object""" + RegionString = ("RegionString", str, FieldPriority.OPTIONAL) + """Comma separated list of all the full names of the geographic regions that contain the object""" + RegionString__1 = ("RegionString:1", str, FieldPriority.OPTIONAL) + """Comma separated list of all the class names of the geographic regions that contain the object""" + RegionString__2 = ("RegionString:2", str, FieldPriority.OPTIONAL) + """Comma separated list of all the first proper names of the geographic regions that contain the object""" + RegionString__3 = ("RegionString:3", str, FieldPriority.OPTIONAL) + """Comma separated list of all the second proper names of the geographic regions that contain the object""" + SAName = ("SAName", str, FieldPriority.OPTIONAL) + """Name of the super area to which the substation's more common area belongs""" + Selected = ("Selected", str, FieldPriority.OPTIONAL) + """YES or NO field which displays if record has been selected. The field can be used in combination with numerous script commands in auxiliary files. Is also used to help choose records in the user interface sometimes""" + sgBGNDeadBus = ("sgBGNDeadBus", int, FieldPriority.OPTIONAL) + """Number of dead buses in the group""" + SolarValue = ("SolarValue", float, FieldPriority.OPTIONAL) + """Gives the sun's elevation about the horizon, with 90 degrees straight overhead""" + SolarValue__1 = ("SolarValue:1", float, FieldPriority.OPTIONAL) + """Gives the sun's azimuth using the compass, with 0 due north, 90 degrees due east, 180 due south and 270 due west.""" + SolarValue__2 = ("SolarValue:2", float, FieldPriority.OPTIONAL) + """Gives an estimate of the atmospheric transmittance with 1 for the sun directly overhead, decreasing as it approaches the horizon; zero if below the horizon""" + SSMaxMVR = ("SSMaxMVR", float, FieldPriority.OPTIONAL) + """Sum of the switched shunt Mvar maximum""" + SSMaxMVR__1 = ("SSMaxMVR:1", float, FieldPriority.OPTIONAL) + """Sum of the switched shunt Mvar maximum ignoring the status field""" + SSMinMVR = ("SSMinMVR", float, FieldPriority.OPTIONAL) + """Sum of the switched shunt Mvar minimum""" + SSMinMVR__1 = ("SSMinMVR:1", float, FieldPriority.OPTIONAL) + """Sum of the switched shunt Mvar minimum ignoring the status field""" + SSMVRPercent = ("SSMVRPercent", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Percent""" + SSMVRPercent__1 = ("SSMVRPercent:1", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Percent Ignoring Status""" + SSMVRRange = ("SSMVRRange", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range""" + SSMVRRange__1 = ("SSMVRRange:1", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range Ignoring Status""" + SSMVRRangeDown = ("SSMVRRangeDown", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range Down""" + SSMVRRangeDown__1 = ("SSMVRRangeDown:1", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range Down Ignoring Status""" + SSMVRRangeUp = ("SSMVRRangeUp", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range Up""" + SSMVRRangeUp__1 = ("SSMVRRangeUp:1", float, FieldPriority.OPTIONAL) + """Switched Shunt Mvar Range Up Ignoring Status""" + SSNum = ("SSNum", int, FieldPriority.OPTIONAL) + """Number of switched shunts that belong to the group""" + SubAutoInserted = ("SubAutoInserted", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """When performing GIC calculations, Simulator may auto-create some substations definitions. This flag will be set to YES by default for these auto-created substations.""" + SubEstimated = ("SubEstimated", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """If yes then the substation's latitude and longitude have been estimated and may need to be corrected""" + SubID = ("SubID", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Substation ID. An extra string identifier. Normally use the Name field""" + SubNumberOf = ("SubNumberOf", int, FieldPriority.OPTIONAL) + """Number of substations with buses that overlap the group""" + TieLineNumber = ("TieLineNumber", int, FieldPriority.OPTIONAL) + """Number of Tie Lines""" + TimeDomainSelected = ("TimeDomainSelected", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Selected for storing in the time domain""" + TopologyType = ("TopologyType", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Specify the node-breaker topology configuration of the substation. """ + TSH = ("TSH", float, FieldPriority.OPTIONAL) + """Shows the sum of generators' active machine model's inertia on the system MVA base.""" + TSSaveAll = ("TSSaveAll", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save All""" + TSSaveSubAvgFreqHz = ("TSSaveSubAvgFreqHz", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Average Frequency (Hz)""" + TSSaveSubAvgPUVolt = ("TSSaveSubAvgPUVolt", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Average Voltage (pu)""" + TSSaveSubGenAccP = ("TSSaveSubGenAccP", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Gen Acceleration MW Sum Substation""" + TSSaveSubGenP = ("TSSaveSubGenP", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Gen MW Sum Substation""" + TSSaveSubGenPMech = ("TSSaveSubGenPMech", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Generator Mech Input Sum Substation""" + TSSaveSubGenQ = ("TSSaveSubGenQ", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Gen Mvar Sum Substation""" + TSSaveSubGICEFieldDeg = ("TSSaveSubGICEFieldDeg", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save GIC Efield Directions (degrees)""" + TSSaveSubGICEFieldMag = ("TSSaveSubGICEFieldMag", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save GIC Efield Magnitude""" + TSSaveSubGICIAmp = ("TSSaveSubGICIAmp", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Total GIC (amps)""" + TSSaveSubGICQ = ("TSSaveSubGICQ", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Total GIC Mvar Losses""" + TSSaveSubLoadP = ("TSSaveSubLoadP", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Load MW Sum Substation""" + TSSaveSubLoadQ = ("TSSaveSubLoadQ", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Load Mvar Sum Substation""" + TSSaveSubMaxPUVolt = ("TSSaveSubMaxPUVolt", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Maximum Voltage (pu)""" + TSSaveSubMinPUOfHighestNomkV = ("TSSaveSubMinPUOfHighestNomkV", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Lowest Voltage (pu) of Highest Nominal Voltage Buses""" + TSSaveSubMinPUVolt = ("TSSaveSubMinPUVolt", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save Minimum Voltage (pu)""" + TSSaveSubROCOFHz = ("TSSaveSubROCOFHz", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Save DSC::TSTimePointResult_TSSubROCOFHz""" + TSSubAvgFreqHz = ("TSSubAvgFreqHz", float, FieldPriority.OPTIONAL) + """Average Frequency (Hz)""" + TSSubAvgPUVolt = ("TSSubAvgPUVolt", float, FieldPriority.OPTIONAL) + """Average Voltage (pu)""" + TSSubGenAccP = ("TSSubGenAccP", float, FieldPriority.OPTIONAL) + """Gen Acceleration MW Sum Substation""" + TSSubGenP = ("TSSubGenP", float, FieldPriority.OPTIONAL) + """Gen MW Sum Substation""" + TSSubGenPMech = ("TSSubGenPMech", float, FieldPriority.OPTIONAL) + """Generator Mech Input Sum Substation""" + TSSubGenQ = ("TSSubGenQ", float, FieldPriority.OPTIONAL) + """Gen Mvar Sum Substation""" + TSSubGICEFieldDeg = ("TSSubGICEFieldDeg", float, FieldPriority.OPTIONAL) + """GIC Efield Directions (degrees)""" + TSSubGICEFieldMag = ("TSSubGICEFieldMag", float, FieldPriority.OPTIONAL) + """GIC Efield Magnitude""" + TSSubGICIAmp = ("TSSubGICIAmp", float, FieldPriority.OPTIONAL) + """Total GIC (amps)""" + TSSubGICQ = ("TSSubGICQ", float, FieldPriority.OPTIONAL) + """Total GIC Mvar Losses""" + TSSubLoadP = ("TSSubLoadP", float, FieldPriority.OPTIONAL) + """Load MW Sum Substation""" + TSSubLoadQ = ("TSSubLoadQ", float, FieldPriority.OPTIONAL) + """Load Mvar Sum Substation""" + TSSubMaxPUVolt = ("TSSubMaxPUVolt", float, FieldPriority.OPTIONAL) + """Maximum Voltage (pu)""" + TSSubMinPUOfHighestNomkV = ("TSSubMinPUOfHighestNomkV", float, FieldPriority.OPTIONAL) + """Lowest Voltage (pu) of Highest Nominal Voltage Buses""" + TSSubMinPUVolt = ("TSSubMinPUVolt", float, FieldPriority.OPTIONAL) + """Minimum Voltage (pu)""" + TSSubROCOFHz = ("TSSubROCOFHz", float, FieldPriority.OPTIONAL) + """DSC::TSTimePointResult_TSSubROCOFHz""" + UTMEasting = ("UTMEasting", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """UTM Easting Coordinates""" + UTMLongitudeZone = ("UTMLongitudeZone", int, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """UTM Longitude Zone""" + UTMMGRS = ("UTMMGRS", str, FieldPriority.OPTIONAL) + """Geographic UTM/MGRS""" + UTMNorthing = ("UTMNorthing", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """UTM Northing Coordinates""" + UTMNorthSouth = ("UTMNorthSouth", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """UTM North-South Hemisphere""" + WeatherMeas = ("WeatherMeas", str, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station assigned by user input""" + WeatherStationDistance = ("WeatherStationDistance", float, FieldPriority.OPTIONAL) + """Distance to the closest weather station in miles""" + WeatherStationDistance__1 = ("WeatherStationDistance:1", float, FieldPriority.OPTIONAL) + """Distance to the closest weather station in km""" + WeatherStationName = ("WeatherStationName", str, FieldPriority.OPTIONAL) + """Name of the closest weather station""" + WeatherValue = ("WeatherValue", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station TempF : Temperature in Fahrenheit""" + WeatherValue__1 = ("WeatherValue:1", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station TempC : Temperature in Celsius""" + WeatherValue__2 = ("WeatherValue:2", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station DewPointF : Dew Point in Fahrenheit""" + WeatherValue__3 = ("WeatherValue:3", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station DewPointC : Dew Point in Celsius""" + WeatherValue__4 = ("WeatherValue:4", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station CloudCoverPerc : Cloud cover percentage (0 is clear, 100 totally overcast)""" + WeatherValue__5 = ("WeatherValue:5", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeedmph : Wind speed in miles per hour""" + WeatherValue__6 = ("WeatherValue:6", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindDirection : Wind direction in degrees (0=North, 90=East, etc)""" + WeatherValue__7 = ("WeatherValue:7", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeedKnots : Wind speed in knots""" + WeatherValue__8 = ("WeatherValue:8", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeedMsec : Wind speed in meters per second""" + WeatherValue__9 = ("WeatherValue:9", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeedkmph : Wind speed in km per hour""" + WeatherValue__10 = ("WeatherValue:10", float, FieldPriority.OPTIONAL) + """Weather Station InsolationPerc : Insolation percent (100 for sun directly overhead)""" + WeatherValue__11 = ("WeatherValue:11", float, FieldPriority.OPTIONAL) + """Weather Station Humidity : Relative humdity""" + WeatherValue__12 = ("WeatherValue:12", float, FieldPriority.OPTIONAL) + """Weather Station HeatIndexF : Heat index in Fahrenheit""" + WeatherValue__13 = ("WeatherValue:13", float, FieldPriority.OPTIONAL) + """Weather Station HeatIndexC : Heat index in Celsius""" + WeatherValue__14 = ("WeatherValue:14", float, FieldPriority.OPTIONAL) + """Weather Station WindChillF : Wind chill in Fahrenheit""" + WeatherValue__15 = ("WeatherValue:15", float, FieldPriority.OPTIONAL) + """Weather Station WindChillC : Wind chill in Celsius""" + WeatherValue__16 = ("WeatherValue:16", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeed100mph : Wind Speed at 100 m in miles per hour""" + WeatherValue__17 = ("WeatherValue:17", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeed100ms : Wind Speed at 100 m in meters per second""" + WeatherValue__18 = ("WeatherValue:18", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeed100knots : Wind Speed at 100 m in knots""" + WeatherValue__19 = ("WeatherValue:19", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindSpeed100kmph : Wind Speed at 100 m in km per hour""" + WeatherValue__20 = ("WeatherValue:20", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station GlobalHorzIrradWM2 : Global Horizontal Irradiance in watts per square meter""" + WeatherValue__21 = ("WeatherValue:21", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station DirectHorzIrradWM2 : Direct Horizontal Irradiance in watts per square meter""" + WeatherValue__22 = ("WeatherValue:22", float, FieldPriority.OPTIONAL) + """Weather Station DirectNormIrradWM2 : Direct Normal Irradiance in watts per square meter""" + WeatherValue__23 = ("WeatherValue:23", float, FieldPriority.OPTIONAL) + """Weather Station DiffuseHorzIrradWM2 : Diffuse Horizontal Irradiance in watts per square meter""" + WeatherValue__24 = ("WeatherValue:24", float, FieldPriority.OPTIONAL) + """Weather Station WindTerrFrictCoeff : Wind terrain friction coefficient""" + WeatherValue__25 = ("WeatherValue:25", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindGustmph : Wind Gust (mph)""" + WeatherValue__26 = ("WeatherValue:26", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindGustms : Wind Gust (m/sec)""" + WeatherValue__27 = ("WeatherValue:27", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station WindGustKnots : Wind Gust (knots)""" + WeatherValue__28 = ("WeatherValue:28", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station SmokeVertIntMgM2 : Smoke verically integrated (mg/m^2)""" + WeatherValue__29 = ("WeatherValue:29", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station PrecipRateMMHr : Precipitation Rate (mm/hr)""" + WeatherValue__30 = ("WeatherValue:30", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE) + """Weather Station PrecipPercFrozen : Precipitation Percent Frozen""" + WeatherValueString = ("WeatherValueString", str, FieldPriority.OPTIONAL) + """Weather Station Enabled : When NO, all values on this record will be ignored and appear as blank.""" + WeatherValueString__2 = ("WeatherValueString:2", str, FieldPriority.OPTIONAL) + """Weather Station ObservationTime : Observation time (UTC) in ISO8601 format. A blank entry indicates the time is not valid.""" + ZoneName = ("ZoneName", str, FieldPriority.OPTIONAL) + """Zone Name""" + ZoneNum = ("ZoneNum", int, FieldPriority.OPTIONAL) + """Zone Number""" + ZoneNumberOf = ("ZoneNumberOf", int, FieldPriority.OPTIONAL) + """Number of zones with buses that overlap the group""" ObjectString = 'Substation' diff --git a/tests/conftest.py b/tests/conftest.py index 0b796a6..b997aa0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -464,8 +464,18 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True) -def _capture_pw_log(request, saw_session): +def _capture_pw_log(request): """Clear the PW log before each test; on failure, dump it to stdout.""" + should_capture = ( + "saw_session" in request.fixturenames + or request.node.get_closest_marker("integration") is not None + ) + if not should_capture: + yield + return + + saw_session = request.getfixturevalue("saw_session") + try: saw_session.LogClear() except Exception: diff --git a/tests/test_generate_components.py b/tests/test_generate_components.py new file mode 100644 index 0000000..c7af4e4 --- /dev/null +++ b/tests/test_generate_components.py @@ -0,0 +1,67 @@ +"""Offline regression tests for the PWRaw component generator.""" +import re +from pathlib import Path + +import pytest + +from esapp.components.generate_components import ComponentGenerator, FieldRole + + +RAW_FILE = Path(__file__).resolve().parents[1] / "esapp" / "components" / "PWRaw" + + +@pytest.fixture(scope="module") +def parsed_generator(): + generator = ComponentGenerator(str(RAW_FILE)) + generator.parse() + return generator + + +def _class_block(text: str, class_name: str) -> str: + match = re.search( + rf"\n\nclass {re.escape(class_name)}\(GObject\):.*?(?=\n\nclass |\Z)", + text, + flags=re.DOTALL, + ) + assert match is not None, f"{class_name} was not generated" + return match.group(0) + + +def test_substation_parsing_continues_after_wrapped_pwraw_row(parsed_generator): + fields = {field.variable_name: field for field in parsed_generator.objects["Substation"].fields} + + assert "GICGeoMagGraphicScalar" in fields + for field_name in [ + "GICGLatScalar", + "GICQLosses", + "GICSubGroundOhms", + "GICUsedSubGroundOhms", + "Latitude", + "Longitude", + "SubName", + "SubNum", + ]: + assert field_name in fields + + assert fields["SubNum"].role & FieldRole.COMPOSITE_KEY_1 + assert fields["SubName"].role & FieldRole.ALTERNATE_KEY + + +def test_manual_fields_do_not_duplicate_existing_pw_names(): + generator = ComponentGenerator("unused") + manual_field = ComponentGenerator.MANUAL_FIELDS["PlantController_REPCA1"][0] + + fields = generator._fields_with_manual_fields("PlantController_REPCA1", [manual_field]) + + assert [field.variable_name for field in fields].count("Dbd:3") == 1 + + +def test_hidden_deadband_fields_are_generated(parsed_generator, tmp_path): + output_path = tmp_path / "grid.py" + + parsed_generator.generate_components(str(output_path)) + generated = output_path.read_text(encoding="utf-8") + + for class_name in ["PlantController_REPCA1", "PlantController_REPCTA1"]: + block = _class_block(generated, class_name) + assert 'Dbd__3 = ("Dbd:3", float, FieldPriority.OPTIONAL | FieldPriority.EDITABLE)' in block diff --git a/tests/test_grid_components.py b/tests/test_grid_components.py index fe22289..af4e7d8 100644 --- a/tests/test_grid_components.py +++ b/tests/test_grid_components.py @@ -70,6 +70,22 @@ def test_gobject_is_settable(test_gobject_class): assert test_gobject_class.is_settable('nonexistent') is False +def test_generated_substation_metadata_contains_expected_fields(): + """Substation should include fields after the wrapped PWRaw row.""" + for field_name in [ + "SubNum", + "SubName", + "Latitude", + "Longitude", + "GICSubGroundOhms", + "GICUsedSubGroundOhms", + ]: + assert field_name in grid.Substation.fields() + + assert "SubNum" in grid.Substation.keys() + assert "SubName" in grid.Substation.secondary() + + @pytest.mark.parametrize("g_object_class", get_all_gobject_subclasses()) def test_real_gobject_subclass_is_well_formed(g_object_class: Type[grid.GObject]): """Validates every auto-generated GObject subclass has correct structure.""" From 29dd3a8dafb7be965bb85c98e65ad1d1f0af520d Mon Sep 17 00:00:00 2001 From: Wyatt Lowery Date: Fri, 5 Jun 2026 02:31:49 -0500 Subject: [PATCH 4/5] BusCat bug fix --- esapp/utils/buscat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esapp/utils/buscat.py b/esapp/utils/buscat.py index a1fe8e0..90f4f52 100644 --- a/esapp/utils/buscat.py +++ b/esapp/utils/buscat.py @@ -99,7 +99,7 @@ def parse_buscat(cat: str) -> dict: eff = BusType.PQ if (limited and typ == BusType.PV) else typ active = typ in (BusType.PV, BusType.SLACK) and not limited ctrl_str = "+".join( - f.name for f in BusCtrl if f in ctrl and f.name + f.name for f in BusCtrl if f is not BusCtrl.NONE and f in ctrl ) or "NONE" return { From 395b3abcdad023d57cc4938fad5a2362f91b2208 Mon Sep 17 00:00:00 2001 From: Wyatt Lowery Date: Fri, 5 Jun 2026 02:46:14 -0500 Subject: [PATCH 5/5] v0.1.4 Release --- CHANGELOG.md | 11 ++++++++++- VERSION | 2 +- examples/mesh.py | 27 +++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adf11e1..c18f45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -[0.1.4] - Unreleased +[0.1.4] - 2026-06-05 -------------------- **Added** @@ -6,6 +6,15 @@ - `BusType`, `BusCtrl`, `Role` enums for type-safe bus classification - API documentation for BusCat, embedded modules, and new enums +**Changed** +- Moved plotting and geospatial dependencies out of core install requirements and into optional extras + +**Fixed** +- Fixed PWRaw component generation so wrapped field rows no longer truncate generated objects +- Restored complete generated `Substation` metadata, including key, location, and GIC fields +- Added generated metadata for hidden `Dbd:3` fields on `PlantController_REPCA1` and `PlantController_REPCTA1` +- Fixed `BusCat` control flag formatting so `NONE` is not combined with active control flags + [0.1.3] - 2026-02-03 -------------------- diff --git a/VERSION b/VERSION index 7693c96..845639e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 \ No newline at end of file +0.1.4 diff --git a/examples/mesh.py b/examples/mesh.py index f294cc1..ffaf8ac 100644 --- a/examples/mesh.py +++ b/examples/mesh.py @@ -800,3 +800,30 @@ def normlap( else: return Di @ L @ Di + +def hermitify(A: NDArray | sp.spmatrix) -> NDArray: + """ + Convert a complex symmetric matrix to Hermitian form. + + For a complex symmetric matrix (A = A^T), this function produces + a Hermitian matrix by pairing the upper triangle's conjugate with + the lower triangle. + + Parameters + ---------- + A : np.ndarray or scipy.sparse matrix + Input complex symmetric matrix. + + Returns + ------- + np.ndarray + The Hermitian form of the matrix. + + Notes + ----- + Useful for converting admittance matrices to a form suitable + for eigenvalue algorithms that require Hermitian input. + """ + dense = A if isinstance(A, np.ndarray) else A.toarray() + return (np.triu(dense).conjugate() + np.tril(dense)) / 2 +