diff --git a/docs/_toc.yml b/docs/_toc.yml index ad0f76a7..4b433724 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -53,3 +53,6 @@ parts: - file: examples/06_wind_and_hydrogen - file: examples/07_open_cycle_gas_turbine - file: examples/09_multiunit_thermal_plant + - caption: Post-Processing + chapters: + - file: herc_analysis diff --git a/docs/adding_components.md b/docs/adding_components.md index 6cf8b53d..4a93ba0d 100644 --- a/docs/adding_components.md +++ b/docs/adding_components.md @@ -83,7 +83,7 @@ class MyComponent(ComponentBase): | Requirement | Description | |---|---| | `component_category` | Must be `"generator"`, `"storage"`, or `"load"`. Generators contribute to `locally_generated_power`. | -| `super().__init__()` | Sets `self.component_name`, `self.component_type`, `self.dt`, `self.starttime`, and configures logging. | +| `super().__init__()` | Sets `self.component_name`, `self.component_type`, `self.component_group` (defaulting `component_group` in `h_dict` to `component_name` when omitted), `self.dt`, `self.starttime`, and configures logging. | | `power` output | All components must write a `power` value to `h_dict[self.component_name]["power"]` in the `step` method. | | Return `h_dict` | Both `get_initial_conditions_and_meta_data` and `step` must return the modified `h_dict`. | diff --git a/docs/component_types.md b/docs/component_types.md index cc67ad7d..1bba49c9 100644 --- a/docs/component_types.md +++ b/docs/component_types.md @@ -1,8 +1,8 @@ # Component Names, Types, and Categories -Three related but distinct concepts govern how plant components are identified in Hercules: `component_name`, `component_type`, and `component_category`. Understanding the distinction is important for writing YAML input files and for programmatically working with `h_dict`. +Related concepts govern how plant components are identified in Hercules: `component_name`, `component_type`, `component_category`, and optionally `component_group`. Understanding the distinction is important for writing YAML input files and for programmatically working with `h_dict`. -## The Three Concepts +## The concepts ### `component_name` @@ -33,6 +33,14 @@ The **component category** is a class-level attribute defined in each component Every `ComponentBase` subclass **must** define `component_category`; a `TypeError` is raised at class-definition time if it is missing. +### `component_group` + +The **component group** is an optional string users may set in YAML to label several distinct components as one logical unit for post-processing (for example, summing their powers before reporting). + +- **Source**: optional `component_group:` field in the component's YAML block +- **Default**: If omitted, Hercules sets `component_group` equal to `component_name` (the YAML key) +- **Used by**: Not used inside Hercules physics or control; the resolved value is stored on each component as `self.component_group` and written into `h_dict[component_name]["component_group"]` (including in HDF5 metadata). Companion tools such as [herc_analysis](herc_analysis.md) use it to aggregate outputs across instances that share the same group label. + ### Summary | Concept | Set by | Example value | Used for | @@ -40,6 +48,7 @@ Every `ComponentBase` subclass **must** define `component_category`; a `TypeErro | `component_name` | User (YAML key) | `"battery_unit_1"` | Accessing `h_dict[name]`; unique instance ID | | `component_type` | User (`component_type:` field) | `"BatterySimple"` | Registry lookup to select the Python class | | `component_category` | Developer (class variable) | `"storage"` | Generator classification; sign convention | +| `component_group` | User (`component_group:` field, optional) | `"stage_a_storage"` | Post-processing grouping (defaults to `component_name`) | --- diff --git a/docs/h_dict.md b/docs/h_dict.md index b6e82402..89487563 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -30,7 +30,7 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `controller` | dict | Controller configuration | - | | **Hybrid Plant Components** | -Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is auto-discovered as a plant component. The key is a user-chosen `component_name` (e.g. `wind_farm`, `battery_unit_1`) — it does not need to match the category name. See [Component Names, Types, and Categories](component_types.md) for details. +Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is auto-discovered as a plant component. The key is a user-chosen `component_name` (e.g. `wind_farm`, `battery_unit_1`) — it does not need to match the category name. Each such dict may optionally include `component_group` (string); if absent, Hercules sets it to `component_name` when the component is constructed. See [Component Names, Types, and Categories](component_types.md) for details. ### Wind Farm | `component_type` | str | Must be "WindFarm" or "WindFarmSCADAPower" | diff --git a/docs/herc_analysis.md b/docs/herc_analysis.md new file mode 100644 index 00000000..cf63725b --- /dev/null +++ b/docs/herc_analysis.md @@ -0,0 +1,13 @@ +# herc_analysis (companion post-processing) + +[**herc_analysis**](https://github.com/NatLabRockies/herc_analysis) is a companion Python repository for analyzing Hercules hybrid plant simulations. Install it into the same environment you use for Hercules when you want reusable loaders, plots, and summaries over HDF5 outputs (see the herc_analysis README and docs for features and API). + +## Relationship to Hercules examples + +The **examples** in this Hercules repository often include small, self-contained scripts that demonstrate basic post-processing (e.g. reading output files and plotting in place). **herc_analysis** builds on that idea with pre-built post-processing utilities tailored to Hercules HDF5 layout—use whichever fits your workflow; for full capability lists and usage, refer to herc_analysis documentation. + +## `component_group` + +Users may set an optional string `component_group` in each plant component block of the Hercules YAML input. If it is omitted, Hercules sets `component_group` equal to `component_name` (the YAML section key). The field does not change simulation behavior in Hercules; it is recorded in `h_dict` (including serialized metadata in output files) so tools such as herc_analysis can treat multiple named components as one logical entity—for example, summing their signals before downstream reporting. + +See [Component Names, Types, and Categories](component_types.md) for the full picture of names, types, categories, and groups. diff --git a/docs/hercules_input.md b/docs/hercules_input.md index 982066ed..828a02d6 100644 --- a/docs/hercules_input.md +++ b/docs/hercules_input.md @@ -12,7 +12,7 @@ The input file structure mirrors the `h_dict` structure documented in the [h_dic - **Top level parameters**: `dt`, `starttime_utc`, `endtime_utc` (see [timing](timing.md) for details) - **Plant configuration**: `interconnect_limit` -- **Plant component sections**: any number of user-named sections, each containing a `component_type` key that identifies the component class to use (see [Component Names, Types, and Categories](component_types.md)) +- **Plant component sections**: any number of user-named sections, each containing a `component_type` key that identifies the component class to use (see [Component Names, Types, and Categories](component_types.md)). Optionally include `component_group` (string) to tag instances for post-processing in [herc_analysis](herc_analysis.md); if omitted, it defaults to the section name (`component_name`). - **External data**: `external_data` for external time series data (e.g., LMP prices, weather forecasts) - **Optional settings**: `verbose`, `name`, `description`, `output_file` diff --git a/docs/hybrid_plant.md b/docs/hybrid_plant.md index 531d2ba6..9ece00b3 100644 --- a/docs/hybrid_plant.md +++ b/docs/hybrid_plant.md @@ -6,7 +6,7 @@ The `HybridPlant` class manages all plant components in Hercules. It handles ini `HybridPlant` auto-discovers components from the [h_dict](h_dict.md) at initialization time. Any top-level `h_dict` entry whose value is a dict containing a `component_type` key is treated as a plant component. The YAML key becomes the component's `component_name` (a user-chosen instance identifier), and the `component_type` value determines which Python class is instantiated. -See [Component Names, Types, and Categories](component_types.md) for a full explanation of how `component_name`, `component_type`, and `component_category` relate to each other. +See [Component Names, Types, and Categories](component_types.md) for a full explanation of how `component_name`, `component_type`, `component_category`, and optional `component_group` relate to each other. ## Available Components diff --git a/examples/hercules_input_example.yaml b/examples/hercules_input_example.yaml index 05766179..aed62ed4 100644 --- a/examples/hercules_input_example.yaml +++ b/examples/hercules_input_example.yaml @@ -25,6 +25,8 @@ plant: # Wind farm configuration (comment out if not using wind) wind_farm: component_type: WindFarm # Options: WindFarm, WindFarmSCADAPower + # Optional: tag for post-processing (herc_analysis); defaults to this section key if omitted + # component_group: wind_assets_north wake_method: dynamic # Options: dynamic, precomputed, no_added_wakes floris_input_file: ../inputs/floris_input.yaml # Path to FLORIS farm configuration file wind_input_filename: ../inputs/wind_input.ftr # Path to wind resource data file (CSV, pickle, or feather format) diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 0f18ff7d..0c3340a7 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -17,6 +17,10 @@ class ComponentBase: ``component_name`` (the unique YAML key chosen by the user) is passed into ``__init__`` and may differ from the category when multiple instances of the same type are present. ``component_type`` is always set automatically to the concrete class name. + + Optional YAML field ``component_group`` defaults to ``component_name`` when omitted. + Hercules does not use it for physics or control; it is written back into ``h_dict`` so + companion tools (e.g. herc_analysis) can aggregate multiple instances under one label. """ # Subclasses must override this with one of: "generator", "load", "storage" @@ -51,6 +55,11 @@ def __init__(self, h_dict, component_name): key). For single-instance plants this is typically the category name (e.g. ``"battery"``); for multi-instance plants it may be any user-chosen string (e.g. ``"battery_unit_1"``). + + Note: + Optional ``h_dict[component_name]["component_group"]`` (str) defaults to + ``component_name`` and is stored on ``self.component_group`` and written back to + ``h_dict`` for downstream metadata consumers. """ # Store the component name (unique instance identifier from the YAML key) @@ -59,6 +68,16 @@ def __init__(self, h_dict, component_name): # Derive component_type from the concrete class name — no hardcoding needed self.component_type = type(self).__name__ + # Optional tag for post-processing (defaults to component_name) + component_group = h_dict[component_name].get("component_group", component_name) + if not isinstance(component_group, str): + raise TypeError( + f"component_group for '{component_name}' must be a string, " + f"got {type(component_group).__name__}: {component_group!r}" + ) + self.component_group = component_group + h_dict[component_name]["component_group"] = component_group + # Set up logging # Check if log_file_name is defined in the h_dict[component_name] if "log_file_name" in h_dict[component_name]: diff --git a/tests/component_base_test.py b/tests/component_base_test.py new file mode 100644 index 00000000..2fc7beef --- /dev/null +++ b/tests/component_base_test.py @@ -0,0 +1,58 @@ +"""Tests for ComponentBase behavior shared by all plant components.""" + +import copy + +import pytest +from hercules.plant_components.battery_simple import BatterySimple + +from .test_inputs.h_dict import battery + + +def _battery_h_dict(**battery_overrides): + """Build a minimal h_dict with one BatterySimple component. + + Args: + **battery_overrides: Keys merged into the battery component dict. + + Returns: + dict: Copy suitable for BatterySimple(h_dict, "battery"). + """ + batt = copy.deepcopy(battery) + batt.update(battery_overrides) + return { + "dt": 1.0, + "starttime": 0.0, + "endtime": 10.0, + "verbose": False, + "plant": {"interconnect_limit": 30000.0}, + "battery": batt, + } + + +def test_component_group_defaults_to_component_name(): + """When component_group is omitted, it equals component_name and is written to h_dict.""" + h_dict = _battery_h_dict() + assert "component_group" not in h_dict["battery"] + + battery_obj = BatterySimple(h_dict, "battery") + + assert battery_obj.component_group == "battery" + assert h_dict["battery"]["component_group"] == "battery" + + +def test_component_group_explicit_value(): + """Explicit component_group is stored on the instance and echoed into h_dict.""" + h_dict = _battery_h_dict(component_group="hybrid_unit_a") + + battery_obj = BatterySimple(h_dict, "battery") + + assert battery_obj.component_group == "hybrid_unit_a" + assert h_dict["battery"]["component_group"] == "hybrid_unit_a" + + +def test_component_group_must_be_string(): + """Non-string component_group raises TypeError.""" + h_dict = _battery_h_dict(component_group=42) + + with pytest.raises(TypeError, match="component_group"): + BatterySimple(h_dict, "battery")