From 711c087eb64c9e26b9efd0835fb00940c4f321fe Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:14 -0600 Subject: [PATCH 01/29] Updating top-level docs, config, etc --- CHANGELOG.md | 1 + README.md | 11 +-- docs/CONTRIBUTING.md | 27 ++++--- docs/_config.yml | 17 +++- docs/_toc.yml | 2 +- .../features_not_currently_implemented.md | 11 --- docs/developer_guide/why_make_h2integrate.md | 80 ++++++------------- docs/getting_started/environment_variables.md | 15 ++-- docs/getting_started/install.md | 18 +++-- docs/intro.md | 13 +-- 10 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 docs/developer_guide/features_not_currently_implemented.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d38d2b3..0026f7110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/README.md b/README.md index dc4a76c27..60c483eeb 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,9 @@ H2Integrate is both a **hybrid systems engineering design tool** and a **technoe - **Cost inputs settable by the user** with examples based on the [Annual Technology Baseline (ATB)](https://atb.nlr.gov/) - **Optimization and scenario analysis** to explore design trade-offs across hybrid plant configurations -### Available Technologies +### Model Library -H2I includes models for a broad set of energy generation, conversion, and storage technologies. -This is a non-exhaustive list, and the library of available technologies is actively expanding: - -- **Electricity generation**: solar PV, wind, wave, tidal, natural gas combined cycle (NGCC), natural gas combustion turbines (NGCT), nuclear, grid -- **Hydrogen production**: PEM electrolysis, NG-SMR -- **Energy storage**: Li-ion batteries, long-duration energy storage (LDES), pumped storage hydropower (PSH), hydrogen storage, compressed gas storage -- **Fuel cells**: H2 PEM fuel cells -- **Industrial processes**: ammonia synthesis, iron ore reduction, steel production, and more +See the [Model Overview](./docs/user_guide/model_overview.md) for the current registered models and their categories. The [documentation](https://h2integrate.readthedocs.io/en/latest/intro.html) provides the project overview and links to model-specific guides. ## Getting Started diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 349f941f7..30b3ae111 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,8 +20,9 @@ GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submi * Keep style fixes to a separate commit to make your pull request more readable. * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). -* When you start working on a pull request, start by creating a new branch pointing at the latest - commit on [main](https://github.com/NatLabRockies/H2Integrate). +* Contributions are generally made against the `develop` branch. New models use the + [`@register` decorator](https://github.com/NatLabRockies/H2Integrate/blob/develop/h2integrate/core/supported_models.py); + see [Adding a New Technology](developer_guide/adding_a_new_technology.md) for the model requirements. * Code formatting is enforced using pre-commit hooks and is required for any code pushed up to the repository. The pre-commit package is included in the developer install of the repository. The pre-commit hooks can be installed by running ```bash pre-commit install @@ -76,22 +77,28 @@ jupytext notebook.md --to .ipynb ## Tests -The test suite can be run using `pytest tests/h2integrate`. Individual test files can be run by specifying them: +Run tests from the repository root in an environment with the development dependencies installed. +Every test must use one of the `unit`, `regression`, `integration`, or `hpc` markers. Collection +fails for unmarked tests. Common commands are: ```bash -pytest tests/h2integrate/test_hybrid.py +pytest -m unit +pytest -m regression +pytest -m integration +pytest -m hpc ``` -and individual tests can be run within those files +To run one test file or test function, provide its path and optional node ID: ```bash -pytest tests/h2integrate/test_hybrid.py::test_h2integrate_system +pytest h2integrate/core/test/test_utilities.py +pytest h2integrate/core/test/test_utilities.py::test_function_name ``` -When you push to your fork, or open a PR, your tests will be run against the -[Continuous Integration (CI)](https://github.com/NatLabRockies/H2Integrate/actions) suite. This will start a build -that runs all tests on your branch against multiple Python versions, and will also test -documentation builds. +See the [Testing Guide](developer_guide/testing.md) for test conventions, marker details, and +additional commands. Pull requests run the automated test suite through +[GitHub Actions](https://github.com/NatLabRockies/H2Integrate/actions). The documentation build is +also checked on every pull request by [Read the Docs](https://h2integrate.readthedocs.io/). ## Code Review Process diff --git a/docs/_config.yml b/docs/_config.yml index ba772c071..594721dd7 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,9 +1,9 @@ # Book settings # Learn more at https://jupyterbook.org/customize/config.html title: H2Integrate -author: National Renewable Energy Laboratory +author: NLR # logo: logo.png -copyright: '2025' +copyright: 2026, NLR only_build_toc_files: false # exclude_patterns: [_build, Thumbs.db, .DS_Store, "**.ipynb_checkpoints", .github] # Force re-execution of notebooks on each build. @@ -30,21 +30,30 @@ latex: repository: url: https://github.com/NatLabRockies/H2Integrate path_to_book: docs - branch: main + branch: develop # Add GitHub buttons to your book # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository html: use_issues_button: true use_repository_button: true - baseurl: https://nrel.github.io/H2Integrate/ + baseurl: https://h2integrate.readthedocs.io/en/latest/ sphinx: extra_extensions: - sphinx.ext.autodoc - sphinx.ext.autosummary - sphinx.ext.napoleon + - h2integrate.core.docs_sphinx config: language: python autosummary_generate: true + html_theme_options: + check_switcher: false + navbar_end: + - version-switcher + - navbar-icon-links + switcher: + json_url: https://h2integrate.readthedocs.io/en/latest/_static/switcher.json + version_match: latest autodoc_default_options: members: true member-order: bysource diff --git a/docs/_toc.yml b/docs/_toc.yml index aad60bffb..94562e793 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -125,10 +125,10 @@ parts: - file: developer_guide/class_structure - file: developer_guide/technology_model_types - file: developer_guide/testing - - file: developer_guide/features_not_currently_implemented - caption: Miscellaneous Resources chapters: - file: misc_resources/FAQ + - file: misc_resources/changelog - file: misc_resources/glossary - file: misc_resources/defining_filepaths - file: misc_resources/turbine_models_library_preprocessing diff --git a/docs/developer_guide/features_not_currently_implemented.md b/docs/developer_guide/features_not_currently_implemented.md deleted file mode 100644 index 71d899a1d..000000000 --- a/docs/developer_guide/features_not_currently_implemented.md +++ /dev/null @@ -1,11 +0,0 @@ -# Features not currently implemented - -There are many features that are not currently implemented in H2Integrate that are necessary for current or future projects. -These will become issues on the H2Integrate GitHub repo. -We are listing them here: - -- allowing different technologies within the same plant to be at different sites -- saving results elegantly for plant components so they do not always need to be rerun -- detailing the required inputs and outputs for different model types (e.g. electrolyzer) so that users can more easily add their own subsystems -- splitters and combiners for when resources are used or produced by multiple components -- simulating different timespans than one year at hourly timesteps (8760s) diff --git a/docs/developer_guide/why_make_h2integrate.md b/docs/developer_guide/why_make_h2integrate.md index fbe144930..54dfb9d22 100644 --- a/docs/developer_guide/why_make_h2integrate.md +++ b/docs/developer_guide/why_make_h2integrate.md @@ -1,53 +1,30 @@ -# Welcome to H2Integrate +# H2Integrate Design Rationale -This collection of docs is focused on presenting H2Integrate, acknowledging where we are now and where we want to be in the future. -Most of these docs are written in a way that we'll be able to reuse them immediately as proper docs in the actual H2Integrate repo. -For now, to reduce development overhead and encourage rapid iteration, we'll continue to work in this sandbox repo. +H2Integrate provides a framework for combining technology models into hybrid-system simulations, +technoeconomic analyses, and optimization studies. Its design emphasizes reusable model interfaces +and explicit connections between technologies. -Our goal with these doc pages is to communicate ideas that exist in our heads, put them to paper, and provide diagrams where helpful. -These docs are a work in progress; please ask questions about any and all parts presented here. +## Why modularity? -## Why make H2Integrate? - -H2Integrate has already proven itself useful as a tool that can analyze and design complex hybrid systems producing electricity, hydrogen, steel, and more. -However, these developments came in waves across multiple disparate projects, leading to a sometimes disjointed codebase. -In an effort to streamline and modularize H2Integrate to make it more effective and capable for necessary future studies, we are devoting time to redesigning it from the ground up. -This page discusses some of the high-level decisions and mindsets that we've used throughout this process. - -## What does it mean to "modularize" H2Integrate? - -Most of the current implementation of H2Integrate assumes that you are modeling a certain hybrid system architecture using a limited number of specific models. -As more projects call for using H2Integrate, we must make it easier to develop and add capability to the tool in a sustainable and clear way. -By making the internal framework of H2Integrate more agnostic to the technologies considered in the hybrid system design, we can allow for more user-defined modularized subsystems to be considered effectively. - -Getting into the details, this means creating generalized components that H2Integrate can expect to behave in a certain way. - -We introduce the ideas of "converters", which convert one resource into another. -Simple examples of converters include electrolyzers, wind turbines, solar PV panels, and more. -These converters can pass resources to other converters or "storage" components via "transporter" components. - -Transporters include hydrogen pipelines, electricity cables, or anything that transports a resource. -In a broad sense, these might include delivery trucks or shipping vessels, though those are more for future consideration. - -Storage components include batteries, hydrogen tanks; anything where you store a resource. - -By combining instances of these different generalized components, we can study distinct hybrid systems in H2Integrate. -Internally, the H2Integrate framework just needs to know if a something is a converter, transporter, storage, or some other type of component. -Additionally, this allows users to develop their own components using the expected interface, and add in custom subsystems to their hybrid plant. +Hybrid systems combine components with different physical behavior, cost assumptions, and operating +constraints. H2Integrate separates these concerns into model roles such as performance, cost, +finance, and control, and connects technologies through defined commodities and transport +components. Users can combine the registered models or add components that follow the framework's +interfaces. ## Why use OpenMDAO as the internal framework for H2Integrate? -Through a series of internal discussions, the H2Integrate dev team landed on using [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) for this new version of the tool. +H2Integrate uses [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) to assemble and +run its models. -Using OpenMDAO for this gives quite a few benefits: +OpenMDAO provides: - a proven framework for complex data-passing within multidisciplinary systems - automatically-generated visualization tools to help understand models and debug them - internal units handling and conversion - built-in nonlinear solvers to resolve model coupling - built-in optimization and parameter sweep drivers - multiple existing NLR tools use OpenMDAO, including [WISDEM](https://github.com/NLRWindSystems/WISDEM/) and [WEIS](https://github.com/NLRWindSystems/WEIS), so we can draw from institutional knowledge -- set up with gradient-based optimization in mind, which is not currently a focus for H2Integrate but this positions the tool well for potential future additions -- parallelization done using MPI, which is also not currently a focus but useful for the future +- gradient-based optimization and MPI-based parallelization capabilities for applicable studies However, there are a few downsides to using OpenMDAO: - an additional layer of code that developers must consider @@ -55,24 +32,15 @@ However, there are a few downsides to using OpenMDAO: - potentially increased computational costs depending on problem type and size - it isn't great at optimizing mixed-integer problems -The benefits outweighed the downsides, hence the team's choice to use OpenMDAO going forward. -Additionally, we can code H2Integrate in a way to minimize some of the potential issues, given that we're aware of them before refactoring H2Integrate. - -## How are generation technologies handled today? - -H2Integrate now models generation technologies directly with native technology components -for wind, solar, storage, wave, and tidal systems. -This keeps technology behavior, dispatch interfaces, and cost modeling within a single -framework, with all components exposed consistently to the same configuration and -optimization infrastructure. - -## Where should code I develop and implement live? +These benefits support H2Integrate's component-level modeling and optimization goals. Contributors +should still consider OpenMDAO's additional abstraction layer, longer error traces, possible +computational overhead, and limitations with mixed-integer optimization. -Historically, H2Integrate has been a sort of hybrid itself, where it contains both the tool itself as well as project-specific code. -This has led to a somewhat disjointed codebase that is difficult to maintain and understand. +## Where should code live? -To address this, here are guidelines for where code you develop should live: -- If the code is specific to a project, it should live in the project's repository. -- If the code is generalizable, could be used in multiple projects, or is a useful addition to the H2Integrate tool, it should live in the H2Integrate repository. -- Wrappers to models that are used in H2Integrate should live in the H2Integrate repository. -- The actual models (physics, costs, financials) should live **outside** the H2Integrate repository. Complex models might live in their own repo (e.g. BERT), whereas other models might live in the project's repository. +- Reusable technology models, cost and finance models, and integrations that benefit multiple + projects belong in the H2Integrate repository. +- Project-specific or proprietary models belong in the project repository. They can be integrated + by implementing the interfaces expected by H2Integrate. +- For the model interfaces and contribution workflow, see + [Adding a New Technology](adding_a_new_technology.md). diff --git a/docs/getting_started/environment_variables.md b/docs/getting_started/environment_variables.md index 21f94609d..e2559cfd4 100644 --- a/docs/getting_started/environment_variables.md +++ b/docs/getting_started/environment_variables.md @@ -93,11 +93,16 @@ unset NLR_API_EMAIL (set-environment-variables-with-env-file)= ## Set Environment Variables with .env file -The ".env" file will be looked for in all of the following locations: - - H2Integrate root directory (`/path/to/H2Integrate/h2integrate/`) - - parent of H2Integrate root directory (`/path/to/H2Integrate/`) (preferred location to store your environment file) - - current working directory (this is not a preferred location to store your environment file) -1. Choose which of the above directories you want to host your .env file, and create a file named ".env" in that folder. +H2Integrate searches for a `.env` file in these locations, in order: + +1. The H2Integrate package directory (for a source checkout, `/path/to/H2Integrate/h2integrate/`). +2. The package directory's parent (for a source checkout, `/path/to/H2Integrate/`). +3. The current working directory. +4. The user's home directory. + +For a source checkout, the repository root is a convenient location. For a pip installation, the +package directory's parent is usually inside `site-packages`; use the home directory or current +working directory instead. Create a file named `.env` in the directory you choose. 2. Open the ".env" file and add the environment variables: ```bash NLR_API_KEY='api-key-value' diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md index f78b34e84..cfec53dcc 100644 --- a/docs/getting_started/install.md +++ b/docs/getting_started/install.md @@ -8,6 +8,14 @@ If you just want to use H2Integrate and aren't developing new models, you should pip install h2integrate ``` +Pyomo is installed with H2Integrate, but solver software is installed separately. If you use a +Pyomo dispatch or optimization model, install at least one supported solver, such as GLPK, CBC, or +HiGHS. These are available from conda-forge: + +```bash +conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy +``` + ## Installing from Source If you want to develop new models or contribute to H2Integrate, you can install it from source. @@ -59,10 +67,10 @@ If you want to develop new models or contribute to H2Integrate, you can install 4. Create a conda environment and install H2Integrate and all its dependencies. Please read the following two notes about modified installation steps. - 1. If on a Unix machine, uncomment line 8 in `environment.yml` to install Cbc. Windows - users will need to manually install from . - 2. If you plan to use Ard, please uncomment line 9 in `environment.yml` to ensure WISDEM (an Ard - dependency) is installed from conda to avoid installation issues with some systems. + 1. The environment specification already installs GLPK, CBC, and HiGHS from conda-forge; no + solver lines need to be uncommented. + 2. If you plan to use Ard, uncomment the `wisdem` dependency in `environment.yml` so it is + installed from conda and avoids installation issues on some systems. ```bash conda env create -f environment.yml @@ -111,7 +119,7 @@ pip install -e ".[all]" 4. Install H2Integrate and its dependencies: ```bash - conda install -y -c conda-forge glpk coin-or-cbc>=2.10.12 highspy + conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy ``` - If you want to just use H2Integrate: diff --git a/docs/intro.md b/docs/intro.md index 0f84b4d33..2d1000c32 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -9,15 +9,18 @@ H2Integrate (H2I) is an open-source Python package for hybrid systems engineering design and technoeconomic analysis. It models and optimizes hybrid energy plants that produce electricity, hydrogen, ammonia, steel, and other products, using high-resolution location-specific resource data to perform optimization and scenario analysis. +## About H2Integrate + +H2Integrate grew from projects that needed to model and analyze complex hybrid energy systems. It is now maintained as a reusable framework for composing technology performance, cost, finance, and control models, while still allowing users to add project-specific components. + Browse the example workflows in the GitHub repository: https://github.com/NatLabRockies/H2Integrate/tree/main/examples ## What is H2Integrate? -H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for various hybrid systems. -The tool currently includes renewable energy generation (land-based wind, offshore wind, solar PV, wave, tidal), conventional generation (natural gas combined cycle, combustion turbines, grid electricity), hydrogen production (PEM electrolysis, NG-SMR), energy storage (Li-ion batteries, long-duration energy storage, pumped storage hydropower), fuel cells, and industrial processes (ammonia synthesis, iron ore reduction, steel production, methanol, and more). -Other elements can also be included as developed by users. -H2Integrate is continually expanding to serve additional hybrid applications -- if you're interested in seeing what's being actively developed, please see the [current pull requests in the GitHub repository](https://github.com/NatLabRockies/H2Integrate/pulls). -Some modeling capabilities in H2Integrate are provided by integrating existing tools, such as [PySAM](https://github.com/NatLabRockies/pysam), [ORBIT](https://github.com/NLRWindSystems/ORBIT), and [ProFAST](https://github.com/NatLabRockies/ProFAST). +H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for hybrid systems. +Its registered models cover land-based wind and solar PV; wave, tidal, run-of-river hydro, natural gas, nuclear, and grid electricity; hydrogen production and storage; fuel cells; and industrial processes including ammonia, methanol, iron, and steel. +The [Model Overview](user_guide/model_overview.md) lists the current registered models, and model-specific pages describe their capabilities and configuration. +Some models integrate existing tools, including [PySAM](https://github.com/NatLabRockies/pysam) and [ProFAST](https://github.com/NatLabRockies/ProFAST). The H2Integrate tool is built on top of [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/), which provides a powerful and flexible environment for modeling and optimization. ```{note} From 1db4f64d35d39057d56fd756366c6de374d344c0 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:46 -0600 Subject: [PATCH 02/29] Added sphinx docs file --- h2integrate/core/docs_sphinx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 h2integrate/core/docs_sphinx.py diff --git a/h2integrate/core/docs_sphinx.py b/h2integrate/core/docs_sphinx.py new file mode 100644 index 000000000..a9b070ede --- /dev/null +++ b/h2integrate/core/docs_sphinx.py @@ -0,0 +1,23 @@ +import os +from importlib.metadata import version + +from sphinx.config import Config +from sphinx.application import Sphinx + + +def configure_version_switcher(app: Sphinx, config: Config) -> None: + readthedocs_version = os.environ.get("READTHEDOCS_VERSION", "latest") + if readthedocs_version == "develop": + readthedocs_version = "latest" + + theme_options = dict(config.html_theme_options or {}) + switcher_options = dict(theme_options.get("switcher", {})) + switcher_options["version_match"] = readthedocs_version + theme_options["switcher"] = switcher_options + theme_options["announcement"] = f"H2Integrate {version('h2integrate')} documentation" + config.html_theme_options = theme_options + + +def setup(app: Sphinx) -> dict[str, object]: + app.connect("config-inited", configure_version_switcher) + return {"version": "1.0", "parallel_read_safe": True} From a562226cd280d3a1980a7a4110c10f617c30a75f Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 12:20:51 -0600 Subject: [PATCH 03/29] Adding a bevy of new doc pages --- CHANGELOG.md | 1 + docs/_templates/autosummary/module.rst | 7 +- docs/_toc.yml | 9 ++ docs/api.md | 2 + .../pyomo_controllers.md | 26 +++- docs/misc_resources/changelog.md | 8 + docs/resource/solar_index.md | 4 + docs/resource/wind_index.md | 4 + docs/storage/storage_models_index.md | 29 ++++ docs/technology_models/ammonia.md | 18 +++ docs/technology_models/desalination.md | 21 +++ docs/technology_models/floris_wind_plant.md | 27 ++++ docs/technology_models/geologic_hydrogen.md | 5 + docs/technology_models/h2_htse.md | 3 + docs/technology_models/iron_transport.md | 31 ++++ docs/technology_models/methanol.md | 41 +++++ docs/technology_models/nitrogen_asu.md | 25 +++ docs/technology_models/nuclear.md | 5 + docs/technology_models/pem_electrolyzer.md | 55 +++++++ docs/technology_models/steel_eaf.md | 17 ++ docs/technology_models/tidal.md | 5 + docs/technology_models/transport.md | 123 +++++++++++++++ docs/user_guide/connecting_technologies.md | 145 +----------------- .../defining_sites_and_resources.md | 4 + docs/user_guide/driver_config_reference.md | 47 ++++++ docs/user_guide/plant_config_reference.md | 110 +++++++++++++ docs/user_guide/postprocessing_results.md | 82 ++++++++++ .../user_guide/technology_config_reference.md | 39 +++++ h2integrate/postprocess/mapping.py | 9 +- h2integrate/preprocess/populate_tech_yaml.py | 9 +- 30 files changed, 757 insertions(+), 154 deletions(-) create mode 100644 docs/misc_resources/changelog.md create mode 100644 docs/technology_models/desalination.md create mode 100644 docs/technology_models/floris_wind_plant.md create mode 100644 docs/technology_models/iron_transport.md create mode 100644 docs/technology_models/nitrogen_asu.md create mode 100644 docs/technology_models/pem_electrolyzer.md create mode 100644 docs/technology_models/transport.md create mode 100644 docs/user_guide/driver_config_reference.md create mode 100644 docs/user_guide/plant_config_reference.md create mode 100644 docs/user_guide/technology_config_reference.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0026f7110..290a1520c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added user references for PEM electrolysis, transport, FLORIS, configuration files, and additional registered models, with API and example links. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) - Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst index 53840f6d2..7e93ba723 100644 --- a/docs/_templates/autosummary/module.rst +++ b/docs/_templates/autosummary/module.rst @@ -47,11 +47,8 @@ {% endblock %} {% block modules %} -{# Filter out pytest conftest modules; they cannot be imported outside a - pytest session (they use `from test.conftest import ...` which requires - the repo-root ``test/`` package to be on ``sys.path``) and so autosummary - raises "failed to import" warnings on them. #} -{% set filtered_modules = modules | reject('equalto', 'conftest') | list %} +{# Exclude pytest modules from the generated public API tree. #} +{% set filtered_modules = modules | reject('equalto', 'conftest') | reject('equalto', 'test') | list %} {% if filtered_modules %} .. rubric:: Modules diff --git a/docs/_toc.yml b/docs/_toc.yml index 94562e793..37980b3d3 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -13,6 +13,9 @@ parts: - file: user_guide/model_overview - file: user_guide/model_registry - file: user_guide/how_to_set_up_an_analysis + - file: user_guide/plant_config_reference + - file: user_guide/driver_config_reference + - file: user_guide/technology_config_reference - file: user_guide/connecting_technologies - file: user_guide/defining_sites_and_resources - file: user_guide/populate_tech_yaml @@ -26,6 +29,7 @@ parts: - file: user_guide/plotting_geospatial_data_with_geopandas - caption: Technology Models chapters: + - file: technology_models/transport - file: technology_models/feedstocks - file: technology_models/marine_hydrokinetic - file: technology_models/run_of_river @@ -34,6 +38,8 @@ parts: - file: technology_models/natural_gas - file: technology_models/nuclear - file: technology_models/wombat_electrolyzer_om + - file: technology_models/pem_electrolyzer + - file: technology_models/floris_wind_plant - file: technology_models/pvwattsv8_solar_pv.md - file: technology_models/windpower_wind_plant.md - file: technology_models/atb_costs_pv.md @@ -41,6 +47,8 @@ parts: - file: technology_models/co2.md - file: technology_models/methanol.md - file: technology_models/ammonia.md + - file: technology_models/nitrogen_asu + - file: technology_models/desalination - file: technology_models/paper_mill.md - file: technology_models/saf.md - file: technology_models/ammonia_synloop_dynamics @@ -52,6 +60,7 @@ parts: - file: technology_models/h2_htse.md - file: technology_models/iron_ewin.md - file: technology_models/iron_mine.md + - file: technology_models/iron_transport - file: technology_models/iron_dri.md - file: technology_models/steel_eaf.md - file: technology_models/steel_eaf_cmu.md diff --git a/docs/api.md b/docs/api.md index 5a8f46847..9a566c10b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,5 +16,7 @@ storage transporters tools + preprocess + postprocess demand reliability diff --git a/docs/control/technology_level_control/pyomo_controllers.md b/docs/control/technology_level_control/pyomo_controllers.md index 68876420e..0c0f56bfa 100644 --- a/docs/control/technology_level_control/pyomo_controllers.md +++ b/docs/control/technology_level_control/pyomo_controllers.md @@ -77,7 +77,11 @@ The optimized dispatch method is specified by setting the storage control to `O The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. The Pyomo plant model is from the perspective of the storage technology and is meant to track inflows of commodities and other parameters that might impact the dispatch of the storage from upstream technologies. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization -```{note} Only the PySAM battery performance model can call Pyomo dispatch at this time. +```{note} +Optimized dispatch passes a `pyomo_dispatch_solver` callable to a storage performance model. +`StoragePerformanceBase` provides this interface, so optimized dispatch is not inherently limited +to the PySAM battery. Confirm that the selected storage model and controller support the requested +dispatch mode. ``` ![](./figures/Pyomo_dispatch_figure.png) @@ -93,7 +97,25 @@ We have exposed the optimization cost (weighting) values to the user in this imp ``` For an example of how to use the optimized Pyomo control framework with the `OptimizedDispatchStorageController`, see -- `examples/30_pyomo_optimized_dispatch` +- [Example 30: Optimized Pyomo Dispatch](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/30_pyomo_optimized_dispatch) + +### Minimum operating-cost rules + +The optimized controller creates `PyomoDispatchGenericConverterMinOperatingCosts` blocks for +upstream converter technologies and a `PyomoRuleStorageMinOperatingCosts` block for the dispatching +storage technology. These registered Pyomo rule classes define the per-technology variables and +constraints used by the controller; the optimized controller constructs them internally rather than +requiring them to be added as performance models. + +```{eval-rst} +.. autoclass:: h2integrate.control.control_rules.converters.generic_converter_min_operating_cost.PyomoDispatchGenericConverterMinOperatingCosts + :members: initialize_parameters + :no-index: + +.. autoclass:: h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost.PyomoRuleStorageMinOperatingCosts + :members: initialize_parameters + :no-index: +``` This controller only allows one incoming electricity stream and does not apply optimal dispatch of that stream back through the upstream technologies (no feedback). The dispatch can handle more than one generation technology, but the incoming electricity must be combined using an H2I combiner before going to the storage component, and the `cost_per_production`, which is defined in the storage technology section, needs to include the cost of production for all production technologies. This could be done using the following: diff --git a/docs/misc_resources/changelog.md b/docs/misc_resources/changelog.md new file mode 100644 index 000000000..40918aa10 --- /dev/null +++ b/docs/misc_resources/changelog.md @@ -0,0 +1,8 @@ +# Changelog + +The release and development history is maintained in the repository's +[CHANGELOG.md](https://github.com/NatLabRockies/H2Integrate/blob/develop/CHANGELOG.md). + +```{include} ../../CHANGELOG.md +:start-line: 2 +``` diff --git a/docs/resource/solar_index.md b/docs/resource/solar_index.md index 7d6d42be4..8bdfaa862 100644 --- a/docs/resource/solar_index.md +++ b/docs/resource/solar_index.md @@ -16,6 +16,10 @@ - "MeteosatPrimeMeridianTMYSolarAPI" - [OpenMeteo Historical API](#solar_resource:openmeteo_historical) +Some API resource models support multi-year simulations when the requested resource data covers +the configured time range. See the [Resource Model Index](resource_index.md) for details on +multi-year resource handling. + ```{note} Please refer to the [Setting Environment Variables](../getting_started/environment_variables) doc page for information on setting up an NLR API key if you haven't yet. ``` diff --git a/docs/resource/wind_index.md b/docs/resource/wind_index.md index 3f835285e..2e528571c 100644 --- a/docs/resource/wind_index.md +++ b/docs/resource/wind_index.md @@ -6,6 +6,10 @@ - "HRRRMETToolkitWindAPI" - [**"OpenMeteoHistoricalWindResource"**](wind_resource:openmeteo_archive) +Some API resource models support multi-year simulations when the requested resource data covers +the configured time range. See the [Resource Model Index](resource_index.md) for details on +multi-year resource handling. + ```{note} Please refer to the [Setting Environment Variables](../getting_started/environment_variables) doc page for information on setting up an NLR API key if you haven't yet. diff --git a/docs/storage/storage_models_index.md b/docs/storage/storage_models_index.md index 47baa086c..32958493d 100644 --- a/docs/storage/storage_models_index.md +++ b/docs/storage/storage_models_index.md @@ -11,6 +11,21 @@ H2I currently supports the following storage performance models: The following sections detail the inputs and outputs of the storage performance models. +### Automatic storage sizing + +`StorageAutoSizingModel` calculates the storage capacity and charge/discharge rate required to +balance a commodity production profile against demand, then simulates the storage operation. Set +`set_demand_as_avg_commodity_in: true` to use average input as demand, or set it to `false` and +provide `demand_profile`. The model requires either `round_trip_efficiency`, or both +`charge_efficiency` and `discharge_efficiency`. `min_soc_fraction` and `max_soc_fraction` bound the +usable state of charge. It supports one-hour time steps. + +```{eval-rst} +.. autoclass:: h2integrate.storage.simple_storage_auto_sizing.StorageSizingModelConfig + :members: + :no-index: +``` + ```{note} The inputs and outputs of storage performance models are generalized here for any commodity. If input and output names include the word `commodity`, the actual variable name would be the commodity defined for that storage model. For example, the `PySAMBatteryPerformanceModel` can only be used for the commodity `electricity`. Therefore, the `commodity_in` input to the `PySAMBatteryPerformanceModel` is actually named `electricity_in`. ``` @@ -61,3 +76,17 @@ The available storage cost models are: - `SaltCavernStorageCostModel` - `PipeStorageCostModel` - `MCHTOLStorageCostModel` + + ### ATB battery cost model + + `ATBBatteryCostModel` uses separate energy and power capital costs from the NLR Annual Technology + Baseline battery workbook. It derives storage duration from capacity and charge rate, calculates + CapEx from the energy and power cost inputs, and calculates annual OpEx as a fraction of CapEx. + `commodity_rate_units` must be a power unit based on watts; the default amount unit is the rate unit + multiplied by hours. + + ```{eval-rst} + .. autoclass:: h2integrate.storage.battery.atb_battery_cost.ATBBatteryCostConfig + :members: + :no-index: + ``` diff --git a/docs/technology_models/ammonia.md b/docs/technology_models/ammonia.md index 3ea56dfa1..2e3ee64d2 100644 --- a/docs/technology_models/ammonia.md +++ b/docs/technology_models/ammonia.md @@ -6,6 +6,24 @@ Ammonia is a common fertilizer, and also has the potential to serve as a lower-c 2. The synloop ammonia model accepts stream-table measurements (or modeled values) from an ammonia synthesis loop as performance parameters. Its registered models are `AmmoniaSynLoopPerformanceModel` in `h2integrate/converters/ammonia/ammonia_synloop_performance.py` and `AmmoniaSynLoopCostModel` in `h2integrate/converters/ammonia/ammonia_synloop_cost.py`. The example in `examples/12_ammonia_synloop/` uses mostly the same parameters as those used by Reznicek et al., but with updated capital costs for the air separator and synthesis loop derived from an NETL baseline study of ammonia production by [Brasington et al.](https://doi.org/10.2172/1515254). +## Simple ammonia configuration + +`SimpleAmmoniaPerformanceModel` uses an annual plant capacity and capacity factor to produce +ammonia. It accepts a hydrogen feed profile and reports ammonia production and capacity factor. +`SimpleAmmoniaCostModel` estimates capital and operating cost and includes oxygen by-product +credits. Both models use one-hour time steps and are demonstrated in +[Example 02: Texas Ammonia](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/02_texas_ammonia). + +```{eval-rst} +.. autoclass:: h2integrate.converters.ammonia.simple_ammonia_model.SimpleAmmoniaPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.ammonia.simple_ammonia_model.SimpleAmmoniaCostModelConfig + :members: + :no-index: +``` + ## Synloop purge gas output The Synloop Ammonia Model exposes the purge gas exiting the synthesis loop as a **multivariable stream** called `process_gas_mixture`. This is a general-purpose stream type for process gas mixtures that can be reused by other components. It bundles seven constituent variables into a single connection type: diff --git a/docs/technology_models/desalination.md b/docs/technology_models/desalination.md new file mode 100644 index 000000000..825b7d6b1 --- /dev/null +++ b/docs/technology_models/desalination.md @@ -0,0 +1,21 @@ +# Reverse Osmosis Desalination + +`ReverseOsmosisPerformanceModel` represents a fixed-capacity reverse osmosis plant. Configure its +freshwater capacity in kg/h and choose `salinity: seawater` or `salinity: brackish`. The model +reports freshwater as `water_out` in m^3/h, along with `feedwater` and electricity demand. Its +current simplified assumptions use 50% recovery and 4 kWh/m^3 for seawater, and 75% recovery and +1.5 kWh/m^3 for brackish water. + +`ReverseOsmosisCostModel` estimates capital and operating costs from the freshwater capacity using +the model's reference costs. Its cost configuration is fixed to 2013 USD. Both models require +hourly time steps. + +```{eval-rst} +.. autoclass:: h2integrate.converters.water.desal.desalination.ReverseOsmosisPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.water.desal.desalination.ReverseOsmosisCostModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md new file mode 100644 index 000000000..875f65acd --- /dev/null +++ b/docs/technology_models/floris_wind_plant.md @@ -0,0 +1,27 @@ +# FLORIS Wind Plant + +`FlorisWindPlantPerformanceModel` wraps the FLORIS wake model to calculate wind-farm electrical +output from wind resource data and turbine configuration. It supports a single turbine design per +farm and uses one-hour time steps. + +The model requires a FLORIS wake configuration and a turbine configuration formatted for FLORIS. +The configurations can be included from YAML files as in [Example 26](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/26_floris). +The `layout` configuration selects a layout generator and its options; do not also set turbine +coordinates in `floris_wake_config.farm.layout_x` or `layout_y`, because the layout settings create +those coordinates. Multiple turbine designs in the same farm are not currently supported. + +Resource data is supplied through the plant's site resource configuration and +`site_to_tech_connections`. The model selects or averages the resource heights around the turbine +hub height using `resource_data_averaging_method` (`weighted_average`, `average`, or `nearest`). +Turbulence intensity can be taken from the resource data or supplied as a default. + +FLORIS calculations can be expensive, so caching is enabled by default. `cache_dir` selects the +cache location when caching is enabled. Cache entries are based on the model configuration and +simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse +is not desired. + +```{eval-rst} +.. autoclass:: h2integrate.converters.wind.floris.FlorisWindPlantPerformanceModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/geologic_hydrogen.md b/docs/technology_models/geologic_hydrogen.md index cbf02e677..bc22fd5e7 100644 --- a/docs/technology_models/geologic_hydrogen.md +++ b/docs/technology_models/geologic_hydrogen.md @@ -64,3 +64,8 @@ The modeling approach in this performance model is based on: The modeling approach in this cost model is based on: - Mathur et al. (Stanford): + +## Example + +[Example 04: Geologic Hydrogen](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/04_geo_h2) +contains separate configurations for natural and stimulated subsurface hydrogen models. diff --git a/docs/technology_models/h2_htse.md b/docs/technology_models/h2_htse.md index 764431541..ef8c9ca7d 100644 --- a/docs/technology_models/h2_htse.md +++ b/docs/technology_models/h2_htse.md @@ -91,6 +91,9 @@ The current implementation is intentionally simple and should be interpreted as ### API details For API details, see the [`HTSEPerformanceModel` and `HTSECostModel` API documentation](../_autosummary/h2integrate.converters.hydrogen.htse_electrolyzer). +The nuclear heat and electricity integration is demonstrated in +[Example 36: Nuclear Reactor and HTSE](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/36_nuclear_reactor_htse). + ## Cost Model To use this model, in your `tech_config.yaml` file, set: diff --git a/docs/technology_models/iron_transport.md b/docs/technology_models/iron_transport.md new file mode 100644 index 000000000..307c8b844 --- /dev/null +++ b/docs/technology_models/iron_transport.md @@ -0,0 +1,31 @@ +# Iron Transport Models + +`IronTransportPerformanceComponent` estimates the land and water distances needed to ship iron ore +to a Great Lakes port. It reads the mine location from `plant_config.sites.site` and uses built-in +port coordinates and shipping waypoints. Set `find_closest_ship_site: true` and +`shipment_site: "None"` to select the shortest modeled route, or set `find_closest_ship_site: false` +and choose `Duluth`, `Chicago`, `Cleveland`, or `Buffalo` explicitly. + +The performance component reports `land_transport_distance`, `water_transport_distance`, and +`total_transport_distance` in kilometers. `IronTransportCostComponent` uses these distances and the +`iron_ore_in` profile to calculate transport cost outputs. Its `transport_year` and `marginal_cost` +inputs are configured in the technology's cost parameters; the cost year is set from the plant's +`target_dollar_year`. + +Both models require hourly time steps. See the iron DRI and mine documentation for their use in +complete flowsheets. + +```{eval-rst} +.. autoclass:: h2integrate.converters.iron.iron_transport.IronTransportPerformanceComponentConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.iron.iron_transport.IronTransportCostComponentConfig + :members: + :no-index: +``` + +## Examples + +The model is used by the iron mine, DRI, electrowinning, and mapping configurations in +[Example 21](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/21_iron_examples). diff --git a/docs/technology_models/methanol.md b/docs/technology_models/methanol.md index 86fee1ef8..4be885adf 100644 --- a/docs/technology_models/methanol.md +++ b/docs/technology_models/methanol.md @@ -10,3 +10,44 @@ Methanol is an essential precursor to many chemicals, and is also used as a fuel - [Nyari et al.](https://doi.org/10.1016/j.jcou.2020.101166) This modeling is further documented in a journal publication by [Martin et al.](https://doi.org/10.1021/acs.est.4c02589) + +## Registered models and configuration + +| Route | Performance model | Cost model | Finance model | +|---|---|---|---| +| Steam methane reforming | `SMRMethanolPlantPerformanceModel` | `SMRMethanolPlantCostModel` | `SMRMethanolPlantFinanceModel` | +| CO2 hydrogenation | `CO2HMethanolPlantPerformanceModel` | `CO2HMethanolPlantCostModel` | `CO2HMethanolPlantFinanceModel` | + +Both performance models use hourly time steps and ratio-based feedstock calculations, limited by +the configured `plant_capacity_kgpy` and available inputs. The SMR route consumes natural gas and +produces electricity as a co-product. The CO2 hydrogenation route consumes hydrogen, carbon +dioxide, natural gas, and electricity. Both report methanol production, emissions, and water +consumption. The cost models use total overnight, fixed operating, and variable operating cost +inputs, then calculate route-specific feedstock costs and credits. The finance models report +levelized cost of methanol. + +```{eval-rst} +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantFinanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantFinanceModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/nitrogen_asu.md b/docs/technology_models/nitrogen_asu.md new file mode 100644 index 000000000..b4a620b77 --- /dev/null +++ b/docs/technology_models/nitrogen_asu.md @@ -0,0 +1,25 @@ +# Nitrogen Air Separation Unit + +`SimpleASUPerformanceModel` is a linear model for producing nitrogen from air. It can size from a +configured nitrogen demand, or use a rated nitrogen production or electrical power input. It also +calculates air feed, oxygen and argon co-products, and electricity use. Its `efficiency_kWh_pr_kg_N2` +is expressed in kWh per kg of nitrogen; the model documentation gives representative values of +0.119 for cryogenic separation and 0.29 for pressure swing adsorption. + +With `size_from_N2_demand: true`, connect `nitrogen_in` as the demand profile and the model reports +the required `electricity_in`. With it set to `false`, provide `electricity_in` and specify either +`rated_N2_kg_pr_hr` or `ASU_rated_power_kW`. The model supports one-hour time steps. + +`SimpleASUCostModel` calculates capital and operating expenses using a cost per nitrogen capacity +or a cost per power capacity. `capex_unit` must identify the basis; when `opex_usd_per_unit_per_year` +is nonzero, set `opex_unit` as well. + +```{eval-rst} +.. autoclass:: h2integrate.converters.nitrogen.simple_ASU.SimpleASUPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.nitrogen.simple_ASU.SimpleASUCostConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/nuclear.md b/docs/technology_models/nuclear.md index edb20a264..1854db931 100644 --- a/docs/technology_models/nuclear.md +++ b/docs/technology_models/nuclear.md @@ -70,3 +70,8 @@ $$ ### API details For API details, see the [`SimpleThermalNuclearReactorPerformanceModel` and `SimpleThermalNuclearReactorCostModel` API documentation](../_autosummary/h2integrate.converters.nuclear.nuclear_plant_thermal). + +## Integrated nuclear and HTSE example + +[Example 36: Nuclear Reactor and HTSE](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/36_nuclear_reactor_htse) +connects thermal nuclear output to the high-temperature steam electrolyzer. diff --git a/docs/technology_models/pem_electrolyzer.md b/docs/technology_models/pem_electrolyzer.md new file mode 100644 index 000000000..b9dbeae93 --- /dev/null +++ b/docs/technology_models/pem_electrolyzer.md @@ -0,0 +1,55 @@ +# PEM Electrolyzer + +H2Integrate's `ECOElectrolyzerPerformanceModel` wraps the ECO-PEM model to convert electricity +into hydrogen and oxygen. It models electrolyzer clusters, turndown, degradation, and replacement +timing. Use it when an analysis needs an electrolyzer model with time-varying operation rather than +a fixed-rate conversion. + +## Registered models + +- `ECOElectrolyzerPerformanceModel` computes hydrogen and oxygen production, electricity and water + consumption, capacity factor, and the replacement schedule. +- `BasicElectrolyzerCostModel`, `SingliticoCostModel`, and `CustomElectrolyzerCostModel` provide + alternative capital-cost models. Choose the cost model whose assumptions and input data fit the + analysis; their configuration classes are documented below. + +The performance model requires a one-hour time step. `size_mode: normal` uses the configured +cluster count and rating. `resize_by_max_feedstock` can size from the maximum available electricity, +and `resize_by_max_commodity` can size from a connected hydrogen demand. The resize modes also +require `flow_used_for_sizing`; see [Sizing Modes](../user_guide/run_size_modes.md) for the +framework-level behavior. + +## Configuration + +The performance configuration controls cluster count and rating, onshore/offshore location, +turndown, degradation penalties, and operating hours to end of life. Cost inputs depend on the +selected cost model. + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.pem_electrolyzer.ECOElectrolyzerPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.basic_cost_model.BasicElectrolyzerCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.singlitico_cost_model.SingliticoCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.custom_electrolyzer_cost_model.CustomElectrolyzerCostModelConfig + :members: + :no-index: +``` + +## Examples + +- [Example 25: Sizing Modes](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/25_sizing_modes) + demonstrates the electrolyzer's sizing modes. +- [Example 01: Onshore Steel](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/01_onshore_steel_mn) + and [Example 13: Dispatch for Electrolyzer](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/13_dispatch_for_electrolyzer) + show the model in larger systems. + +The separate [WOMBAT electrolyzer operations model](wombat_electrolyzer_om.md) can be used when +maintenance and downtime behavior are important. diff --git a/docs/technology_models/steel_eaf.md b/docs/technology_models/steel_eaf.md index 2f7d25967..9b050b90f 100644 --- a/docs/technology_models/steel_eaf.md +++ b/docs/technology_models/steel_eaf.md @@ -17,6 +17,23 @@ The models implemented in H2I are: - `HydrogenEAFPlantPerformanceComponent` - `HydrogenEAFPlantCostComponent` +## Simple steel plant + +The separate `SteelPerformanceModel` represents annual steel production using a configured plant +capacity and capacity factor. `SteelCostAndFinancialModel` calculates capital and operating costs +and reports levelized cost of steel (`LCOS`). Both use hourly time steps. These models are distinct +from the natural-gas and hydrogen EAF performance components described above. + +```{eval-rst} +.. autoclass:: h2integrate.converters.steel.steel.SteelPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.steel.steel.SteelCostAndFinancialModelConfig + :members: + :no-index: +``` + ```{note} The EAF model use sponge iron as an input rather than pig iron, which is lower in carbon content. The LBNL model calls the input to the EAF pig iron, but that's typically produced using a blast furnace rather than through the DRI process and has higher carbon impurities. ``` diff --git a/docs/technology_models/tidal.md b/docs/technology_models/tidal.md index d75dd5505..9ad77dc7d 100644 --- a/docs/technology_models/tidal.md +++ b/docs/technology_models/tidal.md @@ -48,3 +48,8 @@ where: This allows a generic power curve to be adapted to different device ratings. + +## Example + +[Example 31: Tidal](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/31_tidal) +shows the tidal resource, plant, and driver configurations. diff --git a/docs/technology_models/transport.md b/docs/technology_models/transport.md new file mode 100644 index 000000000..2247681d2 --- /dev/null +++ b/docs/technology_models/transport.md @@ -0,0 +1,123 @@ +# Transport Components + +Transport components connect technologies in `plant_config.yaml` through +`technology_interconnections`. The built-in cable, pipe, and generic transporter models pass their +input commodity profile through without losses. They do not model transport delays, pressure drop, +or other physical constraints. + +## Cables and pipes + +The registered transport types `cable` and `pipe` are selected in a plant interconnection, for +example: + +```yaml +technology_interconnections: + - [wind, battery, electricity, cable] + - [hydrogen_producer, hydrogen_storage, hydrogen, pipe] +``` + +`cable` transports electricity. `pipe` supports hydrogen, carbon dioxide, methanol, ammonia, +nitrogen, natural gas, wellhead gas, water, oxygen, lignin, and diesel. Both are no-cost +pass-through components; add a separate cost model if transport cost is needed. + +`GenericTransporterPerformanceModel` is a configurable pass-through for a commodity and its rate +units. It supports time steps from 1 second to $10^9$ seconds, unlike several production models +that require hourly simulation. + +## Combiner + +`GenericCombinerPerformanceModel` sums one commodity from multiple sources into one output. It +accepts `commodity`, `commodity_rate_units`, and optional `in_streams` (default 2). Set +`in_streams` to the number of incoming connections when combining more than two sources. + +```yaml +technologies: + electricity_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 +``` + +The combined commodity profile and rated production are sums of the input streams. The output +capacity factor is a rated-production-weighted average; it is zero when total rated production is +zero. Each input must provide its commodity profile, rated production, and capacity factor. + +## Splitter + +`GenericSplitterPerformanceModel` takes one input and produces exactly two outputs. `out1` is the +priority branch; `out2` receives the remainder. Configure `commodity` and `commodity_rate_units` +under `model_inputs.performance_parameters`, together with one of these modes: + +- `split_mode: fraction` requires `fraction_to_priority_tech`, between 0 and 1. `out1` receives that + fraction of the input, and `out2` receives the rest. +- `split_mode: prescribed_commodity` requires `prescribed_commodity_to_priority_tech`, in the + configured commodity rate units. `out1` receives the requested amount, limited to the available + nonnegative input; `out2` receives the remainder. + +```yaml +technologies: + electricity_splitter: + performance_model: + model: GenericSplitterPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + split_mode: fraction + fraction_to_priority_tech: 0.7 +``` + +Connect the two downstream technologies to the splitter in the same order as the intended `out1` +and `out2` branches. The splitter does not combine sources; use a combiner upstream when multiple +technologies supply its input. + +## Multivariable gas streams + +`GasStreamCombinerPerformanceModel` combines supported multivariable gas streams. It sums each +stream's mass flow and computes mass-weighted averages for intensive properties such as temperature, +pressure, and composition. Set `commodity` and `in_streams` in its performance parameters. The +connection syntax and stream variable names are covered in +{ref}`Connecting Technologies `. + +## Transport cost models + + coordinates, then multiplies it by `capex_per_km` and `fixed_opex_per_km`. Connect site latitude + and longitude to its `source_*` and `dest_*` inputs. +- `LinearMassTransportCostModel` uses geodesic distance, an optional `circuity_ratio` (default 1), + and annual commodity throughput. Its input costs are `capex_per_mton_km` and + `fixed_opex_per_mton_km`. + +```{eval-rst} +.. autoclass:: h2integrate.transporters.generic_transporter.GenericTransporterPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.generic_combiner.GenericCombinerPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.generic_splitter.GenericSplitterPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.gas_stream_combiner.GasStreamCombinerPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.linear_transport_cost.LinearTransportCostConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.linear_mass_transport_cost.LinearMassTransportCostConfig + :members: + :no-index: +``` + +## Examples + +- [Example 17: Splitter, wind, and direct ocean capture](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2) +- [Example 32: Multivariable gas streams](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/32_multivariable_streams) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index cdfc1574e..3b86f592e 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -223,144 +223,9 @@ For the splitter outputs, variables are indexed as `wellhead_gas_mixture:_in`: Time-series commodity profile from source *i* (commodity units) - - `rated__production`: Rated (nameplate) production of source *i* (commodity units, scalar) - - `_capacity_factor`: Annual capacity factor of source *i* (unitless, shape = plant life) - -- **Outputs**: - - `_out`: Combined commodity time-series profile — element-wise sum of all inputs - - `rated__production`: Total rated production — sum of all input rated productions - - `capacity_factor`: Combined capacity factor (unitless, shape = plant life) - -### Commodity output - -The combined output profile is the element-wise sum of all input profiles: - -`_out = _in1 + _in2 + …` - -### Capacity factor calculation - -The combined capacity factor is a **weighted average** of the input capacity factors, weighted by each stream's rated production: - -`CF_out = (CF1 × S1 + CF2 × S2 + …) / (S1 + S2 + …)` - -where `CF_i` is the capacity factor and `S_i` is the rated commodity production of input stream *i*. This weighting ensures that larger sources contribute proportionally more to the combined capacity factor. If the total rated production is zero, the output capacity factor is set to zero. - -### Usage example - -```yaml -technology_interconnections: [ - ["wind_farm", "combiner", "electricity", "cable"], - ["solar_farm", "combiner", "electricity", "cable"], - ["combiner", "electrolyzer", "electricity", "cable"], -] -``` - -This configuration combines wind and solar power before sending it to an electrolyzer. - -## Generic splitter - -The generic splitter takes one commodity stream from a single source and splits it between two outputs. It offers two operating modes; the examples below use electricity. - -### Configuration - -Add the splitter to your `tech_config.yaml`: - -```yaml -technologies: - electricity_splitter: - performance_model: - model: "GenericSplitterPerformanceModel" - model_inputs: - performance_parameters: - commodity: "electricity" - commodity_rate_units: "kW" - split_mode: "fraction" # or "prescribed_commodity" - fraction_to_priority_tech: 0.7 -``` - -### Split modes - -#### Fraction mode (`split_mode: "fraction"`) - -In fraction mode, you specify what fraction of the input commodity goes to the first technology: - -```yaml -config: - split_mode: "fraction" - fraction_to_priority_tech: 0.3 # 30% to first tech, 70% to second -``` - -- **Input parameter**: `fraction_to_priority_tech` (0.0 to 1.0) -- **Behavior**: - - `electricity_out1 = electricity_in × fraction` - - `electricity_out2 = electricity_in × (1 - fraction)` - -#### Prescribed commodity mode (`split_mode: "prescribed_commodity"`) - -In prescribed commodity mode, you specify an amount of the commodity to send to the first technology: - -```yaml -performance_parameters: - split_mode: "prescribed_commodity" - prescribed_commodity_to_priority_tech: 200.0 # 200 kW to first tech -``` - -- **Input parameter**: `prescribed_commodity_to_priority_tech` (in `commodity_rate_units`) -- **Behavior**: - - `electricity_out1 = min(prescribed_amount, available_power)` - - `electricity_out2 = electricity_in - electricity_out1` -- **Smart limiting**: If you request more power than available, the first output gets all available power and the second output gets zero - -### Inputs and outputs - -- **Input**: - - `electricity_in`: Total power input (kW) - - Mode-specific input: `fraction_to_priority_tech` or `prescribed_commodity_to_priority_tech` -- **Outputs**: - - `electricity_out1`: Commodity sent to the priority technology (kW) - - `electricity_out2`: Remaining commodity sent to the other technology (kW) - - `electricity_consumed`: Total commodity consumed from the input (kW) - -### Usage example - -```yaml -technology_interconnections: [ - ["offshore_wind", "electricity_splitter", "electricity", "cable"], - ["electricity_splitter", "doc", "electricity", "cable"], # first output - ["electricity_splitter", "electrolyzer", "electricity", "cable"], # second output -] -``` - -This sends part of the offshore wind power to a direct ocean capture system and the remainder to an electrolyzer. - -```{note} -Each splitter has one input stream and exactly two output streams. Add a combiner upstream when the splitter must receive multiple sources. -``` +The `cable`, `pipe`, generic transporter, combiner, and splitter models are documented in +[Transport Components](../technology_models/transport.md). This page focuses on how to express +their connections in `technology_interconnections`. diff --git a/docs/user_guide/defining_sites_and_resources.md b/docs/user_guide/defining_sites_and_resources.md index fd118b956..2a9079f3a 100644 --- a/docs/user_guide/defining_sites_and_resources.md +++ b/docs/user_guide/defining_sites_and_resources.md @@ -23,6 +23,10 @@ sites: Further information on the available resource models can be found [here](https://h2integrate.readthedocs.io/en/latest/resource/resource_index.html) +Some API resource models support simulations spanning multiple years when the requested data and +simulation time range are compatible. See the [Resource Model Index](../resource/resource_index.md) +for details, and set `plant.simulation.n_timesteps` and `dt` to match the desired simulation range. + ## Site-to-resource connections overview The `site_to_tech_connections` section in your plant configuration file defines how technologies are connected to site data and resource outputs for that site. This includes resource data such as wind or solar time series and site parameters such as latitude and longitude. diff --git a/docs/user_guide/driver_config_reference.md b/docs/user_guide/driver_config_reference.md new file mode 100644 index 000000000..f70a5a154 --- /dev/null +++ b/docs/user_guide/driver_config_reference.md @@ -0,0 +1,47 @@ +# Driver Configuration Reference + +The driver configuration controls output files, optional OpenMDAO reports, analysis drivers, +design variables, objectives, constraints, and recording. It is referenced from the top-level +configuration with `driver_config`. + +## General options + +| Field | Default | Purpose | +|---|---:|---| +| `general.folder_output` | `output` | Directory for generated output and recorder files. | +| `general.create_om_reports` | `true` | Create OpenMDAO reports, including N2 diagrams. Set to `false` to disable them. | + +## Analysis driver + +The `driver` mapping may contain `optimization` or `parameter_sweep`. At most one driver's `flag` +can be true. If neither is enabled, the model runs once using the configured values. Optimization +settings include the solver, tolerance, iteration limit, derivative options, and solver-specific +options. The `parameter_sweep` block selects a generator and its inputs, such as `num_samples`, +`levels`, or a CSV `filename`. See [Design Optimization](design_optimization_in_h2i.md) and +[Parameter Sweeps](parameter_sweep_in_h2i.md) for workflows and examples. + +## Design variables, objective, and constraints + +`design_variables` and `constraints` are grouped by technology or finance-subgroup name, then by +OpenMDAO variable name. Set `flag` to enable each entry and provide its `units`; design variables +may also specify bounds and scaling, while constraints require at least one of `lower`, `upper`, or +`equals`. The single `objective` identifies the full OpenMDAO variable name to minimize. To +maximize a result, minimize its negative using the objective's `ref` scaling as appropriate. + +## Recorder + +Set `recorder.flag` to enable an SQL recorder. The `file` name is written inside +`general.folder_output`; `overwrite_recorder` controls whether an existing file is replaced or a +unique suffix is used. `recorder_attachment` may be `driver` or `model` (the driver is recommended +for parallel runs). + +| Field | Purpose | +|---|---| +| `includes` | Glob patterns selecting variables to record. Defaults to `['*']`. | +| `excludes` | Glob patterns to omit. Defaults to `['*resource_data']`, which avoids storing large, constant resource profiles. | +| `record_inputs`, `record_outputs`, `record_residuals` | Select component-level data. | +| `options_excludes` | Component option names to omit when attached to the model. | +| `record_desvars`, `record_objectives`, `record_constraints`, `record_derivative` | Select driver data; used when attached to the driver. | + +The schema is maintained in [`driver_schema.yaml`](../../h2integrate/core/inputs/driver_schema.yaml). +See [Recording and Loading Data](recording_and_loading_data.md) for working recorder examples. diff --git a/docs/user_guide/plant_config_reference.md b/docs/user_guide/plant_config_reference.md new file mode 100644 index 000000000..2b4e6256c --- /dev/null +++ b/docs/user_guide/plant_config_reference.md @@ -0,0 +1,110 @@ +# Plant Configuration Reference + +The plant configuration describes site locations, simulation time, technology connections, and +system-level finance. It is referenced from the top-level configuration with `plant_config`. See +[Defining Sites and Connecting Resources](defining_sites_and_resources.md) for resource-specific +examples, and the [Technology Configuration Reference](technology_config_reference.md) and +[Driver Configuration Reference](driver_config_reference.md) for the other input files. + +## Top-level fields + +| Field | Purpose | +|---|---| +| `name`, `description` | Identify and describe the plant configuration. | +| `sites` | Named sites with latitude, longitude, optional elevation, and named resource models. | +| `plant` | Plant lifetime and simulation time settings. | +| `technology_interconnections` | Connections between technologies and optional transport components. | +| `site_to_tech_connections` | Connections from site data or resource outputs to technology inputs. | +| `tech_to_dispatch_connections` | Connections from technologies to dispatch rules or dispatch controllers. | +| `system_level_control` | Optional system-level controller configuration. | +| `finance_parameters` | Finance groups, subgroups, and cost-year adjustment settings. | + +The plant schema currently validates the fields documented in +[`plant_schema.yaml`](../../h2integrate/core/inputs/plant_schema.yaml). The model also reads +`tech_to_dispatch_connections` and `system_level_control`, but these two keys are not currently +declared in that schema; they are therefore code-supported configuration fields that schema +validation does not check. + +## Sites + +Each site is identified by a user-chosen key. Latitude and longitude are required; elevation is +optional. A site's `resources` mapping may contain multiple named resource models. Each resource +entry specifies a registered `resource_model` and its `resource_parameters`; the resource key is a +user-chosen name, not the model class name. Use `site_to_tech_connections` to connect site location +or resource data to technologies. + +Resource models have model-specific data ranges and time-step requirements. Some API-backed wind +and solar resources support multiple years; see the [resource index](../resource/resource_index.md) +and the wind and solar resource pages. + +## Plant lifetime and simulation + +`plant.plant_life` is the project lifetime in years. `plant.simulation` controls the simulated +period and uses seconds for `dt`: + +```yaml +plant: + plant_life: 30 + simulation: + dt: 3600 + n_timesteps: 8760 + start_time: "01/01 00:30:00" + timezone: 0 +``` + +`dt` and `n_timesteps` are required by the schema. The default `dt` is 3600 seconds and the +default `n_timesteps` is 8760. `start_time` and `timezone` are optional; timezone is the UTC offset +for the start time. Every performance model in the system must support the selected `dt`, and the +requested simulation period must be compatible with the connected resource data. + +## Technology and site connections + +`technology_interconnections` lists source, destination, commodity or stream, and optionally a +transporter. For example, `- [wind, battery, electricity, cable]` connects wind electricity to a +battery through a cable. See [Connecting Technologies](connecting_technologies.md) for connection +forms and [Transport Components](../technology_models/transport.md) for cable, pipe, combiner, and +splitter behavior. + +`site_to_tech_connections` connects a named site or resource to a technology. Resource data is +typically connected with a three-element entry such as +`[site.wind_resource, wind, wind_resource_data]`; site coordinates can also be mapped to technology +inputs. The site guide covers the supported forms and resource variable names. + +`tech_to_dispatch_connections` is used when dispatch rules need information from technologies +upstream of a dispatch controller. Each pair identifies a source technology and the technology +whose dispatch model consumes it. For example, optimized battery dispatch commonly connects both +the upstream generator and battery to the battery controller: + +```yaml +tech_to_dispatch_connections: + - [wind, battery] + - [battery, battery] +``` + +Declare the corresponding rule models in `tech_config.yaml`; the +[Pyomo Controllers](../control/technology_level_control/pyomo_controllers.md) guide explains the +heuristic and optimized patterns. + +## System-level control + +When present, `system_level_control` enables a plant-level controller. The strategy-specific +parameters are documented with each controller in the [System-Level Control](../control/system_level_control/system_level_control.md) +guide. For example, a demand-following configuration identifies the strategy and demand technology: + +```yaml +system_level_control: + control_strategy: DemandFollowingControl + demand_component: electrical_load_demand + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` + +## Finance parameters + +`finance_parameters` contains `finance_groups`, `finance_subgroups`, and +`cost_adjustment_parameters`. Groups define finance models and their inputs; subgroups select +technologies, a commodity stream, and one or more groups for an analysis. Cost adjustment converts +model costs to `target_dollar_year` using `cost_year_adjustment_inflation`. See +[Specifying Finance Parameters](specifying_finance_parameters.md) for the finance-group structure. diff --git a/docs/user_guide/postprocessing_results.md b/docs/user_guide/postprocessing_results.md index 11b0ad922..27b25cd07 100644 --- a/docs/user_guide/postprocessing_results.md +++ b/docs/user_guide/postprocessing_results.md @@ -191,3 +191,85 @@ Pass the base name and the function handles the rest: # Aggregates cases.sql_0, cases.sql_1, ... into a single DataFrame summary_df = convert_sql_to_csv_summary("output_dir/cases.sql") ``` + +## Printing model results + +`print_results` prints a hierarchical summary of an OpenMDAO model's inputs and outputs and returns +a dictionary containing input, explicit-output, and implicit-output metadata. The `includes` and +`excludes` arguments accept OpenMDAO variable patterns; `show_units` controls whether units are +included in the report and returned metadata. + +```python +from h2integrate.postprocess.reporting import print_results + +model.run() +result_summary = print_results( + model.prob.model, + includes="*.hydrogen_out", + excludes="*resource_data", + show_units=True, +) +``` + +The returned mapping has `inputs`, `explicit_outputs`, and `implicit_outputs` entries. + +```{eval-rst} +.. autofunction:: h2integrate.postprocess.reporting.print_results + :no-index: +``` + +## Plotting energy and hydrogen flows + +The plotting helpers in `h2integrate.tools.plot` read the production `energy_flows.csv` file. They +accept start and end datetimes, a save path, and flags to display or save the figure. The default +input path is `./output/data/production/energy_flows.csv`. + +```python +import datetime as dt + +from h2integrate.tools.plot import plot_energy_flows, plot_hydrogen_flows + +plot_energy_flows( + energy_flow_data_path="output/data/production/energy_flows.csv", + start_date_time=dt.datetime(2024, 1, 5), + end_date_time=dt.datetime(2024, 1, 10), + show_fig=False, +) +plot_hydrogen_flows( + energy_flow_data_path="output/data/production/energy_flows.csv", + show_fig=False, +) +``` + +`plot_energy_flows` plots electricity generation and battery charge/discharge. +`plot_hydrogen_flows` plots hydrogen production, demand, and storage state of charge. The CSV must +contain the columns expected by the selected plotting function. + +```{eval-rst} +.. autofunction:: h2integrate.tools.plot.plot_energy_flows + :no-index: + +.. autofunction:: h2integrate.tools.plot.plot_hydrogen_flows + :no-index: +``` + +## ProFAST helpers + +The ProFAST utilities create and populate a `ProFAST` object from a configuration mapping, run its +price solve, or convert an existing object back to configuration data. They are available from +`h2integrate.tools.profast_tools`. + +- `create_and_populate_profast(pf_config)` builds a ProFAST instance from a configuration mapping. +- `run_profast(pf)` returns the solved price, summary values, and cost-breakdown DataFrame. +- `make_price_breakdown(price_breakdown, pf_config)` distributes non-component financial costs + across primary cost items and returns a check against the solved levelized cost. +- `format_profast_price_breakdown_per_year(price_breakdown)` formats a ProFAST cost breakdown by + year for reporting. +- `convert_pf_to_dict(pf)` converts a ProFAST instance into nested configuration data. + +```python +from h2integrate.tools.profast_tools import create_and_populate_profast, run_profast + +profast_model = create_and_populate_profast(pf_config) +solution, summary, price_breakdown = run_profast(profast_model) +``` diff --git a/docs/user_guide/technology_config_reference.md b/docs/user_guide/technology_config_reference.md new file mode 100644 index 000000000..a5ea2bac6 --- /dev/null +++ b/docs/user_guide/technology_config_reference.md @@ -0,0 +1,39 @@ +# Technology Configuration Reference + +The technology configuration declares each technology, its model classes, and model-specific +inputs. It is referenced from the top-level configuration with `technology_config`. The schema is +maintained in [`tech_schema.yaml`](../../h2integrate/core/inputs/tech_schema.yaml), and +[`populate_tech_yaml`](populate_tech_yaml.md) can generate model input templates. + +## Technology entries + +Each key under `technologies` is a user-chosen technology name. Model class names are specified +under their respective model sections: + +| Field | Purpose | +|---|---| +| `performance_model.model` | Registered performance model class. Required by the technology schema. | +| `cost_model.model` | Registered cost model class when the technology has a cost model. Some registered models are explicitly cost-free. | +| `control_strategy.model` | Optional controller class for technology-level control. | +| `dispatch_rule_set.model` | Rule class used by heuristic Pyomo dispatch. Optimized dispatch does not use this field. | +| `finance_model.model` | Optional technology-level finance model. | +| `model_location` | Optional source path for a custom performance, cost, or finance model. | +| `finance_model.group` | Name of the plant finance group that receives the technology-specific finance model. | + +The performance, cost, control, dispatch-rule, and finance sections contain a `model` field; put +model parameters under `model_inputs`, not alongside `model`. + +## Model inputs + +`model_inputs` may contain: + +- `shared_parameters`: values shared by at least two model roles. +- `performance_parameters`: performance-model configuration. +- `cost_parameters`: cost-model configuration. +- `financial_parameters`: technology finance-model configuration. +- `control_parameters`: technology controller configuration. + +The accepted fields in each parameter section depend on the selected model. The model documentation +and generated templates are the sources for those model-specific values. For dispatch rule wiring, +see [Pyomo Controllers](../control/technology_level_control/pyomo_controllers.md); for technology +finance groups, see [Specifying Finance Parameters](specifying_finance_parameters.md). diff --git a/h2integrate/postprocess/mapping.py b/h2integrate/postprocess/mapping.py index 8cfc6bf1f..e204cff17 100644 --- a/h2integrate/postprocess/mapping.py +++ b/h2integrate/postprocess/mapping.py @@ -791,10 +791,11 @@ def auto_colorbar_limits(values: gpd.GeoSeries | pd.Series | np.ndarray): Automatically compute colorbar limits. This function calculates lower (`vmin`) and upper (`vmax`) limits for a colorbar - based on the range of the input data. It ensures that the limits: - - Extend slightly beyond the data's minimum and maximum. - - Are rounded to "nice" numbers for readability and colorbar tick placement. - - Handles very small or nearly constant data ranges gracefully. + based on the range of the input data. It ensures that the limits: + + - Extend slightly beyond the data's minimum and maximum. + - Are rounded to "nice" numbers for readability and colorbar tick placement. + - Handle very small or nearly constant data ranges gracefully. Args: values (gpd.GeoSeries | pd.Series | np.ndarray): Numeric data to compute colorbar limits. diff --git a/h2integrate/preprocess/populate_tech_yaml.py b/h2integrate/preprocess/populate_tech_yaml.py index 3636ccaa6..71c97b9ce 100644 --- a/h2integrate/preprocess/populate_tech_yaml.py +++ b/h2integrate/preprocess/populate_tech_yaml.py @@ -13,16 +13,19 @@ storage models where parameters must be carefully organized across multiple configuration sections. -Usage (CLI): +Usage (CLI):: + python -m h2integrate.preprocess.populate_tech_yaml \\ \\ [--output-path ] - Or use the command-line entry point: + Or use the command-line entry point:: + populate_tech_yaml \\ [--output-path ] -Usage (Python): +Usage (Python):: + from h2integrate.preprocess.populate_tech_yaml import populate_tech_yaml_from_file populated_config = populate_tech_yaml_from_file( "path/to/skeleton_tech_config.yaml", From 1cce8d6663aa56d7f89f3f927c3f990a13bde613 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:14 -0600 Subject: [PATCH 04/29] Updating top-level docs, config, etc --- CHANGELOG.md | 1 + README.md | 11 +-- docs/CONTRIBUTING.md | 27 ++++--- docs/_config.yml | 17 +++- docs/_toc.yml | 2 +- .../features_not_currently_implemented.md | 11 --- docs/developer_guide/why_make_h2integrate.md | 80 ++++++------------- docs/getting_started/environment_variables.md | 15 ++-- docs/getting_started/install.md | 18 +++-- docs/intro.md | 13 +-- 10 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 docs/developer_guide/features_not_currently_implemented.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d38d2b3..0026f7110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/README.md b/README.md index dc4a76c27..60c483eeb 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,9 @@ H2Integrate is both a **hybrid systems engineering design tool** and a **technoe - **Cost inputs settable by the user** with examples based on the [Annual Technology Baseline (ATB)](https://atb.nlr.gov/) - **Optimization and scenario analysis** to explore design trade-offs across hybrid plant configurations -### Available Technologies +### Model Library -H2I includes models for a broad set of energy generation, conversion, and storage technologies. -This is a non-exhaustive list, and the library of available technologies is actively expanding: - -- **Electricity generation**: solar PV, wind, wave, tidal, natural gas combined cycle (NGCC), natural gas combustion turbines (NGCT), nuclear, grid -- **Hydrogen production**: PEM electrolysis, NG-SMR -- **Energy storage**: Li-ion batteries, long-duration energy storage (LDES), pumped storage hydropower (PSH), hydrogen storage, compressed gas storage -- **Fuel cells**: H2 PEM fuel cells -- **Industrial processes**: ammonia synthesis, iron ore reduction, steel production, and more +See the [Model Overview](./docs/user_guide/model_overview.md) for the current registered models and their categories. The [documentation](https://h2integrate.readthedocs.io/en/latest/intro.html) provides the project overview and links to model-specific guides. ## Getting Started diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 349f941f7..30b3ae111 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,8 +20,9 @@ GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submi * Keep style fixes to a separate commit to make your pull request more readable. * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). -* When you start working on a pull request, start by creating a new branch pointing at the latest - commit on [main](https://github.com/NatLabRockies/H2Integrate). +* Contributions are generally made against the `develop` branch. New models use the + [`@register` decorator](https://github.com/NatLabRockies/H2Integrate/blob/develop/h2integrate/core/supported_models.py); + see [Adding a New Technology](developer_guide/adding_a_new_technology.md) for the model requirements. * Code formatting is enforced using pre-commit hooks and is required for any code pushed up to the repository. The pre-commit package is included in the developer install of the repository. The pre-commit hooks can be installed by running ```bash pre-commit install @@ -76,22 +77,28 @@ jupytext notebook.md --to .ipynb ## Tests -The test suite can be run using `pytest tests/h2integrate`. Individual test files can be run by specifying them: +Run tests from the repository root in an environment with the development dependencies installed. +Every test must use one of the `unit`, `regression`, `integration`, or `hpc` markers. Collection +fails for unmarked tests. Common commands are: ```bash -pytest tests/h2integrate/test_hybrid.py +pytest -m unit +pytest -m regression +pytest -m integration +pytest -m hpc ``` -and individual tests can be run within those files +To run one test file or test function, provide its path and optional node ID: ```bash -pytest tests/h2integrate/test_hybrid.py::test_h2integrate_system +pytest h2integrate/core/test/test_utilities.py +pytest h2integrate/core/test/test_utilities.py::test_function_name ``` -When you push to your fork, or open a PR, your tests will be run against the -[Continuous Integration (CI)](https://github.com/NatLabRockies/H2Integrate/actions) suite. This will start a build -that runs all tests on your branch against multiple Python versions, and will also test -documentation builds. +See the [Testing Guide](developer_guide/testing.md) for test conventions, marker details, and +additional commands. Pull requests run the automated test suite through +[GitHub Actions](https://github.com/NatLabRockies/H2Integrate/actions). The documentation build is +also checked on every pull request by [Read the Docs](https://h2integrate.readthedocs.io/). ## Code Review Process diff --git a/docs/_config.yml b/docs/_config.yml index ba772c071..594721dd7 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,9 +1,9 @@ # Book settings # Learn more at https://jupyterbook.org/customize/config.html title: H2Integrate -author: National Renewable Energy Laboratory +author: NLR # logo: logo.png -copyright: '2025' +copyright: 2026, NLR only_build_toc_files: false # exclude_patterns: [_build, Thumbs.db, .DS_Store, "**.ipynb_checkpoints", .github] # Force re-execution of notebooks on each build. @@ -30,21 +30,30 @@ latex: repository: url: https://github.com/NatLabRockies/H2Integrate path_to_book: docs - branch: main + branch: develop # Add GitHub buttons to your book # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository html: use_issues_button: true use_repository_button: true - baseurl: https://nrel.github.io/H2Integrate/ + baseurl: https://h2integrate.readthedocs.io/en/latest/ sphinx: extra_extensions: - sphinx.ext.autodoc - sphinx.ext.autosummary - sphinx.ext.napoleon + - h2integrate.core.docs_sphinx config: language: python autosummary_generate: true + html_theme_options: + check_switcher: false + navbar_end: + - version-switcher + - navbar-icon-links + switcher: + json_url: https://h2integrate.readthedocs.io/en/latest/_static/switcher.json + version_match: latest autodoc_default_options: members: true member-order: bysource diff --git a/docs/_toc.yml b/docs/_toc.yml index aad60bffb..94562e793 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -125,10 +125,10 @@ parts: - file: developer_guide/class_structure - file: developer_guide/technology_model_types - file: developer_guide/testing - - file: developer_guide/features_not_currently_implemented - caption: Miscellaneous Resources chapters: - file: misc_resources/FAQ + - file: misc_resources/changelog - file: misc_resources/glossary - file: misc_resources/defining_filepaths - file: misc_resources/turbine_models_library_preprocessing diff --git a/docs/developer_guide/features_not_currently_implemented.md b/docs/developer_guide/features_not_currently_implemented.md deleted file mode 100644 index 71d899a1d..000000000 --- a/docs/developer_guide/features_not_currently_implemented.md +++ /dev/null @@ -1,11 +0,0 @@ -# Features not currently implemented - -There are many features that are not currently implemented in H2Integrate that are necessary for current or future projects. -These will become issues on the H2Integrate GitHub repo. -We are listing them here: - -- allowing different technologies within the same plant to be at different sites -- saving results elegantly for plant components so they do not always need to be rerun -- detailing the required inputs and outputs for different model types (e.g. electrolyzer) so that users can more easily add their own subsystems -- splitters and combiners for when resources are used or produced by multiple components -- simulating different timespans than one year at hourly timesteps (8760s) diff --git a/docs/developer_guide/why_make_h2integrate.md b/docs/developer_guide/why_make_h2integrate.md index fbe144930..54dfb9d22 100644 --- a/docs/developer_guide/why_make_h2integrate.md +++ b/docs/developer_guide/why_make_h2integrate.md @@ -1,53 +1,30 @@ -# Welcome to H2Integrate +# H2Integrate Design Rationale -This collection of docs is focused on presenting H2Integrate, acknowledging where we are now and where we want to be in the future. -Most of these docs are written in a way that we'll be able to reuse them immediately as proper docs in the actual H2Integrate repo. -For now, to reduce development overhead and encourage rapid iteration, we'll continue to work in this sandbox repo. +H2Integrate provides a framework for combining technology models into hybrid-system simulations, +technoeconomic analyses, and optimization studies. Its design emphasizes reusable model interfaces +and explicit connections between technologies. -Our goal with these doc pages is to communicate ideas that exist in our heads, put them to paper, and provide diagrams where helpful. -These docs are a work in progress; please ask questions about any and all parts presented here. +## Why modularity? -## Why make H2Integrate? - -H2Integrate has already proven itself useful as a tool that can analyze and design complex hybrid systems producing electricity, hydrogen, steel, and more. -However, these developments came in waves across multiple disparate projects, leading to a sometimes disjointed codebase. -In an effort to streamline and modularize H2Integrate to make it more effective and capable for necessary future studies, we are devoting time to redesigning it from the ground up. -This page discusses some of the high-level decisions and mindsets that we've used throughout this process. - -## What does it mean to "modularize" H2Integrate? - -Most of the current implementation of H2Integrate assumes that you are modeling a certain hybrid system architecture using a limited number of specific models. -As more projects call for using H2Integrate, we must make it easier to develop and add capability to the tool in a sustainable and clear way. -By making the internal framework of H2Integrate more agnostic to the technologies considered in the hybrid system design, we can allow for more user-defined modularized subsystems to be considered effectively. - -Getting into the details, this means creating generalized components that H2Integrate can expect to behave in a certain way. - -We introduce the ideas of "converters", which convert one resource into another. -Simple examples of converters include electrolyzers, wind turbines, solar PV panels, and more. -These converters can pass resources to other converters or "storage" components via "transporter" components. - -Transporters include hydrogen pipelines, electricity cables, or anything that transports a resource. -In a broad sense, these might include delivery trucks or shipping vessels, though those are more for future consideration. - -Storage components include batteries, hydrogen tanks; anything where you store a resource. - -By combining instances of these different generalized components, we can study distinct hybrid systems in H2Integrate. -Internally, the H2Integrate framework just needs to know if a something is a converter, transporter, storage, or some other type of component. -Additionally, this allows users to develop their own components using the expected interface, and add in custom subsystems to their hybrid plant. +Hybrid systems combine components with different physical behavior, cost assumptions, and operating +constraints. H2Integrate separates these concerns into model roles such as performance, cost, +finance, and control, and connects technologies through defined commodities and transport +components. Users can combine the registered models or add components that follow the framework's +interfaces. ## Why use OpenMDAO as the internal framework for H2Integrate? -Through a series of internal discussions, the H2Integrate dev team landed on using [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) for this new version of the tool. +H2Integrate uses [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) to assemble and +run its models. -Using OpenMDAO for this gives quite a few benefits: +OpenMDAO provides: - a proven framework for complex data-passing within multidisciplinary systems - automatically-generated visualization tools to help understand models and debug them - internal units handling and conversion - built-in nonlinear solvers to resolve model coupling - built-in optimization and parameter sweep drivers - multiple existing NLR tools use OpenMDAO, including [WISDEM](https://github.com/NLRWindSystems/WISDEM/) and [WEIS](https://github.com/NLRWindSystems/WEIS), so we can draw from institutional knowledge -- set up with gradient-based optimization in mind, which is not currently a focus for H2Integrate but this positions the tool well for potential future additions -- parallelization done using MPI, which is also not currently a focus but useful for the future +- gradient-based optimization and MPI-based parallelization capabilities for applicable studies However, there are a few downsides to using OpenMDAO: - an additional layer of code that developers must consider @@ -55,24 +32,15 @@ However, there are a few downsides to using OpenMDAO: - potentially increased computational costs depending on problem type and size - it isn't great at optimizing mixed-integer problems -The benefits outweighed the downsides, hence the team's choice to use OpenMDAO going forward. -Additionally, we can code H2Integrate in a way to minimize some of the potential issues, given that we're aware of them before refactoring H2Integrate. - -## How are generation technologies handled today? - -H2Integrate now models generation technologies directly with native technology components -for wind, solar, storage, wave, and tidal systems. -This keeps technology behavior, dispatch interfaces, and cost modeling within a single -framework, with all components exposed consistently to the same configuration and -optimization infrastructure. - -## Where should code I develop and implement live? +These benefits support H2Integrate's component-level modeling and optimization goals. Contributors +should still consider OpenMDAO's additional abstraction layer, longer error traces, possible +computational overhead, and limitations with mixed-integer optimization. -Historically, H2Integrate has been a sort of hybrid itself, where it contains both the tool itself as well as project-specific code. -This has led to a somewhat disjointed codebase that is difficult to maintain and understand. +## Where should code live? -To address this, here are guidelines for where code you develop should live: -- If the code is specific to a project, it should live in the project's repository. -- If the code is generalizable, could be used in multiple projects, or is a useful addition to the H2Integrate tool, it should live in the H2Integrate repository. -- Wrappers to models that are used in H2Integrate should live in the H2Integrate repository. -- The actual models (physics, costs, financials) should live **outside** the H2Integrate repository. Complex models might live in their own repo (e.g. BERT), whereas other models might live in the project's repository. +- Reusable technology models, cost and finance models, and integrations that benefit multiple + projects belong in the H2Integrate repository. +- Project-specific or proprietary models belong in the project repository. They can be integrated + by implementing the interfaces expected by H2Integrate. +- For the model interfaces and contribution workflow, see + [Adding a New Technology](adding_a_new_technology.md). diff --git a/docs/getting_started/environment_variables.md b/docs/getting_started/environment_variables.md index 21f94609d..e2559cfd4 100644 --- a/docs/getting_started/environment_variables.md +++ b/docs/getting_started/environment_variables.md @@ -93,11 +93,16 @@ unset NLR_API_EMAIL (set-environment-variables-with-env-file)= ## Set Environment Variables with .env file -The ".env" file will be looked for in all of the following locations: - - H2Integrate root directory (`/path/to/H2Integrate/h2integrate/`) - - parent of H2Integrate root directory (`/path/to/H2Integrate/`) (preferred location to store your environment file) - - current working directory (this is not a preferred location to store your environment file) -1. Choose which of the above directories you want to host your .env file, and create a file named ".env" in that folder. +H2Integrate searches for a `.env` file in these locations, in order: + +1. The H2Integrate package directory (for a source checkout, `/path/to/H2Integrate/h2integrate/`). +2. The package directory's parent (for a source checkout, `/path/to/H2Integrate/`). +3. The current working directory. +4. The user's home directory. + +For a source checkout, the repository root is a convenient location. For a pip installation, the +package directory's parent is usually inside `site-packages`; use the home directory or current +working directory instead. Create a file named `.env` in the directory you choose. 2. Open the ".env" file and add the environment variables: ```bash NLR_API_KEY='api-key-value' diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md index f78b34e84..cfec53dcc 100644 --- a/docs/getting_started/install.md +++ b/docs/getting_started/install.md @@ -8,6 +8,14 @@ If you just want to use H2Integrate and aren't developing new models, you should pip install h2integrate ``` +Pyomo is installed with H2Integrate, but solver software is installed separately. If you use a +Pyomo dispatch or optimization model, install at least one supported solver, such as GLPK, CBC, or +HiGHS. These are available from conda-forge: + +```bash +conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy +``` + ## Installing from Source If you want to develop new models or contribute to H2Integrate, you can install it from source. @@ -59,10 +67,10 @@ If you want to develop new models or contribute to H2Integrate, you can install 4. Create a conda environment and install H2Integrate and all its dependencies. Please read the following two notes about modified installation steps. - 1. If on a Unix machine, uncomment line 8 in `environment.yml` to install Cbc. Windows - users will need to manually install from . - 2. If you plan to use Ard, please uncomment line 9 in `environment.yml` to ensure WISDEM (an Ard - dependency) is installed from conda to avoid installation issues with some systems. + 1. The environment specification already installs GLPK, CBC, and HiGHS from conda-forge; no + solver lines need to be uncommented. + 2. If you plan to use Ard, uncomment the `wisdem` dependency in `environment.yml` so it is + installed from conda and avoids installation issues on some systems. ```bash conda env create -f environment.yml @@ -111,7 +119,7 @@ pip install -e ".[all]" 4. Install H2Integrate and its dependencies: ```bash - conda install -y -c conda-forge glpk coin-or-cbc>=2.10.12 highspy + conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy ``` - If you want to just use H2Integrate: diff --git a/docs/intro.md b/docs/intro.md index 0f84b4d33..2d1000c32 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -9,15 +9,18 @@ H2Integrate (H2I) is an open-source Python package for hybrid systems engineering design and technoeconomic analysis. It models and optimizes hybrid energy plants that produce electricity, hydrogen, ammonia, steel, and other products, using high-resolution location-specific resource data to perform optimization and scenario analysis. +## About H2Integrate + +H2Integrate grew from projects that needed to model and analyze complex hybrid energy systems. It is now maintained as a reusable framework for composing technology performance, cost, finance, and control models, while still allowing users to add project-specific components. + Browse the example workflows in the GitHub repository: https://github.com/NatLabRockies/H2Integrate/tree/main/examples ## What is H2Integrate? -H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for various hybrid systems. -The tool currently includes renewable energy generation (land-based wind, offshore wind, solar PV, wave, tidal), conventional generation (natural gas combined cycle, combustion turbines, grid electricity), hydrogen production (PEM electrolysis, NG-SMR), energy storage (Li-ion batteries, long-duration energy storage, pumped storage hydropower), fuel cells, and industrial processes (ammonia synthesis, iron ore reduction, steel production, methanol, and more). -Other elements can also be included as developed by users. -H2Integrate is continually expanding to serve additional hybrid applications -- if you're interested in seeing what's being actively developed, please see the [current pull requests in the GitHub repository](https://github.com/NatLabRockies/H2Integrate/pulls). -Some modeling capabilities in H2Integrate are provided by integrating existing tools, such as [PySAM](https://github.com/NatLabRockies/pysam), [ORBIT](https://github.com/NLRWindSystems/ORBIT), and [ProFAST](https://github.com/NatLabRockies/ProFAST). +H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for hybrid systems. +Its registered models cover land-based wind and solar PV; wave, tidal, run-of-river hydro, natural gas, nuclear, and grid electricity; hydrogen production and storage; fuel cells; and industrial processes including ammonia, methanol, iron, and steel. +The [Model Overview](user_guide/model_overview.md) lists the current registered models, and model-specific pages describe their capabilities and configuration. +Some models integrate existing tools, including [PySAM](https://github.com/NatLabRockies/pysam) and [ProFAST](https://github.com/NatLabRockies/ProFAST). The H2Integrate tool is built on top of [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/), which provides a powerful and flexible environment for modeling and optimization. ```{note} From 4d7e78c44b3253e0792815d6e2329c7d59e1ea80 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:46 -0600 Subject: [PATCH 05/29] Added sphinx docs file --- h2integrate/core/docs_sphinx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 h2integrate/core/docs_sphinx.py diff --git a/h2integrate/core/docs_sphinx.py b/h2integrate/core/docs_sphinx.py new file mode 100644 index 000000000..a9b070ede --- /dev/null +++ b/h2integrate/core/docs_sphinx.py @@ -0,0 +1,23 @@ +import os +from importlib.metadata import version + +from sphinx.config import Config +from sphinx.application import Sphinx + + +def configure_version_switcher(app: Sphinx, config: Config) -> None: + readthedocs_version = os.environ.get("READTHEDOCS_VERSION", "latest") + if readthedocs_version == "develop": + readthedocs_version = "latest" + + theme_options = dict(config.html_theme_options or {}) + switcher_options = dict(theme_options.get("switcher", {})) + switcher_options["version_match"] = readthedocs_version + theme_options["switcher"] = switcher_options + theme_options["announcement"] = f"H2Integrate {version('h2integrate')} documentation" + config.html_theme_options = theme_options + + +def setup(app: Sphinx) -> dict[str, object]: + app.connect("config-inited", configure_version_switcher) + return {"version": "1.0", "parallel_read_safe": True} From f71214b88d848c4d908749e973481782ebbca38b Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 8 Oct 2026 16:10:47 -0600 Subject: [PATCH 06/29] Add core framework support for non-annual simulation durations --- h2integrate/core/inputs/validation.py | 32 +- h2integrate/core/model_baseclass.py | 326 +++++++++++++++++- h2integrate/core/test/test_framework.py | 29 +- .../core/test/test_model_baseclasses.py | 189 ++++++++++ h2integrate/finances/profast_baseclass.py | 28 +- h2integrate/finances/profast_npv.py | 31 +- h2integrate/finances/test/test_finances.py | 16 + h2integrate/finances/test/test_profast_lco.py | 20 ++ h2integrate/finances/test/test_profast_npv.py | 40 +++ 9 files changed, 664 insertions(+), 47 deletions(-) create mode 100644 h2integrate/core/test/test_model_baseclasses.py diff --git a/h2integrate/core/inputs/validation.py b/h2integrate/core/inputs/validation.py index 60cbdbacd..78091bc69 100644 --- a/h2integrate/core/inputs/validation.py +++ b/h2integrate/core/inputs/validation.py @@ -152,15 +152,33 @@ def load_tech_yaml(finput): def load_plant_yaml(finput): plant_config = _validate(finput, fschema_plant) - n_timesteps = plant_config["plant"]["simulation"]["n_timesteps"] - dt = plant_config["plant"]["simulation"]["dt"] - - if int(n_timesteps) * int(dt) != 31_536_000: + n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) + dt = int(plant_config["plant"]["simulation"]["dt"]) + plant_life = int(plant_config["plant"]["plant_life"]) + + seconds_per_year = 31_536_000 # 8760 h/year * 3600 s/h + seconds_simulated = n_timesteps * dt + years_simulated = seconds_simulated / seconds_per_year + + # The simulation may cover any positive duration (a fraction of a year, a single + # year, or multiple years). Performance/cost/finance models annualize their results + # using ``fraction_of_year_simulated`` (see the model base classes), so an arbitrary + # horizon is supported as long as it is positive and does not exceed the plant life. + if seconds_simulated <= 0: msg = ( - "H2Integrate does not currently support simulations that are less than or " - "greater than 1-year. Please ensure that " + "The simulation horizon must be positive. Please ensure that " "plant_config['plant']['simulation']['n_timesteps'] times " - "plant_config['plant']['simulation']['dt'] equals 31536000 (s)." + "plant_config['plant']['simulation']['dt'] is greater than 0 (s)." + ) + raise ValueError(msg) + + if years_simulated > plant_life: + msg = ( + "H2Integrate does not support simulations that are longer than the plant " + f"life. The configured simulation covers {years_simulated:.4g} years " + f"(n_timesteps={n_timesteps} * dt={dt} s = {seconds_simulated} s), but " + f"plant_config['plant']['plant_life'] is {plant_life} years. Either shorten " + "the simulation horizon or increase plant_life." ) raise ValueError(msg) diff --git a/h2integrate/core/model_baseclass.py b/h2integrate/core/model_baseclass.py index b66448431..56e2d51f3 100644 --- a/h2integrate/core/model_baseclass.py +++ b/h2integrate/core/model_baseclass.py @@ -1,4 +1,5 @@ import copy +import math import hashlib from pathlib import Path @@ -117,7 +118,8 @@ def setup(self): ) def apply_curtailment(self, outputs): - """Apply curtailment to ``{commodity}_out`` based on ``{commodity}_command_value``. + """ + Apply curtailment to ``{commodity}_out`` based on ``{commodity}_command_value``. Copies the current ``{commodity}_out`` into ``uncurtailed_{commodity}_out``, then clips ``{commodity}_out`` to ``min(uncurtailed, command_value)`` element-wise. @@ -126,6 +128,7 @@ def apply_curtailment(self, outputs): Should be called at the end of each flexible model's ``compute()`` method after the raw production has been written to ``outputs[f"{commodity}_out"]``. """ + if "system_level_control" in self.options["plant_config"]: if getattr(self, "_control_classifier", None) != "flexible": return @@ -140,6 +143,258 @@ def apply_curtailment(self, outputs): command_value = self._inputs[command_value_key] outputs[commodity_out_key] = np.minimum(uncurtailed, command_value) + # TODO rewrite and reorganize this method + def calculate_annual_cf_and_replacement_schedule( + self, + performance_timeseries, + rated_performance, + state_of_health_timeseries, + eol_soh, + no_degradation_extrapolation="tile", + soh_cycle_repetition="all_soh_cycles", + ): + """Project annual capacity factors and replacement schedule across plant life. + + Args: + performance_timeseries (array-like): timestep-level performance values used + to compute annual capacity factors over the simulated horizon. + rated_performance (float): rated performance value used in capacity-factor + calculation. + state_of_health_timeseries (array-like | None): timestep-level state-of-health + values. If None, no degradation/replacement projection is applied. + When provided, SOH is used to determine replacement timing. + eol_soh (float | None): State-of-health threshold at or below which the + technology is considered replaced. Only required when + ``state_of_health_timeseries`` is provided. + no_degradation_extrapolation (str): Extrapolation strategy when + ``state_of_health_timeseries`` is None. Options: + + - ``"tile"``: repeat simulated annual values cyclically. + - ``"final_sim_value"``: hold the last simulated annual value constant for + the rest of the plant life beyond simulated years. + - ``"average_sim_value"``: hold simulated average annual value constant for + the rest of the plant life beyond simulated years. + soh_cycle_repetition (str): When ``state_of_health_timeseries`` is provided, + controls what repeats after the simulated (and completed) cycle history. + All simulated data is always preserved at the front of the timeline. + Options: + + - ``"all_soh_cycles"``: repeat the full ordered set of simulated cycles. + - ``"final_soh_cycle"``: repeat only the final completed cycle. + + Returns: + tuple[np.ndarray, np.ndarray]: projected annual capacity factors and + replacement schedule. + """ + performance_timeseries = np.asarray(performance_timeseries, dtype=float) + if performance_timeseries.size == 0: + raise ValueError("performance_timeseries must contain at least one value.") + + rated_performance = float(rated_performance) + if rated_performance <= 0.0: + raise ValueError("rated_performance must be greater than zero.") + + valid_no_deg_modes = ("tile", "final_sim_value", "average_sim_value") + if no_degradation_extrapolation not in valid_no_deg_modes: + raise ValueError( + "no_degradation_extrapolation must be one of " + f"{valid_no_deg_modes}; got {no_degradation_extrapolation!r}." + ) + + valid_soh_cycle_repetition_modes = ("all_soh_cycles", "final_soh_cycle") + if soh_cycle_repetition not in valid_soh_cycle_repetition_modes: + raise ValueError( + "soh_cycle_repetition must be one of " + f"{valid_soh_cycle_repetition_modes}; got {soh_cycle_repetition!r}." + ) + + use_degradation = state_of_health_timeseries is not None + if use_degradation: + if eol_soh is None: + raise ValueError("eol_soh is required when state_of_health_timeseries is provided.") + state_of_health_timeseries = np.asarray(state_of_health_timeseries, dtype=float) + if state_of_health_timeseries.size == 0: + raise ValueError( + "state_of_health_timeseries must contain at least one value when provided." + ) + + steps_per_year = max(1, round(31_536_000 / self.dt)) + n_sim_years = math.ceil(performance_timeseries.size / steps_per_year) + simulated_annual_capacity_factors = np.zeros(n_sim_years) + + for year in range(n_sim_years): + start = year * steps_per_year + end = min(start + steps_per_year, performance_timeseries.size) + segment = performance_timeseries[start:end] + segment_seconds = (end - start) * self.dt + segment_hours = segment_seconds / 3600.0 + simulated_annual_capacity_factors[year] = ( + (segment.sum() * (self.dt / 3600.0)) / (rated_performance * segment_hours) + if segment_hours > 0.0 + else 0.0 + ) # performance units * h / (performance units * h) + + if not use_degradation: + if no_degradation_extrapolation == "tile": + n_tiles = math.ceil(self.plant_life / n_sim_years) + annual_capacity_factors = np.tile(simulated_annual_capacity_factors, n_tiles)[ + : self.plant_life + ] + elif no_degradation_extrapolation == "final_sim_value": + annual_capacity_factors = np.array( + [ + simulated_annual_capacity_factors[y] + if y < n_sim_years + else simulated_annual_capacity_factors[-1] + for y in range(self.plant_life) + ] + ) + elif no_degradation_extrapolation == "average_sim_value": + simulated_average_capacity_factor = float( + np.mean(simulated_annual_capacity_factors) + ) + annual_capacity_factors = np.array( + [ + simulated_annual_capacity_factors[y] + if y < n_sim_years + else simulated_average_capacity_factor + for y in range(self.plant_life) + ] + ) + else: + raise ValueError( + "no_degradation_extrapolation must be one of " + f"{valid_no_deg_modes}; got {no_degradation_extrapolation!r}." + ) + replacement_schedule = np.zeros(self.plant_life) + return annual_capacity_factors, replacement_schedule + + else: + n_sim_years_from_soh = math.ceil(state_of_health_timeseries.size / steps_per_year) + if n_sim_years_from_soh != n_sim_years: + raise ValueError( + "state_of_health_timeseries length must match the number of simulated years " + "implied by performance_timeseries and dt." + ) + + # Year-end SOH for each simulated year. + sim_soh_year_end = np.zeros(n_sim_years) + for year in range(n_sim_years): + start = year * steps_per_year + end = min(start + steps_per_year, state_of_health_timeseries.size) + sim_soh_year_end[year] = state_of_health_timeseries[end - 1] + + # Segment the observed simulated years into replacement cycles. A cycle ends when + # either the year-end SOH reaches end of life, or the following year's SOH resets + # upward (a replacement already recorded in the input series). Only the trailing + # cycle can be incomplete (ended by running out of simulated data). + soh_reset_tol = 1e-9 + cycle_cf_segments = [] + cycle_soh_segments = [] + cycle_reached_eol = [] + cycle_start = 0 + for year in range(n_sim_years): + eol_reached = sim_soh_year_end[year] <= eol_soh + soh_reset_next = (year + 1 < n_sim_years) and ( + sim_soh_year_end[year + 1] > sim_soh_year_end[year] + soh_reset_tol + ) + is_last_year = year == n_sim_years - 1 + if eol_reached or soh_reset_next or is_last_year: + cycle_cf_segments.append( + np.array(simulated_annual_capacity_factors[cycle_start : year + 1]) + ) + cycle_soh_segments.append(np.array(sim_soh_year_end[cycle_start : year + 1])) + cycle_reached_eol.append(bool(eol_reached or soh_reset_next)) + cycle_start = year + 1 + + # Complete the trailing cycle if the simulation ended before it reached end of + # life. Only extrapolated years are appended; observed years are left unchanged. + if not cycle_reached_eol[-1]: + trailing_cf = cycle_cf_segments[-1] + trailing_soh = cycle_soh_segments[-1] + + # Local annual degradation rate from the last one or two points of the + # incomplete trailing cycle (robust to non-linear degradation). + if trailing_soh.size >= 2: + local_annual_deg_rate = trailing_soh[-2] - trailing_soh[-1] + else: + trailing_start_year = n_sim_years - trailing_soh.size + start_ts = trailing_start_year * steps_per_year + end_ts = min(start_ts + steps_per_year, state_of_health_timeseries.size) + year_fraction = (end_ts - start_ts) / steps_per_year + year_fraction = year_fraction if year_fraction > 0.0 else 1.0 + local_annual_deg_rate = ( + state_of_health_timeseries[start_ts] - trailing_soh[-1] + ) / year_fraction + local_annual_deg_rate = max(float(local_annual_deg_rate), 0.0) + + last_observed_soh = float(trailing_soh[-1]) + last_observed_cf = float(trailing_cf[-1]) + extrapolated_soh = [] + extrapolated_cf = [] + projected_soh = last_observed_soh + # Cap the number of extrapolated years to avoid an unbounded loop if + # degradation stalls (rate of zero). + while ( + local_annual_deg_rate > 0.0 + and projected_soh > eol_soh + and len(extrapolated_soh) < self.plant_life + ): + projected_soh = projected_soh - local_annual_deg_rate + extrapolated_soh.append(projected_soh) + if last_observed_soh > 0.0: + extrapolated_cf.append( + last_observed_cf * max(projected_soh, 0.0) / last_observed_soh + ) + else: + extrapolated_cf.append(0.0) + + if extrapolated_soh: + cycle_cf_segments[-1] = np.concatenate([trailing_cf, np.array(extrapolated_cf)]) + cycle_soh_segments[-1] = np.concatenate( + [trailing_soh, np.array(extrapolated_soh)] + ) + cycle_reached_eol[-1] = extrapolated_soh[-1] <= eol_soh + + # The timeline is every simulated/completed cycle in order (the "front"), + # followed by the repeated cycle unit until the plant life is filled. + cycle_lengths = [segment.size for segment in cycle_cf_segments] + front_cf = np.concatenate(cycle_cf_segments) + + if soh_cycle_repetition == "all_soh_cycles": + repeat_cf = front_cf + repeat_lengths = cycle_lengths + repeat_reached_eol = list(cycle_reached_eol) + else: # "final_soh_cycle" (already validated above) + repeat_cf = cycle_cf_segments[-1] + repeat_lengths = [cycle_cf_segments[-1].size] + repeat_reached_eol = [cycle_reached_eol[-1]] + + # Capacity factors: keep the simulated/completed front, then tile the repeat unit. + n_repeats = math.ceil(max(self.plant_life - front_cf.size, 0) / repeat_cf.size) + annual_capacity_factors = np.concatenate([front_cf, np.tile(repeat_cf, n_repeats)])[ + : self.plant_life + ] + + # Replacement schedule: a replacement occurs at the first year of a cycle + # (plant year > 0) whenever the previous cycle reached end of life. + timeline_cycle_lengths = list(cycle_lengths) + timeline_reached_eol = list(cycle_reached_eol) + while sum(timeline_cycle_lengths) < self.plant_life: + timeline_cycle_lengths.extend(repeat_lengths) + timeline_reached_eol.extend(repeat_reached_eol) + + replacement_schedule = np.zeros(self.plant_life) + cycle_start_year = 0 + for cycle_index, cycle_length in enumerate(timeline_cycle_lengths): + if cycle_start_year >= self.plant_life: + break + if cycle_start_year > 0 and timeline_reached_eol[cycle_index - 1]: + replacement_schedule[cycle_start_year] = 1.0 + cycle_start_year += cycle_length + + return annual_capacity_factors, replacement_schedule + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ Computation for the OM component. @@ -156,7 +411,8 @@ class CostModelBaseConfig(BaseConfig): class CostModelBaseClass(om.ExplicitComponent): - """Baseclass to be used for all cost models. The built-in outputs + """ + Baseclass to be used for all cost models. The built-in outputs are used by the finance model and must be outputted by all cost models. Subclasses should use CostModelBaseConfig for their configuration class. @@ -216,6 +472,72 @@ def setup(self): desc="Marginal cost of production for dispatch decisions", ) + def calculate_annual_varopex( + self, + varopex_timeseries, + extrapolation_method="tile", + **kwargs, + ): + """Project annual variable OpEx across plant life from timestep-level costs. + + Args: + varopex_timeseries (array-like): timestep-level variable cost values in + annual cost numerator units (for example USD per timestep after any + flow * price multiplication). + extrapolation_method (str): Extrapolation strategy after the simulated + horizon. Options are ``"tile"``, ``"final_sim_value"``, and + ``"average_sim_value"``. + + Returns: + np.ndarray: annualized variable OpEx for each year of plant life. + """ + varopex_timeseries = np.asarray(varopex_timeseries, dtype=float) + if varopex_timeseries.size == 0: + raise ValueError("varopex_timeseries must contain at least one value.") + + valid_extrapolation_methods = ("tile", "final_sim_value", "average_sim_value") + if extrapolation_method not in valid_extrapolation_methods: + raise ValueError( + "extrapolation_method must be one of " + f"{valid_extrapolation_methods}; got {extrapolation_method!r}." + ) + + steps_per_year = max(1, round(31_536_000 / self.dt)) + n_sim_years = math.ceil(varopex_timeseries.size / steps_per_year) + simulated_annual_varopex = np.zeros(n_sim_years) + + for year in range(n_sim_years): + start = year * steps_per_year + end = min(start + steps_per_year, varopex_timeseries.size) + segment = varopex_timeseries[start:end] + segment_seconds = (end - start) * self.dt + if segment_seconds <= 0.0: + simulated_annual_varopex[year] = 0.0 + continue + + simulated_annual_varopex[year] = segment.sum() * (31_536_000 / segment_seconds) + + if extrapolation_method == "tile": + n_tiles = math.ceil(self.plant_life / n_sim_years) + annual_varopex = np.tile(simulated_annual_varopex, n_tiles)[: self.plant_life] + elif extrapolation_method == "final_sim_value": + annual_varopex = np.array( + [ + simulated_annual_varopex[y] if y < n_sim_years else simulated_annual_varopex[-1] + for y in range(self.plant_life) + ] + ) + else: + simulated_average_varopex = float(np.mean(simulated_annual_varopex)) + annual_varopex = np.array( + [ + simulated_annual_varopex[y] if y < n_sim_years else simulated_average_varopex + for y in range(self.plant_life) + ] + ) + + return annual_varopex + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ Computation for the OM component. diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index c568c9d7c..8ff33c81b 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -897,20 +897,31 @@ def test_unsupported_simulation_parameters(temp_dir): plant_config_data_dt = load_plant_yaml(temp_plant_config_dt) # docs fencepost end: DO NOT REMOVE - # Modify the n_timesteps entry for the temp_plant_config_ntimesteps - plant_config_data_ntimesteps["plant"]["simulation"]["n_timesteps"] = 8759 - # Modify the dt entry for the temp_plant_config_dt - plant_config_data_dt["plant"]["simulation"]["dt"] = 3601 + plant_life = int(plant_config_data_ntimesteps["plant"]["plant_life"]) - # Save the modified plant_configs YAML back + # Sub-year and multi-year horizons are supported when positive and within plant life. + plant_config_data_ntimesteps["plant"]["simulation"]["n_timesteps"] = 4380 # 0.5 year at 1 h with temp_plant_config_ntimesteps.open("w") as f: yaml.safe_dump(plant_config_data_ntimesteps, f) + load_plant_yaml(temp_plant_config_ntimesteps) + + # A two-year hourly horizon should also load without error. + plant_config_data_dt["plant"]["simulation"]["n_timesteps"] = 2 * 8760 with temp_plant_config_dt.open("w") as f: yaml.safe_dump(plant_config_data_dt, f) - - # check that error is thrown when loading config with invalid number of timesteps - with pytest.raises(ValueError, match="greater than 1-year"): - load_plant_yaml(plant_config_data_ntimesteps) + load_plant_yaml(temp_plant_config_dt) + + # A horizon longer than the plant life is not supported and must raise. + over_life = deepcopy(plant_config_data_dt) + over_life["plant"]["simulation"]["n_timesteps"] = (plant_life + 1) * 8760 + with pytest.raises(ValueError, match="longer than the plant"): + load_plant_yaml(over_life) + + # A non-positive horizon is invalid and must raise. + non_positive = deepcopy(plant_config_data_dt) + non_positive["plant"]["simulation"]["dt"] = 0 + with pytest.raises(ValueError, match="must be positive"): + load_plant_yaml(non_positive) @pytest.mark.unit diff --git a/h2integrate/core/test/test_model_baseclasses.py b/h2integrate/core/test/test_model_baseclasses.py new file mode 100644 index 000000000..2a3eb34c9 --- /dev/null +++ b/h2integrate/core/test/test_model_baseclasses.py @@ -0,0 +1,189 @@ +"""Tests for ``PerformanceModelBaseClass.calculate_annual_cf_and_replacement_schedule``.""" + +import numpy as np +import pytest + +from h2integrate.core.model_baseclass import PerformanceModelBaseClass + + +class _DummyPerformanceModel: + """Minimal stand-in exposing the attributes the helper reads (``plant_life``, ``dt``).""" + + def __init__(self, plant_life=6, dt=31_536_000): + self.plant_life = plant_life + self.dt = dt + + +def _run(dummy, performance_timeseries, rated, soh, eol, **kwargs): + return PerformanceModelBaseClass.calculate_annual_cf_and_replacement_schedule( + dummy, + np.asarray(performance_timeseries, dtype=float), + rated, + None if soh is None else np.asarray(soh, dtype=float), + eol, + **kwargs, + ) + + +@pytest.mark.unit +def test_resets_on_replacement(): + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [1.0, 0.92, 0.84], + 1.0, + [0.97, 0.89, 0.8], + 0.8, + ) + + np.testing.assert_allclose(cf_values, [1.0, 0.92, 0.84, 1.0, 0.92, 0.84]) + np.testing.assert_allclose(replacement_schedule, [0.0, 0.0, 0.0, 1.0, 0.0, 0.0]) + + +@pytest.mark.unit +def test_without_degradation_tile(): + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [0.6, 0.5, 0.4], + 1.0, + None, + None, + ) + + np.testing.assert_allclose(cf_values, [0.6, 0.5, 0.4, 0.6, 0.5, 0.4]) + np.testing.assert_allclose(replacement_schedule, np.zeros(6)) + + +@pytest.mark.unit +def test_without_degradation_final_sim_value(): + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [0.6, 0.5, 0.4], + 1.0, + None, + None, + no_degradation_extrapolation="final_sim_value", + ) + + np.testing.assert_allclose(cf_values, [0.6, 0.5, 0.4, 0.4, 0.4, 0.4]) + np.testing.assert_allclose(replacement_schedule, np.zeros(6)) + + +@pytest.mark.unit +def test_without_degradation_average_sim_value(): + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [0.6, 0.5, 0.4], + 1.0, + None, + None, + no_degradation_extrapolation="average_sim_value", + ) + + np.testing.assert_allclose(cf_values, [0.6, 0.5, 0.4, 0.5, 0.5, 0.5]) + np.testing.assert_allclose(replacement_schedule, np.zeros(6)) + + +@pytest.mark.unit +def test_extrapolates_incomplete_trailing_cycle_then_tiles(): + # SOH never reaches EOL during simulation, so the trailing cycle is completed by + # extrapolation before the completed cycle is tiled across plant life. + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=8), + [1.0, 0.9, 0.8], + 1.0, + [0.95, 0.90, 0.85], + 0.8, + ) + + extrapolated_cf = 0.8 * 0.80 / 0.85 + np.testing.assert_allclose( + cf_values, + [1.0, 0.9, 0.8, extrapolated_cf, 1.0, 0.9, 0.8, extrapolated_cf], + ) + np.testing.assert_allclose(replacement_schedule, [0, 0, 0, 0, 1, 0, 0, 0]) + + +@pytest.mark.unit +def test_preserves_multiple_simulated_cycles_all_soh_cycles(): + # Two completed cycles are observed (EOL crossing at year 1 and year 4). + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [1.0, 0.95, 0.9, 0.85, 0.8], + 1.0, + [0.85, 0.80, 0.90, 0.85, 0.80], + 0.8, + ) + + np.testing.assert_allclose(cf_values, [1.0, 0.95, 0.9, 0.85, 0.8, 1.0]) + np.testing.assert_allclose(replacement_schedule, [0, 0, 1, 0, 0, 1]) + + +@pytest.mark.unit +def test_final_soh_cycle_vs_all_soh_cycles(): + performance_timeseries = [1.0, 0.95, 0.9, 0.85, 0.8] + soh = [0.85, 0.80, 0.90, 0.85, 0.80] + + final_cf, final_repl = _run( + _DummyPerformanceModel(plant_life=8), + performance_timeseries, + 1.0, + soh, + 0.8, + soh_cycle_repetition="final_soh_cycle", + ) + np.testing.assert_allclose(final_cf, [1.0, 0.95, 0.9, 0.85, 0.8, 0.9, 0.85, 0.8]) + np.testing.assert_allclose(final_repl, [0, 0, 1, 0, 0, 1, 0, 0]) + + all_cf, all_repl = _run( + _DummyPerformanceModel(plant_life=8), + performance_timeseries, + 1.0, + soh, + 0.8, + soh_cycle_repetition="all_soh_cycles", + ) + np.testing.assert_allclose(all_cf, [1.0, 0.95, 0.9, 0.85, 0.8, 1.0, 0.95, 0.9]) + np.testing.assert_allclose(all_repl, [0, 0, 1, 0, 0, 1, 0, 1]) + + +@pytest.mark.unit +def test_detects_reset_via_upward_soh_jump(): + # SOH never falls to EOL, but resets upward between years 1 and 2, marking a + # completed cycle. The trailing cycle is then completed via extrapolation. + cf_values, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6), + [1.0, 0.95, 0.9, 0.85, 0.8], + 1.0, + [0.85, 0.82, 1.0, 0.9, 0.82], + 0.8, + ) + + extrapolated_cf = 0.8 * 0.74 / 0.82 + np.testing.assert_allclose(cf_values, [1.0, 0.95, 0.9, 0.85, 0.8, extrapolated_cf]) + np.testing.assert_allclose(replacement_schedule, [0, 0, 1, 0, 0, 0]) + + +@pytest.mark.unit +def test_invalid_soh_cycle_repetition_raises(): + with pytest.raises(ValueError): + _run( + _DummyPerformanceModel(plant_life=6), + [1.0, 0.9, 0.8], + 1.0, + [0.95, 0.9, 0.85], + 0.8, + soh_cycle_repetition="not_a_mode", + ) + + +@pytest.mark.unit +def test_invalid_no_degradation_extrapolation_raises(): + with pytest.raises(ValueError): + _run( + _DummyPerformanceModel(plant_life=6), + [0.6, 0.5, 0.4], + 1.0, + None, + None, + no_degradation_extrapolation="not_a_mode", + ) diff --git a/h2integrate/finances/profast_baseclass.py b/h2integrate/finances/profast_baseclass.py index 9e144f3db..f9f856082 100644 --- a/h2integrate/finances/profast_baseclass.py +++ b/h2integrate/finances/profast_baseclass.py @@ -2,6 +2,7 @@ import numpy as np import openmdao.api as om from attrs import field, define, validators +from openmdao.utils.units import convert_units from h2integrate.core.utilities import BaseConfig, attr_filter, attr_serializer from h2integrate.finances.tools import check_plant_config_and_profast_params @@ -519,7 +520,10 @@ def setup(self): ) self.output_txt = f"{self.options['commodity_type'].lower()}{self.description}" - plant_life = int(self.options["plant_config"]["plant"]["plant_life"]) + plant_cfg = self.options["plant_config"]["plant"] + plant_life = int(plant_cfg["plant_life"]) + self.dt = int(plant_cfg["simulation"]["dt"]) + self.n_timesteps = int(plant_cfg["simulation"]["n_timesteps"]) # Add rated capacity and capacity factor inputs self.add_input( @@ -625,12 +629,30 @@ def populate_profast(self, inputs): profast_params["commodity"].update({"unit": self.commodity_amount_units}) # calculate capacity and total production based on commodity type - capacity = inputs[f"rated_{self.options['commodity_type']}_production"][0] * 24 + io_meta_data = self.get_io_metadata() + rate_units_capacity = io_meta_data[f"rated_{self.options['commodity_type']}_production"][ + "units" + ] + day_amount_per_unit_rate = convert_units( + 86_400, + "s", + f"({self.commodity_amount_units})/({rate_units_capacity})", + ) + annual_amount_per_unit_rate = convert_units( + 31_536_000, + "s", + f"({self.commodity_amount_units})/({rate_units_capacity})", + ) + + capacity = ( + inputs[f"rated_{self.options['commodity_type']}_production"][0] + * day_amount_per_unit_rate + ) utilization = dict(zip(years_of_operation, inputs["capacity_factor"])) total_production = ( inputs["capacity_factor"] * inputs[f"rated_{self.options['commodity_type']}_production"] - * 8760 + * annual_amount_per_unit_rate ) # define profast parameters for capacity and utilization diff --git a/h2integrate/finances/profast_npv.py b/h2integrate/finances/profast_npv.py index b773f2246..5a974cd19 100644 --- a/h2integrate/finances/profast_npv.py +++ b/h2integrate/finances/profast_npv.py @@ -2,9 +2,8 @@ from collections.abc import Iterable import numpy as np -from openmdao.utils.units import convert_units, simplify_unit +from openmdao.utils.units import simplify_unit -from h2integrate.finances.tools import _compute_rate_units from h2integrate.core.supported_models import register from h2integrate.finances.profast_baseclass import ProFastBase @@ -103,15 +102,6 @@ def compute(self, inputs, outputs): self.price_units = io_meta_data[f"sell_price_{self.output_txt}"]["units"] self.commodity_amount_units = simplify_unit(f"USD/({self.price_units})") - # compute rate_units from the price - rate_units_from_price = _compute_rate_units( - self.commodity_sell_price_units, check_conversion=False - ) - rate_units_capacity = io_meta_data[f"rated_{self.options['commodity_type']}_production"][ - "units" - ] - conversion_ratio = convert_units(1, rate_units_from_price, rate_units_capacity) - non_pos_prod = inputs[f"rated_{self.options['commodity_type']}_production"][0] <= 0 has_zero_cf = np.all(inputs["capacity_factor"] == 0.0) @@ -127,21 +117,10 @@ def compute(self, inputs, outputs): ) warnings.warn(msg, UserWarning) return - # ensure that sell price units are compatible with the rate units - if float(conversion_ratio) != 1.0: - # convert rate units to units compatible with the price_units - inputs_adjusted = dict(inputs.items()) - capacity_converted = convert_units( - inputs[f"rated_{self.options['commodity_type']}_production"], - rate_units_capacity, - rate_units_from_price, - ) - inputs_adjusted[f"rated_{self.options['commodity_type']}_production"] = ( - capacity_converted - ) - pf = self.populate_profast(inputs_adjusted) - else: - pf = self.populate_profast(inputs) + + # populate_profast converts the rated production (in its native rate units) into the + # commodity amount units implied by the price, so no extra rate-unit adjustment is needed. + pf = self.populate_profast(inputs) non_op_Nyears = int(np.ceil(self.params.installation_time / 12) + 1) sell_price = inputs[f"sell_price_{self.output_txt}"] diff --git a/h2integrate/finances/test/test_finances.py b/h2integrate/finances/test/test_finances.py index f0b28c549..d57a2b72f 100644 --- a/h2integrate/finances/test/test_finances.py +++ b/h2integrate/finances/test/test_finances.py @@ -50,6 +50,10 @@ def model_configs(): }, "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, "grid_connection": True, "ppa_price": 0.05, }, @@ -434,6 +438,10 @@ def test_profast_config_provided(): }, "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, "cost_year": 2022, "grid_connection": True, "ppa_price": 0.05, @@ -538,6 +546,10 @@ def test_parameter_validation_clashing_values(): }, "plant": { "plant_life": 30, # Different from pf_params + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, } @@ -630,6 +642,10 @@ def test_parameter_validation_duplicate_parameters(): }, "plant": { "plant_life": 30, # Different from pf_params + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, } diff --git a/h2integrate/finances/test/test_profast_lco.py b/h2integrate/finances/test/test_profast_lco.py index 607fca078..2d3f8c229 100644 --- a/h2integrate/finances/test/test_profast_lco.py +++ b/h2integrate/finances/test/test_profast_lco.py @@ -95,6 +95,10 @@ def test_profast_comp(profast_inputs_no1, fake_filtered_tech_config, fake_cost_d plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -168,6 +172,10 @@ def test_profast_comp_coproduct( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -234,6 +242,10 @@ def test_profast_comp_heat(profast_inputs_no1, fake_filtered_tech_config, fake_c plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -294,6 +306,10 @@ def test_profast_comp_sales_tax( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -342,6 +358,10 @@ def test_profast_lco_warnings( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } diff --git a/h2integrate/finances/test/test_profast_npv.py b/h2integrate/finances/test/test_profast_npv.py index 147453968..e13c9c88e 100644 --- a/h2integrate/finances/test/test_profast_npv.py +++ b/h2integrate/finances/test/test_profast_npv.py @@ -104,6 +104,10 @@ def test_profast_npv_no1(profast_inputs_no1, fake_filtered_tech_config, fake_cos plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -152,6 +156,10 @@ def test_profast_npv_no1_change_sell_price( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } @@ -242,6 +250,10 @@ def test_profast_npv_no2(profast_inputs_no2, fake_filtered_tech_config, fake_cos plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -293,6 +305,10 @@ def test_profast_npv_nonstandard_price_units( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -347,6 +363,10 @@ def test_profast_npv_multi_year_sell_price( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -393,6 +413,10 @@ def test_profast_npv_multi_year_error(profast_inputs_no2, fake_filtered_tech_con plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -426,6 +450,10 @@ def test_profast_npv_missing_sell_price(profast_inputs_no2, fake_filtered_tech_c plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -472,6 +500,10 @@ def test_profast_npv_with_inflation( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -545,6 +577,10 @@ def test_profast_npv_uses_first_year_price_for_construction_padding( plant_config = { "plant": { "plant_life": plant_life, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no2}, } @@ -599,6 +635,10 @@ def test_profast_npv_warnings( plant_config = { "plant": { "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + }, }, "finance_parameters": {"model_inputs": profast_inputs_no1}, } From 26327f1b92a84f95b1ba2c1db6de50bec7e6a829 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 8 Oct 2026 17:00:21 -0600 Subject: [PATCH 07/29] add per-model simulation duration check --- h2integrate/core/h2integrate_model.py | 36 +++++++++++++++++++++- h2integrate/core/model_baseclass.py | 6 ++++ h2integrate/core/model_checks.py | 31 +++++++++++++++++++ h2integrate/core/test/test_framework.py | 41 ++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index 99c7c8602..ffdc69901 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -6,7 +6,11 @@ from h2integrate.core.dict_utils import check_inputs from h2integrate.core.file_utils import get_path, find_file, load_yaml, load_component_config -from h2integrate.core.model_checks import check_model_time_step, check_model_control_classifier +from h2integrate.core.model_checks import ( + check_model_time_step, + check_model_control_classifier, + check_model_simulation_duration, +) from h2integrate.core.connection_utils import ( create_technology_graph, check_dispatch_connections, @@ -926,6 +930,12 @@ def create_technology_models(self): comp, self.plant_config["plant"]["simulation"]["dt"], ) + check_model_simulation_duration( + perf_model, + comp, + self.plant_config["plant"]["simulation"]["n_timesteps"], + self.plant_config["plant"]["simulation"]["dt"], + ) self.tech_control_classifiers.update({tech_name: "feedstock"}) self.plant.add_subsystem(f"{tech_name}_source", comp) else: @@ -970,6 +980,12 @@ def create_technology_models(self): comp, self.plant_config["plant"]["simulation"]["dt"], ) + check_model_simulation_duration( + perf_model, + comp, + self.plant_config["plant"]["simulation"]["n_timesteps"], + self.plant_config["plant"]["simulation"]["dt"], + ) om_model_object = tech_group.add_subsystem(perf_model, comp, promotes=["*"]) self.performance_models.append(om_model_object) self.cost_models.append(om_model_object) @@ -1050,6 +1066,12 @@ def create_technology_models(self): comp, self.plant_config["plant"]["simulation"]["dt"], ) + check_model_simulation_duration( + tech_name, + comp, + self.plant_config["plant"]["simulation"]["n_timesteps"], + self.plant_config["plant"]["simulation"]["dt"], + ) self.plant.add_subsystem(tech_name, comp) n_non_transport_techs = sum( 1 for v in self.tech_control_classifiers.values() if v != "transport" @@ -1076,6 +1098,12 @@ def _process_model(self, model_type, individual_tech_config, tech_group): model_object, self.plant_config["plant"]["simulation"]["dt"], ) + check_model_simulation_duration( + model_name, + model_object, + self.plant_config["plant"]["simulation"]["n_timesteps"], + self.plant_config["plant"]["simulation"]["dt"], + ) om_model_object = tech_group.add_subsystem( model_name, @@ -1647,6 +1675,12 @@ def connect_technologies(self): connection_component, self.plant_config["plant"]["simulation"]["dt"], ) + check_model_simulation_duration( + transport_type, + connection_component, + self.plant_config["plant"]["simulation"]["n_timesteps"], + self.plant_config["plant"]["simulation"]["dt"], + ) self.plant.add_subsystem(connection_name, connection_component) # Reorder the subsystems so transporters comes after their source technology diff --git a/h2integrate/core/model_baseclass.py b/h2integrate/core/model_baseclass.py index 56e2d51f3..c4dd356d2 100644 --- a/h2integrate/core/model_baseclass.py +++ b/h2integrate/core/model_baseclass.py @@ -12,6 +12,9 @@ class PerformanceModelBaseClass(om.ExplicitComponent): + # (min, max) permitted simulation duration in years; default is annual-only. + _simulation_duration_bounds = (1.0, 1.0) + def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) @@ -427,6 +430,9 @@ class CostModelBaseClass(om.ExplicitComponent): This may be inherent to the cost model, or may depend on user provided input values. """ + # (min, max) permitted simulation duration in years; default is annual-only. + _simulation_duration_bounds = (1.0, 1.0) + def initialize(self): self.options.declare("driver_config", types=dict) self.options.declare("plant_config", types=dict) diff --git a/h2integrate/core/model_checks.py b/h2integrate/core/model_checks.py index dc328933f..f026b6dee 100644 --- a/h2integrate/core/model_checks.py +++ b/h2integrate/core/model_checks.py @@ -28,6 +28,37 @@ def check_model_time_step(model_name, model_object, time_step): ) +def check_model_simulation_duration(model_name, model_object, n_timesteps, dt): + """Check that a model supports the configured simulation duration. + + Args: + model_name (str): Name used to identify the model in error messages. + model_object: Model class or instance defining ``_simulation_duration_bounds`` + as ``(min, max)`` permitted simulation durations in years. Models that do not + define the attribute default to supporting a single year only. + n_timesteps (int | float): Number of timesteps in the simulation. + dt (int | float): Simulation time step in seconds. + + Returns: + None: Returns when the configured simulation duration is supported. + + Raises: + ValueError: If the simulation duration falls outside the model's supported bounds. + """ + seconds_per_year = 31_536_000 # 8760 h/year * 3600 s/h + years_simulated = (int(n_timesteps) * int(dt)) / seconds_per_year + minimum_years, maximum_years = getattr(model_object, "_simulation_duration_bounds", (1.0, 1.0)) + if minimum_years <= years_simulated <= maximum_years: + return + raise ValueError( + f"Model {model_name} is compatible with simulation durations between " + f"{minimum_years} and {maximum_years} years, but a simulation duration of " + f"{years_simulated:g} years (n_timesteps={int(n_timesteps)} * dt={int(dt)} s) was " + "specified. Please set plant_config['plant']['simulation'] so that n_timesteps * dt " + f"corresponds to a duration within the range [{minimum_years}, {maximum_years}] years." + ) + + def check_model_control_classifier(model_name, model_object, system_level_control_enabled): """Check that a model declares a classifier when system-level control is enabled. diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index 8ff33c81b..01bda2f8d 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -18,7 +18,11 @@ load_plant_yaml, load_driver_yaml, ) -from h2integrate.core.model_checks import check_model_time_step, check_model_control_classifier +from h2integrate.core.model_checks import ( + check_model_time_step, + check_model_control_classifier, + check_model_simulation_duration, +) from h2integrate.core.connection_utils import ( create_technology_graph, check_dispatch_connections, @@ -969,6 +973,41 @@ class ClassifiedModel: check_model_control_classifier("ClassifiedModel", ClassifiedModel, True) +@pytest.mark.unit +def test_check_simulation_duration_defaults_to_annual_only(): + class DummyModel: + pass + + # Exactly one year (8760 h at 1 h steps) is allowed by default. + check_model_simulation_duration("DummyModel", DummyModel, 8760, 3600) + + with pytest.raises( + ValueError, + match=( + r"Model DummyModel is compatible with simulation durations between " + r"1.0 and 1.0 years, but a simulation duration of 2 years" + ), + ): + check_model_simulation_duration("DummyModel", DummyModel, 2 * 8760, 3600) + + +@pytest.mark.unit +def test_check_simulation_duration_respects_model_bounds(): + class MultiYearModel: + # (min, max) permitted simulation duration in years + _simulation_duration_bounds = (0.0, 5.0) + + # Half a year and two years are both within the model's supported range. + check_model_simulation_duration("MultiYearModel", MultiYearModel, 4380, 3600) + check_model_simulation_duration("MultiYearModel", MultiYearModel, 2 * 8760, 3600) + + with pytest.raises( + ValueError, + match=r"compatible with simulation durations between 0.0 and 5.0 years", + ): + check_model_simulation_duration("MultiYearModel", MultiYearModel, 6 * 8760, 3600) + + @pytest.mark.unit def test_technology_connections(temp_dir): # Path to the original plant_config.yaml and high-level yaml in the example directory From 99cadaa625cee65253c66036d15dce9138598a42 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:14 -0600 Subject: [PATCH 08/29] Updating top-level docs, config, etc --- CHANGELOG.md | 1 + README.md | 11 +-- docs/CONTRIBUTING.md | 27 ++++--- docs/_config.yml | 17 +++- docs/_toc.yml | 2 +- .../features_not_currently_implemented.md | 11 --- docs/developer_guide/why_make_h2integrate.md | 80 ++++++------------- docs/getting_started/environment_variables.md | 15 ++-- docs/getting_started/install.md | 18 +++-- docs/intro.md | 13 +-- 10 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 docs/developer_guide/features_not_currently_implemented.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d38d2b3..0026f7110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/README.md b/README.md index dc4a76c27..60c483eeb 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,9 @@ H2Integrate is both a **hybrid systems engineering design tool** and a **technoe - **Cost inputs settable by the user** with examples based on the [Annual Technology Baseline (ATB)](https://atb.nlr.gov/) - **Optimization and scenario analysis** to explore design trade-offs across hybrid plant configurations -### Available Technologies +### Model Library -H2I includes models for a broad set of energy generation, conversion, and storage technologies. -This is a non-exhaustive list, and the library of available technologies is actively expanding: - -- **Electricity generation**: solar PV, wind, wave, tidal, natural gas combined cycle (NGCC), natural gas combustion turbines (NGCT), nuclear, grid -- **Hydrogen production**: PEM electrolysis, NG-SMR -- **Energy storage**: Li-ion batteries, long-duration energy storage (LDES), pumped storage hydropower (PSH), hydrogen storage, compressed gas storage -- **Fuel cells**: H2 PEM fuel cells -- **Industrial processes**: ammonia synthesis, iron ore reduction, steel production, and more +See the [Model Overview](./docs/user_guide/model_overview.md) for the current registered models and their categories. The [documentation](https://h2integrate.readthedocs.io/en/latest/intro.html) provides the project overview and links to model-specific guides. ## Getting Started diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 349f941f7..30b3ae111 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,8 +20,9 @@ GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submi * Keep style fixes to a separate commit to make your pull request more readable. * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). -* When you start working on a pull request, start by creating a new branch pointing at the latest - commit on [main](https://github.com/NatLabRockies/H2Integrate). +* Contributions are generally made against the `develop` branch. New models use the + [`@register` decorator](https://github.com/NatLabRockies/H2Integrate/blob/develop/h2integrate/core/supported_models.py); + see [Adding a New Technology](developer_guide/adding_a_new_technology.md) for the model requirements. * Code formatting is enforced using pre-commit hooks and is required for any code pushed up to the repository. The pre-commit package is included in the developer install of the repository. The pre-commit hooks can be installed by running ```bash pre-commit install @@ -76,22 +77,28 @@ jupytext notebook.md --to .ipynb ## Tests -The test suite can be run using `pytest tests/h2integrate`. Individual test files can be run by specifying them: +Run tests from the repository root in an environment with the development dependencies installed. +Every test must use one of the `unit`, `regression`, `integration`, or `hpc` markers. Collection +fails for unmarked tests. Common commands are: ```bash -pytest tests/h2integrate/test_hybrid.py +pytest -m unit +pytest -m regression +pytest -m integration +pytest -m hpc ``` -and individual tests can be run within those files +To run one test file or test function, provide its path and optional node ID: ```bash -pytest tests/h2integrate/test_hybrid.py::test_h2integrate_system +pytest h2integrate/core/test/test_utilities.py +pytest h2integrate/core/test/test_utilities.py::test_function_name ``` -When you push to your fork, or open a PR, your tests will be run against the -[Continuous Integration (CI)](https://github.com/NatLabRockies/H2Integrate/actions) suite. This will start a build -that runs all tests on your branch against multiple Python versions, and will also test -documentation builds. +See the [Testing Guide](developer_guide/testing.md) for test conventions, marker details, and +additional commands. Pull requests run the automated test suite through +[GitHub Actions](https://github.com/NatLabRockies/H2Integrate/actions). The documentation build is +also checked on every pull request by [Read the Docs](https://h2integrate.readthedocs.io/). ## Code Review Process diff --git a/docs/_config.yml b/docs/_config.yml index ba772c071..594721dd7 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,9 +1,9 @@ # Book settings # Learn more at https://jupyterbook.org/customize/config.html title: H2Integrate -author: National Renewable Energy Laboratory +author: NLR # logo: logo.png -copyright: '2025' +copyright: 2026, NLR only_build_toc_files: false # exclude_patterns: [_build, Thumbs.db, .DS_Store, "**.ipynb_checkpoints", .github] # Force re-execution of notebooks on each build. @@ -30,21 +30,30 @@ latex: repository: url: https://github.com/NatLabRockies/H2Integrate path_to_book: docs - branch: main + branch: develop # Add GitHub buttons to your book # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository html: use_issues_button: true use_repository_button: true - baseurl: https://nrel.github.io/H2Integrate/ + baseurl: https://h2integrate.readthedocs.io/en/latest/ sphinx: extra_extensions: - sphinx.ext.autodoc - sphinx.ext.autosummary - sphinx.ext.napoleon + - h2integrate.core.docs_sphinx config: language: python autosummary_generate: true + html_theme_options: + check_switcher: false + navbar_end: + - version-switcher + - navbar-icon-links + switcher: + json_url: https://h2integrate.readthedocs.io/en/latest/_static/switcher.json + version_match: latest autodoc_default_options: members: true member-order: bysource diff --git a/docs/_toc.yml b/docs/_toc.yml index aad60bffb..94562e793 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -125,10 +125,10 @@ parts: - file: developer_guide/class_structure - file: developer_guide/technology_model_types - file: developer_guide/testing - - file: developer_guide/features_not_currently_implemented - caption: Miscellaneous Resources chapters: - file: misc_resources/FAQ + - file: misc_resources/changelog - file: misc_resources/glossary - file: misc_resources/defining_filepaths - file: misc_resources/turbine_models_library_preprocessing diff --git a/docs/developer_guide/features_not_currently_implemented.md b/docs/developer_guide/features_not_currently_implemented.md deleted file mode 100644 index 71d899a1d..000000000 --- a/docs/developer_guide/features_not_currently_implemented.md +++ /dev/null @@ -1,11 +0,0 @@ -# Features not currently implemented - -There are many features that are not currently implemented in H2Integrate that are necessary for current or future projects. -These will become issues on the H2Integrate GitHub repo. -We are listing them here: - -- allowing different technologies within the same plant to be at different sites -- saving results elegantly for plant components so they do not always need to be rerun -- detailing the required inputs and outputs for different model types (e.g. electrolyzer) so that users can more easily add their own subsystems -- splitters and combiners for when resources are used or produced by multiple components -- simulating different timespans than one year at hourly timesteps (8760s) diff --git a/docs/developer_guide/why_make_h2integrate.md b/docs/developer_guide/why_make_h2integrate.md index fbe144930..54dfb9d22 100644 --- a/docs/developer_guide/why_make_h2integrate.md +++ b/docs/developer_guide/why_make_h2integrate.md @@ -1,53 +1,30 @@ -# Welcome to H2Integrate +# H2Integrate Design Rationale -This collection of docs is focused on presenting H2Integrate, acknowledging where we are now and where we want to be in the future. -Most of these docs are written in a way that we'll be able to reuse them immediately as proper docs in the actual H2Integrate repo. -For now, to reduce development overhead and encourage rapid iteration, we'll continue to work in this sandbox repo. +H2Integrate provides a framework for combining technology models into hybrid-system simulations, +technoeconomic analyses, and optimization studies. Its design emphasizes reusable model interfaces +and explicit connections between technologies. -Our goal with these doc pages is to communicate ideas that exist in our heads, put them to paper, and provide diagrams where helpful. -These docs are a work in progress; please ask questions about any and all parts presented here. +## Why modularity? -## Why make H2Integrate? - -H2Integrate has already proven itself useful as a tool that can analyze and design complex hybrid systems producing electricity, hydrogen, steel, and more. -However, these developments came in waves across multiple disparate projects, leading to a sometimes disjointed codebase. -In an effort to streamline and modularize H2Integrate to make it more effective and capable for necessary future studies, we are devoting time to redesigning it from the ground up. -This page discusses some of the high-level decisions and mindsets that we've used throughout this process. - -## What does it mean to "modularize" H2Integrate? - -Most of the current implementation of H2Integrate assumes that you are modeling a certain hybrid system architecture using a limited number of specific models. -As more projects call for using H2Integrate, we must make it easier to develop and add capability to the tool in a sustainable and clear way. -By making the internal framework of H2Integrate more agnostic to the technologies considered in the hybrid system design, we can allow for more user-defined modularized subsystems to be considered effectively. - -Getting into the details, this means creating generalized components that H2Integrate can expect to behave in a certain way. - -We introduce the ideas of "converters", which convert one resource into another. -Simple examples of converters include electrolyzers, wind turbines, solar PV panels, and more. -These converters can pass resources to other converters or "storage" components via "transporter" components. - -Transporters include hydrogen pipelines, electricity cables, or anything that transports a resource. -In a broad sense, these might include delivery trucks or shipping vessels, though those are more for future consideration. - -Storage components include batteries, hydrogen tanks; anything where you store a resource. - -By combining instances of these different generalized components, we can study distinct hybrid systems in H2Integrate. -Internally, the H2Integrate framework just needs to know if a something is a converter, transporter, storage, or some other type of component. -Additionally, this allows users to develop their own components using the expected interface, and add in custom subsystems to their hybrid plant. +Hybrid systems combine components with different physical behavior, cost assumptions, and operating +constraints. H2Integrate separates these concerns into model roles such as performance, cost, +finance, and control, and connects technologies through defined commodities and transport +components. Users can combine the registered models or add components that follow the framework's +interfaces. ## Why use OpenMDAO as the internal framework for H2Integrate? -Through a series of internal discussions, the H2Integrate dev team landed on using [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) for this new version of the tool. +H2Integrate uses [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) to assemble and +run its models. -Using OpenMDAO for this gives quite a few benefits: +OpenMDAO provides: - a proven framework for complex data-passing within multidisciplinary systems - automatically-generated visualization tools to help understand models and debug them - internal units handling and conversion - built-in nonlinear solvers to resolve model coupling - built-in optimization and parameter sweep drivers - multiple existing NLR tools use OpenMDAO, including [WISDEM](https://github.com/NLRWindSystems/WISDEM/) and [WEIS](https://github.com/NLRWindSystems/WEIS), so we can draw from institutional knowledge -- set up with gradient-based optimization in mind, which is not currently a focus for H2Integrate but this positions the tool well for potential future additions -- parallelization done using MPI, which is also not currently a focus but useful for the future +- gradient-based optimization and MPI-based parallelization capabilities for applicable studies However, there are a few downsides to using OpenMDAO: - an additional layer of code that developers must consider @@ -55,24 +32,15 @@ However, there are a few downsides to using OpenMDAO: - potentially increased computational costs depending on problem type and size - it isn't great at optimizing mixed-integer problems -The benefits outweighed the downsides, hence the team's choice to use OpenMDAO going forward. -Additionally, we can code H2Integrate in a way to minimize some of the potential issues, given that we're aware of them before refactoring H2Integrate. - -## How are generation technologies handled today? - -H2Integrate now models generation technologies directly with native technology components -for wind, solar, storage, wave, and tidal systems. -This keeps technology behavior, dispatch interfaces, and cost modeling within a single -framework, with all components exposed consistently to the same configuration and -optimization infrastructure. - -## Where should code I develop and implement live? +These benefits support H2Integrate's component-level modeling and optimization goals. Contributors +should still consider OpenMDAO's additional abstraction layer, longer error traces, possible +computational overhead, and limitations with mixed-integer optimization. -Historically, H2Integrate has been a sort of hybrid itself, where it contains both the tool itself as well as project-specific code. -This has led to a somewhat disjointed codebase that is difficult to maintain and understand. +## Where should code live? -To address this, here are guidelines for where code you develop should live: -- If the code is specific to a project, it should live in the project's repository. -- If the code is generalizable, could be used in multiple projects, or is a useful addition to the H2Integrate tool, it should live in the H2Integrate repository. -- Wrappers to models that are used in H2Integrate should live in the H2Integrate repository. -- The actual models (physics, costs, financials) should live **outside** the H2Integrate repository. Complex models might live in their own repo (e.g. BERT), whereas other models might live in the project's repository. +- Reusable technology models, cost and finance models, and integrations that benefit multiple + projects belong in the H2Integrate repository. +- Project-specific or proprietary models belong in the project repository. They can be integrated + by implementing the interfaces expected by H2Integrate. +- For the model interfaces and contribution workflow, see + [Adding a New Technology](adding_a_new_technology.md). diff --git a/docs/getting_started/environment_variables.md b/docs/getting_started/environment_variables.md index 21f94609d..e2559cfd4 100644 --- a/docs/getting_started/environment_variables.md +++ b/docs/getting_started/environment_variables.md @@ -93,11 +93,16 @@ unset NLR_API_EMAIL (set-environment-variables-with-env-file)= ## Set Environment Variables with .env file -The ".env" file will be looked for in all of the following locations: - - H2Integrate root directory (`/path/to/H2Integrate/h2integrate/`) - - parent of H2Integrate root directory (`/path/to/H2Integrate/`) (preferred location to store your environment file) - - current working directory (this is not a preferred location to store your environment file) -1. Choose which of the above directories you want to host your .env file, and create a file named ".env" in that folder. +H2Integrate searches for a `.env` file in these locations, in order: + +1. The H2Integrate package directory (for a source checkout, `/path/to/H2Integrate/h2integrate/`). +2. The package directory's parent (for a source checkout, `/path/to/H2Integrate/`). +3. The current working directory. +4. The user's home directory. + +For a source checkout, the repository root is a convenient location. For a pip installation, the +package directory's parent is usually inside `site-packages`; use the home directory or current +working directory instead. Create a file named `.env` in the directory you choose. 2. Open the ".env" file and add the environment variables: ```bash NLR_API_KEY='api-key-value' diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md index f78b34e84..cfec53dcc 100644 --- a/docs/getting_started/install.md +++ b/docs/getting_started/install.md @@ -8,6 +8,14 @@ If you just want to use H2Integrate and aren't developing new models, you should pip install h2integrate ``` +Pyomo is installed with H2Integrate, but solver software is installed separately. If you use a +Pyomo dispatch or optimization model, install at least one supported solver, such as GLPK, CBC, or +HiGHS. These are available from conda-forge: + +```bash +conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy +``` + ## Installing from Source If you want to develop new models or contribute to H2Integrate, you can install it from source. @@ -59,10 +67,10 @@ If you want to develop new models or contribute to H2Integrate, you can install 4. Create a conda environment and install H2Integrate and all its dependencies. Please read the following two notes about modified installation steps. - 1. If on a Unix machine, uncomment line 8 in `environment.yml` to install Cbc. Windows - users will need to manually install from . - 2. If you plan to use Ard, please uncomment line 9 in `environment.yml` to ensure WISDEM (an Ard - dependency) is installed from conda to avoid installation issues with some systems. + 1. The environment specification already installs GLPK, CBC, and HiGHS from conda-forge; no + solver lines need to be uncommented. + 2. If you plan to use Ard, uncomment the `wisdem` dependency in `environment.yml` so it is + installed from conda and avoids installation issues on some systems. ```bash conda env create -f environment.yml @@ -111,7 +119,7 @@ pip install -e ".[all]" 4. Install H2Integrate and its dependencies: ```bash - conda install -y -c conda-forge glpk coin-or-cbc>=2.10.12 highspy + conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy ``` - If you want to just use H2Integrate: diff --git a/docs/intro.md b/docs/intro.md index 0f84b4d33..2d1000c32 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -9,15 +9,18 @@ H2Integrate (H2I) is an open-source Python package for hybrid systems engineering design and technoeconomic analysis. It models and optimizes hybrid energy plants that produce electricity, hydrogen, ammonia, steel, and other products, using high-resolution location-specific resource data to perform optimization and scenario analysis. +## About H2Integrate + +H2Integrate grew from projects that needed to model and analyze complex hybrid energy systems. It is now maintained as a reusable framework for composing technology performance, cost, finance, and control models, while still allowing users to add project-specific components. + Browse the example workflows in the GitHub repository: https://github.com/NatLabRockies/H2Integrate/tree/main/examples ## What is H2Integrate? -H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for various hybrid systems. -The tool currently includes renewable energy generation (land-based wind, offshore wind, solar PV, wave, tidal), conventional generation (natural gas combined cycle, combustion turbines, grid electricity), hydrogen production (PEM electrolysis, NG-SMR), energy storage (Li-ion batteries, long-duration energy storage, pumped storage hydropower), fuel cells, and industrial processes (ammonia synthesis, iron ore reduction, steel production, methanol, and more). -Other elements can also be included as developed by users. -H2Integrate is continually expanding to serve additional hybrid applications -- if you're interested in seeing what's being actively developed, please see the [current pull requests in the GitHub repository](https://github.com/NatLabRockies/H2Integrate/pulls). -Some modeling capabilities in H2Integrate are provided by integrating existing tools, such as [PySAM](https://github.com/NatLabRockies/pysam), [ORBIT](https://github.com/NLRWindSystems/ORBIT), and [ProFAST](https://github.com/NatLabRockies/ProFAST). +H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for hybrid systems. +Its registered models cover land-based wind and solar PV; wave, tidal, run-of-river hydro, natural gas, nuclear, and grid electricity; hydrogen production and storage; fuel cells; and industrial processes including ammonia, methanol, iron, and steel. +The [Model Overview](user_guide/model_overview.md) lists the current registered models, and model-specific pages describe their capabilities and configuration. +Some models integrate existing tools, including [PySAM](https://github.com/NatLabRockies/pysam) and [ProFAST](https://github.com/NatLabRockies/ProFAST). The H2Integrate tool is built on top of [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/), which provides a powerful and flexible environment for modeling and optimization. ```{note} From 9dabc080d6276e522f06597a1b2da8ba22eba3c3 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:46 -0600 Subject: [PATCH 09/29] Added sphinx docs file --- h2integrate/core/docs_sphinx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 h2integrate/core/docs_sphinx.py diff --git a/h2integrate/core/docs_sphinx.py b/h2integrate/core/docs_sphinx.py new file mode 100644 index 000000000..a9b070ede --- /dev/null +++ b/h2integrate/core/docs_sphinx.py @@ -0,0 +1,23 @@ +import os +from importlib.metadata import version + +from sphinx.config import Config +from sphinx.application import Sphinx + + +def configure_version_switcher(app: Sphinx, config: Config) -> None: + readthedocs_version = os.environ.get("READTHEDOCS_VERSION", "latest") + if readthedocs_version == "develop": + readthedocs_version = "latest" + + theme_options = dict(config.html_theme_options or {}) + switcher_options = dict(theme_options.get("switcher", {})) + switcher_options["version_match"] = readthedocs_version + theme_options["switcher"] = switcher_options + theme_options["announcement"] = f"H2Integrate {version('h2integrate')} documentation" + config.html_theme_options = theme_options + + +def setup(app: Sphinx) -> dict[str, object]: + app.connect("config-inited", configure_version_switcher) + return {"version": "1.0", "parallel_read_safe": True} From a381dea60fcedc8822d56d032f31d7155ad18b16 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:46 -0600 Subject: [PATCH 10/29] Added sphinx docs file From 4b694a1df15b4a9ccb7b6c2eee0800af631665ad Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 12:20:51 -0600 Subject: [PATCH 11/29] Adding a bevy of new doc pages --- CHANGELOG.md | 1 + docs/_templates/autosummary/module.rst | 7 +- docs/_toc.yml | 9 ++ docs/api.md | 2 + .../pyomo_controllers.md | 26 +++- docs/misc_resources/changelog.md | 8 + docs/resource/solar_index.md | 4 + docs/resource/wind_index.md | 4 + docs/storage/storage_models_index.md | 29 ++++ docs/technology_models/ammonia.md | 18 +++ docs/technology_models/desalination.md | 21 +++ docs/technology_models/floris_wind_plant.md | 27 ++++ docs/technology_models/geologic_hydrogen.md | 5 + docs/technology_models/h2_htse.md | 3 + docs/technology_models/iron_transport.md | 31 ++++ docs/technology_models/methanol.md | 41 +++++ docs/technology_models/nitrogen_asu.md | 25 +++ docs/technology_models/nuclear.md | 5 + docs/technology_models/pem_electrolyzer.md | 55 +++++++ docs/technology_models/steel_eaf.md | 17 ++ docs/technology_models/tidal.md | 5 + docs/technology_models/transport.md | 123 +++++++++++++++ docs/user_guide/connecting_technologies.md | 145 +----------------- .../defining_sites_and_resources.md | 4 + docs/user_guide/driver_config_reference.md | 47 ++++++ docs/user_guide/plant_config_reference.md | 110 +++++++++++++ docs/user_guide/postprocessing_results.md | 82 ++++++++++ .../user_guide/technology_config_reference.md | 39 +++++ h2integrate/postprocess/mapping.py | 9 +- h2integrate/preprocess/populate_tech_yaml.py | 9 +- 30 files changed, 757 insertions(+), 154 deletions(-) create mode 100644 docs/misc_resources/changelog.md create mode 100644 docs/technology_models/desalination.md create mode 100644 docs/technology_models/floris_wind_plant.md create mode 100644 docs/technology_models/iron_transport.md create mode 100644 docs/technology_models/nitrogen_asu.md create mode 100644 docs/technology_models/pem_electrolyzer.md create mode 100644 docs/technology_models/transport.md create mode 100644 docs/user_guide/driver_config_reference.md create mode 100644 docs/user_guide/plant_config_reference.md create mode 100644 docs/user_guide/technology_config_reference.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0026f7110..290a1520c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added user references for PEM electrolysis, transport, FLORIS, configuration files, and additional registered models, with API and example links. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) - Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst index 53840f6d2..7e93ba723 100644 --- a/docs/_templates/autosummary/module.rst +++ b/docs/_templates/autosummary/module.rst @@ -47,11 +47,8 @@ {% endblock %} {% block modules %} -{# Filter out pytest conftest modules; they cannot be imported outside a - pytest session (they use `from test.conftest import ...` which requires - the repo-root ``test/`` package to be on ``sys.path``) and so autosummary - raises "failed to import" warnings on them. #} -{% set filtered_modules = modules | reject('equalto', 'conftest') | list %} +{# Exclude pytest modules from the generated public API tree. #} +{% set filtered_modules = modules | reject('equalto', 'conftest') | reject('equalto', 'test') | list %} {% if filtered_modules %} .. rubric:: Modules diff --git a/docs/_toc.yml b/docs/_toc.yml index 94562e793..37980b3d3 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -13,6 +13,9 @@ parts: - file: user_guide/model_overview - file: user_guide/model_registry - file: user_guide/how_to_set_up_an_analysis + - file: user_guide/plant_config_reference + - file: user_guide/driver_config_reference + - file: user_guide/technology_config_reference - file: user_guide/connecting_technologies - file: user_guide/defining_sites_and_resources - file: user_guide/populate_tech_yaml @@ -26,6 +29,7 @@ parts: - file: user_guide/plotting_geospatial_data_with_geopandas - caption: Technology Models chapters: + - file: technology_models/transport - file: technology_models/feedstocks - file: technology_models/marine_hydrokinetic - file: technology_models/run_of_river @@ -34,6 +38,8 @@ parts: - file: technology_models/natural_gas - file: technology_models/nuclear - file: technology_models/wombat_electrolyzer_om + - file: technology_models/pem_electrolyzer + - file: technology_models/floris_wind_plant - file: technology_models/pvwattsv8_solar_pv.md - file: technology_models/windpower_wind_plant.md - file: technology_models/atb_costs_pv.md @@ -41,6 +47,8 @@ parts: - file: technology_models/co2.md - file: technology_models/methanol.md - file: technology_models/ammonia.md + - file: technology_models/nitrogen_asu + - file: technology_models/desalination - file: technology_models/paper_mill.md - file: technology_models/saf.md - file: technology_models/ammonia_synloop_dynamics @@ -52,6 +60,7 @@ parts: - file: technology_models/h2_htse.md - file: technology_models/iron_ewin.md - file: technology_models/iron_mine.md + - file: technology_models/iron_transport - file: technology_models/iron_dri.md - file: technology_models/steel_eaf.md - file: technology_models/steel_eaf_cmu.md diff --git a/docs/api.md b/docs/api.md index 5a8f46847..9a566c10b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,5 +16,7 @@ storage transporters tools + preprocess + postprocess demand reliability diff --git a/docs/control/technology_level_control/pyomo_controllers.md b/docs/control/technology_level_control/pyomo_controllers.md index 68876420e..0c0f56bfa 100644 --- a/docs/control/technology_level_control/pyomo_controllers.md +++ b/docs/control/technology_level_control/pyomo_controllers.md @@ -77,7 +77,11 @@ The optimized dispatch method is specified by setting the storage control to `O The optimized dispatch using Pyomo is implemented differently than the heuristic dispatch in order to be able to properly aggregate the individual Pyomo technology models into a cohesive Pyomo plant model for the optimization solver. The Pyomo plant model is from the perspective of the storage technology and is meant to track inflows of commodities and other parameters that might impact the dispatch of the storage from upstream technologies. Practically, this means that the Pyomo elements of the dispatch (including the individual technology models and the plant model) are not exposed to the main H2I code flow, and do not appear in the N2 diagram. The figure below shows a flow diagram of how the dispatch is implemented. The green blocks below represent what is represented in the N2 diagram of the system. The dispatch routine is currently self-contained within the storage technology of the system, though it includes solving an aggregated plant model in the optimization -```{note} Only the PySAM battery performance model can call Pyomo dispatch at this time. +```{note} +Optimized dispatch passes a `pyomo_dispatch_solver` callable to a storage performance model. +`StoragePerformanceBase` provides this interface, so optimized dispatch is not inherently limited +to the PySAM battery. Confirm that the selected storage model and controller support the requested +dispatch mode. ``` ![](./figures/Pyomo_dispatch_figure.png) @@ -93,7 +97,25 @@ We have exposed the optimization cost (weighting) values to the user in this imp ``` For an example of how to use the optimized Pyomo control framework with the `OptimizedDispatchStorageController`, see -- `examples/30_pyomo_optimized_dispatch` +- [Example 30: Optimized Pyomo Dispatch](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/30_pyomo_optimized_dispatch) + +### Minimum operating-cost rules + +The optimized controller creates `PyomoDispatchGenericConverterMinOperatingCosts` blocks for +upstream converter technologies and a `PyomoRuleStorageMinOperatingCosts` block for the dispatching +storage technology. These registered Pyomo rule classes define the per-technology variables and +constraints used by the controller; the optimized controller constructs them internally rather than +requiring them to be added as performance models. + +```{eval-rst} +.. autoclass:: h2integrate.control.control_rules.converters.generic_converter_min_operating_cost.PyomoDispatchGenericConverterMinOperatingCosts + :members: initialize_parameters + :no-index: + +.. autoclass:: h2integrate.control.control_rules.storage.pyomo_storage_rule_min_operating_cost.PyomoRuleStorageMinOperatingCosts + :members: initialize_parameters + :no-index: +``` This controller only allows one incoming electricity stream and does not apply optimal dispatch of that stream back through the upstream technologies (no feedback). The dispatch can handle more than one generation technology, but the incoming electricity must be combined using an H2I combiner before going to the storage component, and the `cost_per_production`, which is defined in the storage technology section, needs to include the cost of production for all production technologies. This could be done using the following: diff --git a/docs/misc_resources/changelog.md b/docs/misc_resources/changelog.md new file mode 100644 index 000000000..40918aa10 --- /dev/null +++ b/docs/misc_resources/changelog.md @@ -0,0 +1,8 @@ +# Changelog + +The release and development history is maintained in the repository's +[CHANGELOG.md](https://github.com/NatLabRockies/H2Integrate/blob/develop/CHANGELOG.md). + +```{include} ../../CHANGELOG.md +:start-line: 2 +``` diff --git a/docs/resource/solar_index.md b/docs/resource/solar_index.md index 7d6d42be4..8bdfaa862 100644 --- a/docs/resource/solar_index.md +++ b/docs/resource/solar_index.md @@ -16,6 +16,10 @@ - "MeteosatPrimeMeridianTMYSolarAPI" - [OpenMeteo Historical API](#solar_resource:openmeteo_historical) +Some API resource models support multi-year simulations when the requested resource data covers +the configured time range. See the [Resource Model Index](resource_index.md) for details on +multi-year resource handling. + ```{note} Please refer to the [Setting Environment Variables](../getting_started/environment_variables) doc page for information on setting up an NLR API key if you haven't yet. ``` diff --git a/docs/resource/wind_index.md b/docs/resource/wind_index.md index 3f835285e..2e528571c 100644 --- a/docs/resource/wind_index.md +++ b/docs/resource/wind_index.md @@ -6,6 +6,10 @@ - "HRRRMETToolkitWindAPI" - [**"OpenMeteoHistoricalWindResource"**](wind_resource:openmeteo_archive) +Some API resource models support multi-year simulations when the requested resource data covers +the configured time range. See the [Resource Model Index](resource_index.md) for details on +multi-year resource handling. + ```{note} Please refer to the [Setting Environment Variables](../getting_started/environment_variables) doc page for information on setting up an NLR API key if you haven't yet. diff --git a/docs/storage/storage_models_index.md b/docs/storage/storage_models_index.md index 47baa086c..32958493d 100644 --- a/docs/storage/storage_models_index.md +++ b/docs/storage/storage_models_index.md @@ -11,6 +11,21 @@ H2I currently supports the following storage performance models: The following sections detail the inputs and outputs of the storage performance models. +### Automatic storage sizing + +`StorageAutoSizingModel` calculates the storage capacity and charge/discharge rate required to +balance a commodity production profile against demand, then simulates the storage operation. Set +`set_demand_as_avg_commodity_in: true` to use average input as demand, or set it to `false` and +provide `demand_profile`. The model requires either `round_trip_efficiency`, or both +`charge_efficiency` and `discharge_efficiency`. `min_soc_fraction` and `max_soc_fraction` bound the +usable state of charge. It supports one-hour time steps. + +```{eval-rst} +.. autoclass:: h2integrate.storage.simple_storage_auto_sizing.StorageSizingModelConfig + :members: + :no-index: +``` + ```{note} The inputs and outputs of storage performance models are generalized here for any commodity. If input and output names include the word `commodity`, the actual variable name would be the commodity defined for that storage model. For example, the `PySAMBatteryPerformanceModel` can only be used for the commodity `electricity`. Therefore, the `commodity_in` input to the `PySAMBatteryPerformanceModel` is actually named `electricity_in`. ``` @@ -61,3 +76,17 @@ The available storage cost models are: - `SaltCavernStorageCostModel` - `PipeStorageCostModel` - `MCHTOLStorageCostModel` + + ### ATB battery cost model + + `ATBBatteryCostModel` uses separate energy and power capital costs from the NLR Annual Technology + Baseline battery workbook. It derives storage duration from capacity and charge rate, calculates + CapEx from the energy and power cost inputs, and calculates annual OpEx as a fraction of CapEx. + `commodity_rate_units` must be a power unit based on watts; the default amount unit is the rate unit + multiplied by hours. + + ```{eval-rst} + .. autoclass:: h2integrate.storage.battery.atb_battery_cost.ATBBatteryCostConfig + :members: + :no-index: + ``` diff --git a/docs/technology_models/ammonia.md b/docs/technology_models/ammonia.md index 3ea56dfa1..2e3ee64d2 100644 --- a/docs/technology_models/ammonia.md +++ b/docs/technology_models/ammonia.md @@ -6,6 +6,24 @@ Ammonia is a common fertilizer, and also has the potential to serve as a lower-c 2. The synloop ammonia model accepts stream-table measurements (or modeled values) from an ammonia synthesis loop as performance parameters. Its registered models are `AmmoniaSynLoopPerformanceModel` in `h2integrate/converters/ammonia/ammonia_synloop_performance.py` and `AmmoniaSynLoopCostModel` in `h2integrate/converters/ammonia/ammonia_synloop_cost.py`. The example in `examples/12_ammonia_synloop/` uses mostly the same parameters as those used by Reznicek et al., but with updated capital costs for the air separator and synthesis loop derived from an NETL baseline study of ammonia production by [Brasington et al.](https://doi.org/10.2172/1515254). +## Simple ammonia configuration + +`SimpleAmmoniaPerformanceModel` uses an annual plant capacity and capacity factor to produce +ammonia. It accepts a hydrogen feed profile and reports ammonia production and capacity factor. +`SimpleAmmoniaCostModel` estimates capital and operating cost and includes oxygen by-product +credits. Both models use one-hour time steps and are demonstrated in +[Example 02: Texas Ammonia](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/02_texas_ammonia). + +```{eval-rst} +.. autoclass:: h2integrate.converters.ammonia.simple_ammonia_model.SimpleAmmoniaPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.ammonia.simple_ammonia_model.SimpleAmmoniaCostModelConfig + :members: + :no-index: +``` + ## Synloop purge gas output The Synloop Ammonia Model exposes the purge gas exiting the synthesis loop as a **multivariable stream** called `process_gas_mixture`. This is a general-purpose stream type for process gas mixtures that can be reused by other components. It bundles seven constituent variables into a single connection type: diff --git a/docs/technology_models/desalination.md b/docs/technology_models/desalination.md new file mode 100644 index 000000000..825b7d6b1 --- /dev/null +++ b/docs/technology_models/desalination.md @@ -0,0 +1,21 @@ +# Reverse Osmosis Desalination + +`ReverseOsmosisPerformanceModel` represents a fixed-capacity reverse osmosis plant. Configure its +freshwater capacity in kg/h and choose `salinity: seawater` or `salinity: brackish`. The model +reports freshwater as `water_out` in m^3/h, along with `feedwater` and electricity demand. Its +current simplified assumptions use 50% recovery and 4 kWh/m^3 for seawater, and 75% recovery and +1.5 kWh/m^3 for brackish water. + +`ReverseOsmosisCostModel` estimates capital and operating costs from the freshwater capacity using +the model's reference costs. Its cost configuration is fixed to 2013 USD. Both models require +hourly time steps. + +```{eval-rst} +.. autoclass:: h2integrate.converters.water.desal.desalination.ReverseOsmosisPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.water.desal.desalination.ReverseOsmosisCostModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md new file mode 100644 index 000000000..875f65acd --- /dev/null +++ b/docs/technology_models/floris_wind_plant.md @@ -0,0 +1,27 @@ +# FLORIS Wind Plant + +`FlorisWindPlantPerformanceModel` wraps the FLORIS wake model to calculate wind-farm electrical +output from wind resource data and turbine configuration. It supports a single turbine design per +farm and uses one-hour time steps. + +The model requires a FLORIS wake configuration and a turbine configuration formatted for FLORIS. +The configurations can be included from YAML files as in [Example 26](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/26_floris). +The `layout` configuration selects a layout generator and its options; do not also set turbine +coordinates in `floris_wake_config.farm.layout_x` or `layout_y`, because the layout settings create +those coordinates. Multiple turbine designs in the same farm are not currently supported. + +Resource data is supplied through the plant's site resource configuration and +`site_to_tech_connections`. The model selects or averages the resource heights around the turbine +hub height using `resource_data_averaging_method` (`weighted_average`, `average`, or `nearest`). +Turbulence intensity can be taken from the resource data or supplied as a default. + +FLORIS calculations can be expensive, so caching is enabled by default. `cache_dir` selects the +cache location when caching is enabled. Cache entries are based on the model configuration and +simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse +is not desired. + +```{eval-rst} +.. autoclass:: h2integrate.converters.wind.floris.FlorisWindPlantPerformanceModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/geologic_hydrogen.md b/docs/technology_models/geologic_hydrogen.md index cbf02e677..bc22fd5e7 100644 --- a/docs/technology_models/geologic_hydrogen.md +++ b/docs/technology_models/geologic_hydrogen.md @@ -64,3 +64,8 @@ The modeling approach in this performance model is based on: The modeling approach in this cost model is based on: - Mathur et al. (Stanford): + +## Example + +[Example 04: Geologic Hydrogen](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/04_geo_h2) +contains separate configurations for natural and stimulated subsurface hydrogen models. diff --git a/docs/technology_models/h2_htse.md b/docs/technology_models/h2_htse.md index 764431541..ef8c9ca7d 100644 --- a/docs/technology_models/h2_htse.md +++ b/docs/technology_models/h2_htse.md @@ -91,6 +91,9 @@ The current implementation is intentionally simple and should be interpreted as ### API details For API details, see the [`HTSEPerformanceModel` and `HTSECostModel` API documentation](../_autosummary/h2integrate.converters.hydrogen.htse_electrolyzer). +The nuclear heat and electricity integration is demonstrated in +[Example 36: Nuclear Reactor and HTSE](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/36_nuclear_reactor_htse). + ## Cost Model To use this model, in your `tech_config.yaml` file, set: diff --git a/docs/technology_models/iron_transport.md b/docs/technology_models/iron_transport.md new file mode 100644 index 000000000..307c8b844 --- /dev/null +++ b/docs/technology_models/iron_transport.md @@ -0,0 +1,31 @@ +# Iron Transport Models + +`IronTransportPerformanceComponent` estimates the land and water distances needed to ship iron ore +to a Great Lakes port. It reads the mine location from `plant_config.sites.site` and uses built-in +port coordinates and shipping waypoints. Set `find_closest_ship_site: true` and +`shipment_site: "None"` to select the shortest modeled route, or set `find_closest_ship_site: false` +and choose `Duluth`, `Chicago`, `Cleveland`, or `Buffalo` explicitly. + +The performance component reports `land_transport_distance`, `water_transport_distance`, and +`total_transport_distance` in kilometers. `IronTransportCostComponent` uses these distances and the +`iron_ore_in` profile to calculate transport cost outputs. Its `transport_year` and `marginal_cost` +inputs are configured in the technology's cost parameters; the cost year is set from the plant's +`target_dollar_year`. + +Both models require hourly time steps. See the iron DRI and mine documentation for their use in +complete flowsheets. + +```{eval-rst} +.. autoclass:: h2integrate.converters.iron.iron_transport.IronTransportPerformanceComponentConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.iron.iron_transport.IronTransportCostComponentConfig + :members: + :no-index: +``` + +## Examples + +The model is used by the iron mine, DRI, electrowinning, and mapping configurations in +[Example 21](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/21_iron_examples). diff --git a/docs/technology_models/methanol.md b/docs/technology_models/methanol.md index 86fee1ef8..4be885adf 100644 --- a/docs/technology_models/methanol.md +++ b/docs/technology_models/methanol.md @@ -10,3 +10,44 @@ Methanol is an essential precursor to many chemicals, and is also used as a fuel - [Nyari et al.](https://doi.org/10.1016/j.jcou.2020.101166) This modeling is further documented in a journal publication by [Martin et al.](https://doi.org/10.1021/acs.est.4c02589) + +## Registered models and configuration + +| Route | Performance model | Cost model | Finance model | +|---|---|---|---| +| Steam methane reforming | `SMRMethanolPlantPerformanceModel` | `SMRMethanolPlantCostModel` | `SMRMethanolPlantFinanceModel` | +| CO2 hydrogenation | `CO2HMethanolPlantPerformanceModel` | `CO2HMethanolPlantCostModel` | `CO2HMethanolPlantFinanceModel` | + +Both performance models use hourly time steps and ratio-based feedstock calculations, limited by +the configured `plant_capacity_kgpy` and available inputs. The SMR route consumes natural gas and +produces electricity as a co-product. The CO2 hydrogenation route consumes hydrogen, carbon +dioxide, natural gas, and electricity. Both report methanol production, emissions, and water +consumption. The cost models use total overnight, fixed operating, and variable operating cost +inputs, then calculate route-specific feedstock costs and credits. The finance models report +levelized cost of methanol. + +```{eval-rst} +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.smr_methanol_plant.SMRMethanolPlantFinanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.methanol.co2h_methanol_plant.CO2HMethanolPlantFinanceModelConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/nitrogen_asu.md b/docs/technology_models/nitrogen_asu.md new file mode 100644 index 000000000..b4a620b77 --- /dev/null +++ b/docs/technology_models/nitrogen_asu.md @@ -0,0 +1,25 @@ +# Nitrogen Air Separation Unit + +`SimpleASUPerformanceModel` is a linear model for producing nitrogen from air. It can size from a +configured nitrogen demand, or use a rated nitrogen production or electrical power input. It also +calculates air feed, oxygen and argon co-products, and electricity use. Its `efficiency_kWh_pr_kg_N2` +is expressed in kWh per kg of nitrogen; the model documentation gives representative values of +0.119 for cryogenic separation and 0.29 for pressure swing adsorption. + +With `size_from_N2_demand: true`, connect `nitrogen_in` as the demand profile and the model reports +the required `electricity_in`. With it set to `false`, provide `electricity_in` and specify either +`rated_N2_kg_pr_hr` or `ASU_rated_power_kW`. The model supports one-hour time steps. + +`SimpleASUCostModel` calculates capital and operating expenses using a cost per nitrogen capacity +or a cost per power capacity. `capex_unit` must identify the basis; when `opex_usd_per_unit_per_year` +is nonzero, set `opex_unit` as well. + +```{eval-rst} +.. autoclass:: h2integrate.converters.nitrogen.simple_ASU.SimpleASUPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.nitrogen.simple_ASU.SimpleASUCostConfig + :members: + :no-index: +``` diff --git a/docs/technology_models/nuclear.md b/docs/technology_models/nuclear.md index edb20a264..1854db931 100644 --- a/docs/technology_models/nuclear.md +++ b/docs/technology_models/nuclear.md @@ -70,3 +70,8 @@ $$ ### API details For API details, see the [`SimpleThermalNuclearReactorPerformanceModel` and `SimpleThermalNuclearReactorCostModel` API documentation](../_autosummary/h2integrate.converters.nuclear.nuclear_plant_thermal). + +## Integrated nuclear and HTSE example + +[Example 36: Nuclear Reactor and HTSE](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/36_nuclear_reactor_htse) +connects thermal nuclear output to the high-temperature steam electrolyzer. diff --git a/docs/technology_models/pem_electrolyzer.md b/docs/technology_models/pem_electrolyzer.md new file mode 100644 index 000000000..b9dbeae93 --- /dev/null +++ b/docs/technology_models/pem_electrolyzer.md @@ -0,0 +1,55 @@ +# PEM Electrolyzer + +H2Integrate's `ECOElectrolyzerPerformanceModel` wraps the ECO-PEM model to convert electricity +into hydrogen and oxygen. It models electrolyzer clusters, turndown, degradation, and replacement +timing. Use it when an analysis needs an electrolyzer model with time-varying operation rather than +a fixed-rate conversion. + +## Registered models + +- `ECOElectrolyzerPerformanceModel` computes hydrogen and oxygen production, electricity and water + consumption, capacity factor, and the replacement schedule. +- `BasicElectrolyzerCostModel`, `SingliticoCostModel`, and `CustomElectrolyzerCostModel` provide + alternative capital-cost models. Choose the cost model whose assumptions and input data fit the + analysis; their configuration classes are documented below. + +The performance model requires a one-hour time step. `size_mode: normal` uses the configured +cluster count and rating. `resize_by_max_feedstock` can size from the maximum available electricity, +and `resize_by_max_commodity` can size from a connected hydrogen demand. The resize modes also +require `flow_used_for_sizing`; see [Sizing Modes](../user_guide/run_size_modes.md) for the +framework-level behavior. + +## Configuration + +The performance configuration controls cluster count and rating, onshore/offshore location, +turndown, degradation penalties, and operating hours to end of life. Cost inputs depend on the +selected cost model. + +```{eval-rst} +.. autoclass:: h2integrate.converters.hydrogen.pem_electrolyzer.ECOElectrolyzerPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.basic_cost_model.BasicElectrolyzerCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.singlitico_cost_model.SingliticoCostModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.hydrogen.custom_electrolyzer_cost_model.CustomElectrolyzerCostModelConfig + :members: + :no-index: +``` + +## Examples + +- [Example 25: Sizing Modes](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/25_sizing_modes) + demonstrates the electrolyzer's sizing modes. +- [Example 01: Onshore Steel](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/01_onshore_steel_mn) + and [Example 13: Dispatch for Electrolyzer](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/13_dispatch_for_electrolyzer) + show the model in larger systems. + +The separate [WOMBAT electrolyzer operations model](wombat_electrolyzer_om.md) can be used when +maintenance and downtime behavior are important. diff --git a/docs/technology_models/steel_eaf.md b/docs/technology_models/steel_eaf.md index 2f7d25967..9b050b90f 100644 --- a/docs/technology_models/steel_eaf.md +++ b/docs/technology_models/steel_eaf.md @@ -17,6 +17,23 @@ The models implemented in H2I are: - `HydrogenEAFPlantPerformanceComponent` - `HydrogenEAFPlantCostComponent` +## Simple steel plant + +The separate `SteelPerformanceModel` represents annual steel production using a configured plant +capacity and capacity factor. `SteelCostAndFinancialModel` calculates capital and operating costs +and reports levelized cost of steel (`LCOS`). Both use hourly time steps. These models are distinct +from the natural-gas and hydrogen EAF performance components described above. + +```{eval-rst} +.. autoclass:: h2integrate.converters.steel.steel.SteelPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.converters.steel.steel.SteelCostAndFinancialModelConfig + :members: + :no-index: +``` + ```{note} The EAF model use sponge iron as an input rather than pig iron, which is lower in carbon content. The LBNL model calls the input to the EAF pig iron, but that's typically produced using a blast furnace rather than through the DRI process and has higher carbon impurities. ``` diff --git a/docs/technology_models/tidal.md b/docs/technology_models/tidal.md index d75dd5505..9ad77dc7d 100644 --- a/docs/technology_models/tidal.md +++ b/docs/technology_models/tidal.md @@ -48,3 +48,8 @@ where: This allows a generic power curve to be adapted to different device ratings. + +## Example + +[Example 31: Tidal](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/31_tidal) +shows the tidal resource, plant, and driver configurations. diff --git a/docs/technology_models/transport.md b/docs/technology_models/transport.md new file mode 100644 index 000000000..2247681d2 --- /dev/null +++ b/docs/technology_models/transport.md @@ -0,0 +1,123 @@ +# Transport Components + +Transport components connect technologies in `plant_config.yaml` through +`technology_interconnections`. The built-in cable, pipe, and generic transporter models pass their +input commodity profile through without losses. They do not model transport delays, pressure drop, +or other physical constraints. + +## Cables and pipes + +The registered transport types `cable` and `pipe` are selected in a plant interconnection, for +example: + +```yaml +technology_interconnections: + - [wind, battery, electricity, cable] + - [hydrogen_producer, hydrogen_storage, hydrogen, pipe] +``` + +`cable` transports electricity. `pipe` supports hydrogen, carbon dioxide, methanol, ammonia, +nitrogen, natural gas, wellhead gas, water, oxygen, lignin, and diesel. Both are no-cost +pass-through components; add a separate cost model if transport cost is needed. + +`GenericTransporterPerformanceModel` is a configurable pass-through for a commodity and its rate +units. It supports time steps from 1 second to $10^9$ seconds, unlike several production models +that require hourly simulation. + +## Combiner + +`GenericCombinerPerformanceModel` sums one commodity from multiple sources into one output. It +accepts `commodity`, `commodity_rate_units`, and optional `in_streams` (default 2). Set +`in_streams` to the number of incoming connections when combining more than two sources. + +```yaml +technologies: + electricity_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 +``` + +The combined commodity profile and rated production are sums of the input streams. The output +capacity factor is a rated-production-weighted average; it is zero when total rated production is +zero. Each input must provide its commodity profile, rated production, and capacity factor. + +## Splitter + +`GenericSplitterPerformanceModel` takes one input and produces exactly two outputs. `out1` is the +priority branch; `out2` receives the remainder. Configure `commodity` and `commodity_rate_units` +under `model_inputs.performance_parameters`, together with one of these modes: + +- `split_mode: fraction` requires `fraction_to_priority_tech`, between 0 and 1. `out1` receives that + fraction of the input, and `out2` receives the rest. +- `split_mode: prescribed_commodity` requires `prescribed_commodity_to_priority_tech`, in the + configured commodity rate units. `out1` receives the requested amount, limited to the available + nonnegative input; `out2` receives the remainder. + +```yaml +technologies: + electricity_splitter: + performance_model: + model: GenericSplitterPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + split_mode: fraction + fraction_to_priority_tech: 0.7 +``` + +Connect the two downstream technologies to the splitter in the same order as the intended `out1` +and `out2` branches. The splitter does not combine sources; use a combiner upstream when multiple +technologies supply its input. + +## Multivariable gas streams + +`GasStreamCombinerPerformanceModel` combines supported multivariable gas streams. It sums each +stream's mass flow and computes mass-weighted averages for intensive properties such as temperature, +pressure, and composition. Set `commodity` and `in_streams` in its performance parameters. The +connection syntax and stream variable names are covered in +{ref}`Connecting Technologies `. + +## Transport cost models + + coordinates, then multiplies it by `capex_per_km` and `fixed_opex_per_km`. Connect site latitude + and longitude to its `source_*` and `dest_*` inputs. +- `LinearMassTransportCostModel` uses geodesic distance, an optional `circuity_ratio` (default 1), + and annual commodity throughput. Its input costs are `capex_per_mton_km` and + `fixed_opex_per_mton_km`. + +```{eval-rst} +.. autoclass:: h2integrate.transporters.generic_transporter.GenericTransporterPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.generic_combiner.GenericCombinerPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.generic_splitter.GenericSplitterPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.gas_stream_combiner.GasStreamCombinerPerformanceModelConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.linear_transport_cost.LinearTransportCostConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.linear_mass_transport_cost.LinearMassTransportCostConfig + :members: + :no-index: +``` + +## Examples + +- [Example 17: Splitter, wind, and direct ocean capture](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2) +- [Example 32: Multivariable gas streams](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/32_multivariable_streams) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index cdfc1574e..3b86f592e 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -223,144 +223,9 @@ For the splitter outputs, variables are indexed as `wellhead_gas_mixture:_in`: Time-series commodity profile from source *i* (commodity units) - - `rated__production`: Rated (nameplate) production of source *i* (commodity units, scalar) - - `_capacity_factor`: Annual capacity factor of source *i* (unitless, shape = plant life) - -- **Outputs**: - - `_out`: Combined commodity time-series profile — element-wise sum of all inputs - - `rated__production`: Total rated production — sum of all input rated productions - - `capacity_factor`: Combined capacity factor (unitless, shape = plant life) - -### Commodity output - -The combined output profile is the element-wise sum of all input profiles: - -`_out = _in1 + _in2 + …` - -### Capacity factor calculation - -The combined capacity factor is a **weighted average** of the input capacity factors, weighted by each stream's rated production: - -`CF_out = (CF1 × S1 + CF2 × S2 + …) / (S1 + S2 + …)` - -where `CF_i` is the capacity factor and `S_i` is the rated commodity production of input stream *i*. This weighting ensures that larger sources contribute proportionally more to the combined capacity factor. If the total rated production is zero, the output capacity factor is set to zero. - -### Usage example - -```yaml -technology_interconnections: [ - ["wind_farm", "combiner", "electricity", "cable"], - ["solar_farm", "combiner", "electricity", "cable"], - ["combiner", "electrolyzer", "electricity", "cable"], -] -``` - -This configuration combines wind and solar power before sending it to an electrolyzer. - -## Generic splitter - -The generic splitter takes one commodity stream from a single source and splits it between two outputs. It offers two operating modes; the examples below use electricity. - -### Configuration - -Add the splitter to your `tech_config.yaml`: - -```yaml -technologies: - electricity_splitter: - performance_model: - model: "GenericSplitterPerformanceModel" - model_inputs: - performance_parameters: - commodity: "electricity" - commodity_rate_units: "kW" - split_mode: "fraction" # or "prescribed_commodity" - fraction_to_priority_tech: 0.7 -``` - -### Split modes - -#### Fraction mode (`split_mode: "fraction"`) - -In fraction mode, you specify what fraction of the input commodity goes to the first technology: - -```yaml -config: - split_mode: "fraction" - fraction_to_priority_tech: 0.3 # 30% to first tech, 70% to second -``` - -- **Input parameter**: `fraction_to_priority_tech` (0.0 to 1.0) -- **Behavior**: - - `electricity_out1 = electricity_in × fraction` - - `electricity_out2 = electricity_in × (1 - fraction)` - -#### Prescribed commodity mode (`split_mode: "prescribed_commodity"`) - -In prescribed commodity mode, you specify an amount of the commodity to send to the first technology: - -```yaml -performance_parameters: - split_mode: "prescribed_commodity" - prescribed_commodity_to_priority_tech: 200.0 # 200 kW to first tech -``` - -- **Input parameter**: `prescribed_commodity_to_priority_tech` (in `commodity_rate_units`) -- **Behavior**: - - `electricity_out1 = min(prescribed_amount, available_power)` - - `electricity_out2 = electricity_in - electricity_out1` -- **Smart limiting**: If you request more power than available, the first output gets all available power and the second output gets zero - -### Inputs and outputs - -- **Input**: - - `electricity_in`: Total power input (kW) - - Mode-specific input: `fraction_to_priority_tech` or `prescribed_commodity_to_priority_tech` -- **Outputs**: - - `electricity_out1`: Commodity sent to the priority technology (kW) - - `electricity_out2`: Remaining commodity sent to the other technology (kW) - - `electricity_consumed`: Total commodity consumed from the input (kW) - -### Usage example - -```yaml -technology_interconnections: [ - ["offshore_wind", "electricity_splitter", "electricity", "cable"], - ["electricity_splitter", "doc", "electricity", "cable"], # first output - ["electricity_splitter", "electrolyzer", "electricity", "cable"], # second output -] -``` - -This sends part of the offshore wind power to a direct ocean capture system and the remainder to an electrolyzer. - -```{note} -Each splitter has one input stream and exactly two output streams. Add a combiner upstream when the splitter must receive multiple sources. -``` +The `cable`, `pipe`, generic transporter, combiner, and splitter models are documented in +[Transport Components](../technology_models/transport.md). This page focuses on how to express +their connections in `technology_interconnections`. diff --git a/docs/user_guide/defining_sites_and_resources.md b/docs/user_guide/defining_sites_and_resources.md index fd118b956..2a9079f3a 100644 --- a/docs/user_guide/defining_sites_and_resources.md +++ b/docs/user_guide/defining_sites_and_resources.md @@ -23,6 +23,10 @@ sites: Further information on the available resource models can be found [here](https://h2integrate.readthedocs.io/en/latest/resource/resource_index.html) +Some API resource models support simulations spanning multiple years when the requested data and +simulation time range are compatible. See the [Resource Model Index](../resource/resource_index.md) +for details, and set `plant.simulation.n_timesteps` and `dt` to match the desired simulation range. + ## Site-to-resource connections overview The `site_to_tech_connections` section in your plant configuration file defines how technologies are connected to site data and resource outputs for that site. This includes resource data such as wind or solar time series and site parameters such as latitude and longitude. diff --git a/docs/user_guide/driver_config_reference.md b/docs/user_guide/driver_config_reference.md new file mode 100644 index 000000000..f70a5a154 --- /dev/null +++ b/docs/user_guide/driver_config_reference.md @@ -0,0 +1,47 @@ +# Driver Configuration Reference + +The driver configuration controls output files, optional OpenMDAO reports, analysis drivers, +design variables, objectives, constraints, and recording. It is referenced from the top-level +configuration with `driver_config`. + +## General options + +| Field | Default | Purpose | +|---|---:|---| +| `general.folder_output` | `output` | Directory for generated output and recorder files. | +| `general.create_om_reports` | `true` | Create OpenMDAO reports, including N2 diagrams. Set to `false` to disable them. | + +## Analysis driver + +The `driver` mapping may contain `optimization` or `parameter_sweep`. At most one driver's `flag` +can be true. If neither is enabled, the model runs once using the configured values. Optimization +settings include the solver, tolerance, iteration limit, derivative options, and solver-specific +options. The `parameter_sweep` block selects a generator and its inputs, such as `num_samples`, +`levels`, or a CSV `filename`. See [Design Optimization](design_optimization_in_h2i.md) and +[Parameter Sweeps](parameter_sweep_in_h2i.md) for workflows and examples. + +## Design variables, objective, and constraints + +`design_variables` and `constraints` are grouped by technology or finance-subgroup name, then by +OpenMDAO variable name. Set `flag` to enable each entry and provide its `units`; design variables +may also specify bounds and scaling, while constraints require at least one of `lower`, `upper`, or +`equals`. The single `objective` identifies the full OpenMDAO variable name to minimize. To +maximize a result, minimize its negative using the objective's `ref` scaling as appropriate. + +## Recorder + +Set `recorder.flag` to enable an SQL recorder. The `file` name is written inside +`general.folder_output`; `overwrite_recorder` controls whether an existing file is replaced or a +unique suffix is used. `recorder_attachment` may be `driver` or `model` (the driver is recommended +for parallel runs). + +| Field | Purpose | +|---|---| +| `includes` | Glob patterns selecting variables to record. Defaults to `['*']`. | +| `excludes` | Glob patterns to omit. Defaults to `['*resource_data']`, which avoids storing large, constant resource profiles. | +| `record_inputs`, `record_outputs`, `record_residuals` | Select component-level data. | +| `options_excludes` | Component option names to omit when attached to the model. | +| `record_desvars`, `record_objectives`, `record_constraints`, `record_derivative` | Select driver data; used when attached to the driver. | + +The schema is maintained in [`driver_schema.yaml`](../../h2integrate/core/inputs/driver_schema.yaml). +See [Recording and Loading Data](recording_and_loading_data.md) for working recorder examples. diff --git a/docs/user_guide/plant_config_reference.md b/docs/user_guide/plant_config_reference.md new file mode 100644 index 000000000..2b4e6256c --- /dev/null +++ b/docs/user_guide/plant_config_reference.md @@ -0,0 +1,110 @@ +# Plant Configuration Reference + +The plant configuration describes site locations, simulation time, technology connections, and +system-level finance. It is referenced from the top-level configuration with `plant_config`. See +[Defining Sites and Connecting Resources](defining_sites_and_resources.md) for resource-specific +examples, and the [Technology Configuration Reference](technology_config_reference.md) and +[Driver Configuration Reference](driver_config_reference.md) for the other input files. + +## Top-level fields + +| Field | Purpose | +|---|---| +| `name`, `description` | Identify and describe the plant configuration. | +| `sites` | Named sites with latitude, longitude, optional elevation, and named resource models. | +| `plant` | Plant lifetime and simulation time settings. | +| `technology_interconnections` | Connections between technologies and optional transport components. | +| `site_to_tech_connections` | Connections from site data or resource outputs to technology inputs. | +| `tech_to_dispatch_connections` | Connections from technologies to dispatch rules or dispatch controllers. | +| `system_level_control` | Optional system-level controller configuration. | +| `finance_parameters` | Finance groups, subgroups, and cost-year adjustment settings. | + +The plant schema currently validates the fields documented in +[`plant_schema.yaml`](../../h2integrate/core/inputs/plant_schema.yaml). The model also reads +`tech_to_dispatch_connections` and `system_level_control`, but these two keys are not currently +declared in that schema; they are therefore code-supported configuration fields that schema +validation does not check. + +## Sites + +Each site is identified by a user-chosen key. Latitude and longitude are required; elevation is +optional. A site's `resources` mapping may contain multiple named resource models. Each resource +entry specifies a registered `resource_model` and its `resource_parameters`; the resource key is a +user-chosen name, not the model class name. Use `site_to_tech_connections` to connect site location +or resource data to technologies. + +Resource models have model-specific data ranges and time-step requirements. Some API-backed wind +and solar resources support multiple years; see the [resource index](../resource/resource_index.md) +and the wind and solar resource pages. + +## Plant lifetime and simulation + +`plant.plant_life` is the project lifetime in years. `plant.simulation` controls the simulated +period and uses seconds for `dt`: + +```yaml +plant: + plant_life: 30 + simulation: + dt: 3600 + n_timesteps: 8760 + start_time: "01/01 00:30:00" + timezone: 0 +``` + +`dt` and `n_timesteps` are required by the schema. The default `dt` is 3600 seconds and the +default `n_timesteps` is 8760. `start_time` and `timezone` are optional; timezone is the UTC offset +for the start time. Every performance model in the system must support the selected `dt`, and the +requested simulation period must be compatible with the connected resource data. + +## Technology and site connections + +`technology_interconnections` lists source, destination, commodity or stream, and optionally a +transporter. For example, `- [wind, battery, electricity, cable]` connects wind electricity to a +battery through a cable. See [Connecting Technologies](connecting_technologies.md) for connection +forms and [Transport Components](../technology_models/transport.md) for cable, pipe, combiner, and +splitter behavior. + +`site_to_tech_connections` connects a named site or resource to a technology. Resource data is +typically connected with a three-element entry such as +`[site.wind_resource, wind, wind_resource_data]`; site coordinates can also be mapped to technology +inputs. The site guide covers the supported forms and resource variable names. + +`tech_to_dispatch_connections` is used when dispatch rules need information from technologies +upstream of a dispatch controller. Each pair identifies a source technology and the technology +whose dispatch model consumes it. For example, optimized battery dispatch commonly connects both +the upstream generator and battery to the battery controller: + +```yaml +tech_to_dispatch_connections: + - [wind, battery] + - [battery, battery] +``` + +Declare the corresponding rule models in `tech_config.yaml`; the +[Pyomo Controllers](../control/technology_level_control/pyomo_controllers.md) guide explains the +heuristic and optimized patterns. + +## System-level control + +When present, `system_level_control` enables a plant-level controller. The strategy-specific +parameters are documented with each controller in the [System-Level Control](../control/system_level_control/system_level_control.md) +guide. For example, a demand-following configuration identifies the strategy and demand technology: + +```yaml +system_level_control: + control_strategy: DemandFollowingControl + demand_component: electrical_load_demand + solver_options: + solver_name: gauss_seidel + max_iter: 20 + convergence_tolerance: 1.0e-6 +``` + +## Finance parameters + +`finance_parameters` contains `finance_groups`, `finance_subgroups`, and +`cost_adjustment_parameters`. Groups define finance models and their inputs; subgroups select +technologies, a commodity stream, and one or more groups for an analysis. Cost adjustment converts +model costs to `target_dollar_year` using `cost_year_adjustment_inflation`. See +[Specifying Finance Parameters](specifying_finance_parameters.md) for the finance-group structure. diff --git a/docs/user_guide/postprocessing_results.md b/docs/user_guide/postprocessing_results.md index 11b0ad922..27b25cd07 100644 --- a/docs/user_guide/postprocessing_results.md +++ b/docs/user_guide/postprocessing_results.md @@ -191,3 +191,85 @@ Pass the base name and the function handles the rest: # Aggregates cases.sql_0, cases.sql_1, ... into a single DataFrame summary_df = convert_sql_to_csv_summary("output_dir/cases.sql") ``` + +## Printing model results + +`print_results` prints a hierarchical summary of an OpenMDAO model's inputs and outputs and returns +a dictionary containing input, explicit-output, and implicit-output metadata. The `includes` and +`excludes` arguments accept OpenMDAO variable patterns; `show_units` controls whether units are +included in the report and returned metadata. + +```python +from h2integrate.postprocess.reporting import print_results + +model.run() +result_summary = print_results( + model.prob.model, + includes="*.hydrogen_out", + excludes="*resource_data", + show_units=True, +) +``` + +The returned mapping has `inputs`, `explicit_outputs`, and `implicit_outputs` entries. + +```{eval-rst} +.. autofunction:: h2integrate.postprocess.reporting.print_results + :no-index: +``` + +## Plotting energy and hydrogen flows + +The plotting helpers in `h2integrate.tools.plot` read the production `energy_flows.csv` file. They +accept start and end datetimes, a save path, and flags to display or save the figure. The default +input path is `./output/data/production/energy_flows.csv`. + +```python +import datetime as dt + +from h2integrate.tools.plot import plot_energy_flows, plot_hydrogen_flows + +plot_energy_flows( + energy_flow_data_path="output/data/production/energy_flows.csv", + start_date_time=dt.datetime(2024, 1, 5), + end_date_time=dt.datetime(2024, 1, 10), + show_fig=False, +) +plot_hydrogen_flows( + energy_flow_data_path="output/data/production/energy_flows.csv", + show_fig=False, +) +``` + +`plot_energy_flows` plots electricity generation and battery charge/discharge. +`plot_hydrogen_flows` plots hydrogen production, demand, and storage state of charge. The CSV must +contain the columns expected by the selected plotting function. + +```{eval-rst} +.. autofunction:: h2integrate.tools.plot.plot_energy_flows + :no-index: + +.. autofunction:: h2integrate.tools.plot.plot_hydrogen_flows + :no-index: +``` + +## ProFAST helpers + +The ProFAST utilities create and populate a `ProFAST` object from a configuration mapping, run its +price solve, or convert an existing object back to configuration data. They are available from +`h2integrate.tools.profast_tools`. + +- `create_and_populate_profast(pf_config)` builds a ProFAST instance from a configuration mapping. +- `run_profast(pf)` returns the solved price, summary values, and cost-breakdown DataFrame. +- `make_price_breakdown(price_breakdown, pf_config)` distributes non-component financial costs + across primary cost items and returns a check against the solved levelized cost. +- `format_profast_price_breakdown_per_year(price_breakdown)` formats a ProFAST cost breakdown by + year for reporting. +- `convert_pf_to_dict(pf)` converts a ProFAST instance into nested configuration data. + +```python +from h2integrate.tools.profast_tools import create_and_populate_profast, run_profast + +profast_model = create_and_populate_profast(pf_config) +solution, summary, price_breakdown = run_profast(profast_model) +``` diff --git a/docs/user_guide/technology_config_reference.md b/docs/user_guide/technology_config_reference.md new file mode 100644 index 000000000..a5ea2bac6 --- /dev/null +++ b/docs/user_guide/technology_config_reference.md @@ -0,0 +1,39 @@ +# Technology Configuration Reference + +The technology configuration declares each technology, its model classes, and model-specific +inputs. It is referenced from the top-level configuration with `technology_config`. The schema is +maintained in [`tech_schema.yaml`](../../h2integrate/core/inputs/tech_schema.yaml), and +[`populate_tech_yaml`](populate_tech_yaml.md) can generate model input templates. + +## Technology entries + +Each key under `technologies` is a user-chosen technology name. Model class names are specified +under their respective model sections: + +| Field | Purpose | +|---|---| +| `performance_model.model` | Registered performance model class. Required by the technology schema. | +| `cost_model.model` | Registered cost model class when the technology has a cost model. Some registered models are explicitly cost-free. | +| `control_strategy.model` | Optional controller class for technology-level control. | +| `dispatch_rule_set.model` | Rule class used by heuristic Pyomo dispatch. Optimized dispatch does not use this field. | +| `finance_model.model` | Optional technology-level finance model. | +| `model_location` | Optional source path for a custom performance, cost, or finance model. | +| `finance_model.group` | Name of the plant finance group that receives the technology-specific finance model. | + +The performance, cost, control, dispatch-rule, and finance sections contain a `model` field; put +model parameters under `model_inputs`, not alongside `model`. + +## Model inputs + +`model_inputs` may contain: + +- `shared_parameters`: values shared by at least two model roles. +- `performance_parameters`: performance-model configuration. +- `cost_parameters`: cost-model configuration. +- `financial_parameters`: technology finance-model configuration. +- `control_parameters`: technology controller configuration. + +The accepted fields in each parameter section depend on the selected model. The model documentation +and generated templates are the sources for those model-specific values. For dispatch rule wiring, +see [Pyomo Controllers](../control/technology_level_control/pyomo_controllers.md); for technology +finance groups, see [Specifying Finance Parameters](specifying_finance_parameters.md). diff --git a/h2integrate/postprocess/mapping.py b/h2integrate/postprocess/mapping.py index 8cfc6bf1f..e204cff17 100644 --- a/h2integrate/postprocess/mapping.py +++ b/h2integrate/postprocess/mapping.py @@ -791,10 +791,11 @@ def auto_colorbar_limits(values: gpd.GeoSeries | pd.Series | np.ndarray): Automatically compute colorbar limits. This function calculates lower (`vmin`) and upper (`vmax`) limits for a colorbar - based on the range of the input data. It ensures that the limits: - - Extend slightly beyond the data's minimum and maximum. - - Are rounded to "nice" numbers for readability and colorbar tick placement. - - Handles very small or nearly constant data ranges gracefully. + based on the range of the input data. It ensures that the limits: + + - Extend slightly beyond the data's minimum and maximum. + - Are rounded to "nice" numbers for readability and colorbar tick placement. + - Handle very small or nearly constant data ranges gracefully. Args: values (gpd.GeoSeries | pd.Series | np.ndarray): Numeric data to compute colorbar limits. diff --git a/h2integrate/preprocess/populate_tech_yaml.py b/h2integrate/preprocess/populate_tech_yaml.py index 3636ccaa6..71c97b9ce 100644 --- a/h2integrate/preprocess/populate_tech_yaml.py +++ b/h2integrate/preprocess/populate_tech_yaml.py @@ -13,16 +13,19 @@ storage models where parameters must be carefully organized across multiple configuration sections. -Usage (CLI): +Usage (CLI):: + python -m h2integrate.preprocess.populate_tech_yaml \\ \\ [--output-path ] - Or use the command-line entry point: + Or use the command-line entry point:: + populate_tech_yaml \\ [--output-path ] -Usage (Python): +Usage (Python):: + from h2integrate.preprocess.populate_tech_yaml import populate_tech_yaml_from_file populated_config = populate_tech_yaml_from_file( "path/to/skeleton_tech_config.yaml", From ac18d48b154789cb128d7efe13da9f9ba59e1d27 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 9 Oct 2026 00:10:34 -0600 Subject: [PATCH 12/29] Fixed doc build --- docs/misc_resources/changelog.md | 2 ++ test/test_docs_consistency.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/misc_resources/changelog.md diff --git a/docs/misc_resources/changelog.md b/docs/misc_resources/changelog.md new file mode 100644 index 000000000..3139cd42b --- /dev/null +++ b/docs/misc_resources/changelog.md @@ -0,0 +1,2 @@ +```{include} ../../CHANGELOG.md +``` diff --git a/test/test_docs_consistency.py b/test/test_docs_consistency.py index c6d9e5648..0a7b3d145 100644 --- a/test/test_docs_consistency.py +++ b/test/test_docs_consistency.py @@ -51,7 +51,7 @@ } UNREFERENCED_EXAMPLES = {"04_geo_h2", "31_tidal", "36_nuclear_reactor_htse"} CUSTOM_MODEL_NAMES = {"CustomRiverResource", "SimpleLCOFinance"} -KNOWN_MISSING_PATHS = {("docs/CONTRIBUTING.md", "h2integrate/test_hybrid.py")} +KNOWN_MISSING_PATHS = set() def _markdown_pages() -> list[Path]: From 2c2311b6398b3ae36ed71b3ba745cee4e4e8ea1b Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 9 Oct 2026 11:52:09 -0600 Subject: [PATCH 13/29] update docs and docstrings and break up cf and replacement schedule annualizaiton --- CHANGELOG.md | 1 + .../adding_a_new_technology.md | 5 + docs/developer_guide/class_structure.md | 12 + .../features_not_currently_implemented.md | 1 - docs/intro.md | 10 + h2integrate/core/inputs/validation.py | 16 + h2integrate/core/model_baseclass.py | 537 ++++++++++-------- .../core/test/test_model_baseclasses.py | 189 ++++-- h2integrate/finances/profast_baseclass.py | 3 + 9 files changed, 489 insertions(+), 285 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 884ff0684..63e7e6671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Update API resource models to be able to be able to handle nonannual simulations. [PR 897](https://github.com/NatLabRockies/H2Integrate/pull/897) - Move PySAM model instantiation for wind and solar performance models to the `compute()` method, and validate recalculated wind power curves. [PR 909](https://github.com/NatLabRockies/H2Integrate/pull/909) - Add data resampling capabilities via pandas methods. [PR 893](https://github.com/NatLabRockies/H2Integrate/pull/893) +- Add core framework support for non-annual simulation durations. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ## 0.9 [August 10, 2026] diff --git a/docs/developer_guide/adding_a_new_technology.md b/docs/developer_guide/adding_a_new_technology.md index 38e3d7467..c7dfa2e79 100644 --- a/docs/developer_guide/adding_a_new_technology.md +++ b/docs/developer_guide/adding_a_new_technology.md @@ -80,6 +80,10 @@ from h2integrate.core.model_baseclass import PerformanceModelBaseClass class SolarPerformanceClass(PerformanceModelBaseClass): # (min, max) time step lengths (in seconds) compatible with this model _time_step_bounds = (3600, 3600) + # (min, max) simulation duration (in years) supported by this model. + # Defaults to annual-only (1.0, 1.0) on the baseclass; override to allow + # sub-annual or multi-year simulations. + _simulation_duration_bounds = (1.0, 1.0) # System-level control classifier; see the control classifier docs. _control_classifier = "flexible" @@ -142,6 +146,7 @@ class SolarPerformanceClass(PerformanceModelBaseClass): Every model must define the following class attributes. These are typically set on the category baseclass so that all subclasses inherit them, but they can also be set or overridden on individual model classes. - `_time_step_bounds` (tuple[int, int]): `(min, max)` simulation time-step lengths (in seconds) the model can run at. Use `(3600, 3600)` for hourly-only models and a wider range (e.g. `(300, 3600)`) for models that support sub-hourly time steps. The plant simulation `dt` must lie within every model's bounds. +- `_simulation_duration_bounds` (tuple[float, float]): `(min, max)` simulation durations (in years) the model can run over. Defaults to `(1.0, 1.0)` (annual-only) on the core baseclasses; override it (e.g. `(0.0, float("inf"))`) for models that support sub-annual or multi-year simulations. The total simulated duration (`n_timesteps * dt`) must lie within every model's bounds. Performance models must also define the following class attributes: - `commodity` (str), `commodity_rate_units` (str), `commodity_amount_units` (str): set in `initialize()` (or before calling `super().setup()`). These define the commodity produced by the model and the units used for its rate (e.g. `"kW"`, `"kg/h"`) and cumulative amount (e.g. `"kW*h"`, `"kg"`). `PerformanceModelBaseClass.setup()` uses them to register all of the standard outputs and will raise `NotImplementedError` if any are missing. diff --git a/docs/developer_guide/class_structure.md b/docs/developer_guide/class_structure.md index e6700b073..e9c69df98 100644 --- a/docs/developer_guide/class_structure.md +++ b/docs/developer_guide/class_structure.md @@ -57,6 +57,18 @@ directly) means the baseclass: - Reads `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` from `plant_config`. - Validates that `commodity`, `commodity_rate_units`, and `commodity_amount_units` are set on the subclass and registers all of the standard production outputs from those attributes. - Adds the command-value input and uncurtailed output for `flexible` models, and provides the `apply_curtailment()` helper. + - Provides `calculate_annual_cf_and_replacement_schedule()` to project annual capacity factors and a replacement schedule across the plant life from a simulated horizon of any length (optionally accounting for degradation). + - Declares `_time_step_bounds` (min, max in seconds) used to validate the configured simulation time step against the model. + - Declares `_simulation_duration_bounds` (min, max in years, default annual-only) used to validate the configured simulation duration against the model. + +Similarly, inheriting from `CostModelBaseClass` (rather than `om.ExplicitComponent` +directly) means the baseclass: + - Declares the standard `driver_config` / `plant_config` / `tech_config` options. + - Reads `n_timesteps`, `dt`, `plant_life`, and `fraction_of_year_simulated` from `plant_config`. + - Registers the standard cost outputs `CapEx`, `OpEx`, and `VarOpEx` consumed by the finance models, along with the discrete `cost_year` output and a `marginal_cost` output used for dispatch decisions. + - Provides `calculate_annual_varopex()` to annualize timestep-level variable operating costs across the plant life. + - Declares `_time_step_bounds` (min, max in seconds) used to validate the configured simulation time step against the model. + - Declares `_simulation_duration_bounds` (min, max in years, default annual-only) used to validate the configured simulation duration against the model. ### Multiple layers of inheritance diff --git a/docs/developer_guide/features_not_currently_implemented.md b/docs/developer_guide/features_not_currently_implemented.md index 71d899a1d..324583c94 100644 --- a/docs/developer_guide/features_not_currently_implemented.md +++ b/docs/developer_guide/features_not_currently_implemented.md @@ -8,4 +8,3 @@ We are listing them here: - saving results elegantly for plant components so they do not always need to be rerun - detailing the required inputs and outputs for different model types (e.g. electrolyzer) so that users can more easily add their own subsystems - splitters and combiners for when resources are used or produced by multiple components -- simulating different timespans than one year at hourly timesteps (8760s) diff --git a/docs/intro.md b/docs/intro.md index 306529465..243a12a21 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -37,6 +37,16 @@ Appropriate time step bounds are included as class attributes when non-hourly ti Check individual model docs and definitions for time step bounds for individual models. All models in a given simulation must be compatible with the specified time step. ``` +```{note} +Simulations are no longer restricted to exactly one year. A simulation may cover any +positive duration up to the plant life (a fraction of a year, a single year, or multiple +years), set via `n_timesteps` and `dt` in `plant_config`. Performance, cost, and finance +results are annualized across the plant life using the simulated horizon. Each model +declares the durations it supports with a `_simulation_duration_bounds` class attribute +(min, max in years); the default is annual-only. All models in a given simulation must be +compatible with the specified duration. +``` + For each technology there are 4 different types of models: control, performance, cost, and finance. These model categories allow for modular pieces to be brought in or re-used throughout H2Integrate, as well as ease of development and organization. Note that the only required models for a technology are performance and cost, while control and finance are optional. The figure below shows these four categories and some of the technologies included in H2Integrate. For a full list of models available, please see [Model Overview](user_guide/model_overview.md). ![A representation of a single technology model in H2Integrate](tech-model.png) diff --git a/h2integrate/core/inputs/validation.py b/h2integrate/core/inputs/validation.py index 78091bc69..827899efd 100644 --- a/h2integrate/core/inputs/validation.py +++ b/h2integrate/core/inputs/validation.py @@ -150,6 +150,22 @@ def load_tech_yaml(finput): def load_plant_yaml(finput): + """Validate a plant configuration and check its simulation duration. + + The simulation may cover any positive duration (a fraction of a year, a single year, or + multiple years); performance, cost, and finance models annualize their results across the + plant life. The duration (``n_timesteps * dt``) must be positive and must not exceed the + configured ``plant_life``. + + Args: + finput (dict | str): plant configuration dictionary or path to the plant config YAML. + + Returns: + dict: the validated plant configuration. + + Raises: + ValueError: if the simulation horizon is non-positive or longer than the plant life. + """ plant_config = _validate(finput, fschema_plant) n_timesteps = int(plant_config["plant"]["simulation"]["n_timesteps"]) diff --git a/h2integrate/core/model_baseclass.py b/h2integrate/core/model_baseclass.py index c4dd356d2..ebebeea88 100644 --- a/h2integrate/core/model_baseclass.py +++ b/h2integrate/core/model_baseclass.py @@ -11,6 +11,259 @@ from h2integrate.core.utilities import BaseConfig +_VALID_EXTRAPOLATION_METHODS = ("tile", "final_sim_value", "average_sim_value") + + +def _annualization_steps_per_year(dt): + """Number of timesteps that make up one year for a timestep length ``dt`` (seconds).""" + return max(1, round(31_536_000 / dt)) + + +def _extrapolate_annual_values(simulated_values, plant_life, method): + """Extend per-simulated-year values across the full plant life. + + Args: + simulated_values (np.ndarray): one value per simulated year. + plant_life (int): number of years to project across. + method (str): one of ``"tile"`` (repeat simulated years cyclically), + ``"final_sim_value"`` (hold the last simulated year), or + ``"average_sim_value"`` (hold the simulated average). + + Returns: + np.ndarray: ``plant_life`` annual values. + """ + if method not in _VALID_EXTRAPOLATION_METHODS: + raise ValueError( + f"extrapolation method must be one of {_VALID_EXTRAPOLATION_METHODS}; got {method!r}." + ) + + n_sim_years = simulated_values.size + if method == "tile": + n_tiles = math.ceil(plant_life / n_sim_years) + return np.tile(simulated_values, n_tiles)[:plant_life] + + fill_value = ( + simulated_values[-1] if method == "final_sim_value" else float(np.mean(simulated_values)) + ) + projected = np.full(plant_life, fill_value, dtype=float) + projected[:n_sim_years] = simulated_values[:plant_life] + return projected + + +def _validate_annualization_inputs( + performance_timeseries, + rated_performance, + state_of_health_timeseries, + eol_soh, + no_degradation_extrapolation, + soh_cycle_repetition, +): + """Validate and coerce the inputs to ``calculate_annual_cf_and_replacement_schedule``. + + Returns: + tuple: the coerced ``performance_timeseries`` array, ``rated_performance`` float, + ``state_of_health_timeseries`` (array or None), and a ``use_degradation`` flag. + """ + performance_timeseries = np.asarray(performance_timeseries, dtype=float) + if performance_timeseries.size == 0: + raise ValueError("performance_timeseries must contain at least one value.") + + rated_performance = float(rated_performance) + if rated_performance <= 0.0: + raise ValueError("rated_performance must be greater than zero.") + + if no_degradation_extrapolation not in _VALID_EXTRAPOLATION_METHODS: + raise ValueError( + "no_degradation_extrapolation must be one of " + f"{_VALID_EXTRAPOLATION_METHODS}; got {no_degradation_extrapolation!r}." + ) + + valid_soh_cycle_repetition_modes = ("all_soh_cycles", "final_soh_cycle") + if soh_cycle_repetition not in valid_soh_cycle_repetition_modes: + raise ValueError( + "soh_cycle_repetition must be one of " + f"{valid_soh_cycle_repetition_modes}; got {soh_cycle_repetition!r}." + ) + + use_degradation = state_of_health_timeseries is not None + if use_degradation: + if eol_soh is None: + raise ValueError("eol_soh is required when state_of_health_timeseries is provided.") + state_of_health_timeseries = np.asarray(state_of_health_timeseries, dtype=float) + if state_of_health_timeseries.size == 0: + raise ValueError( + "state_of_health_timeseries must contain at least one value when provided." + ) + + return performance_timeseries, rated_performance, state_of_health_timeseries, use_degradation + + +def _simulated_annual_capacity_factors(performance_timeseries, rated_performance, dt): + """Capacity factor for each simulated year of ``performance_timeseries``.""" + steps_per_year = _annualization_steps_per_year(dt) + n_sim_years = math.ceil(performance_timeseries.size / steps_per_year) + simulated_annual_capacity_factors = np.zeros(n_sim_years) + + for year in range(n_sim_years): + start = year * steps_per_year + end = min(start + steps_per_year, performance_timeseries.size) + segment = performance_timeseries[start:end] + segment_hours = ((end - start) * dt) / 3600.0 + simulated_annual_capacity_factors[year] = ( + (segment.sum() * (dt / 3600.0)) / (rated_performance * segment_hours) + if segment_hours > 0.0 + else 0.0 + ) # performance units * h / (performance units * h) + + return simulated_annual_capacity_factors + + +def _degraded_replacement_cycles( + simulated_annual_capacity_factors, state_of_health_timeseries, eol_soh, dt, plant_life +): + """Decompose the simulated years into replacement cycles. + + A cycle ends when the year-end state of health (SOH) reaches end of life, when the + following year's SOH resets upward (a replacement already recorded in the input series), + or when the simulation runs out of data. Only the trailing cycle can be incomplete; when + it is, it is completed by extrapolating the local degradation rate until end of life is + reached. + + Returns: + tuple[list[np.ndarray], list[bool]]: per-cycle capacity-factor segments and, for each + cycle, whether it reached end of life. + """ + steps_per_year = _annualization_steps_per_year(dt) + n_sim_years = simulated_annual_capacity_factors.size + if math.ceil(state_of_health_timeseries.size / steps_per_year) != n_sim_years: + raise ValueError( + "state_of_health_timeseries length must match the number of simulated years " + "implied by performance_timeseries and dt." + ) + + # Year-end SOH for each simulated year. + sim_soh_year_end = np.zeros(n_sim_years) + for year in range(n_sim_years): + end = min((year + 1) * steps_per_year, state_of_health_timeseries.size) + sim_soh_year_end[year] = state_of_health_timeseries[end - 1] + + soh_reset_tol = 1e-9 + cycle_cf_segments = [] + cycle_soh_segments = [] + cycle_reached_eol = [] + cycle_start = 0 + for year in range(n_sim_years): + eol_reached = sim_soh_year_end[year] <= eol_soh + soh_reset_next = (year + 1 < n_sim_years) and ( + sim_soh_year_end[year + 1] > sim_soh_year_end[year] + soh_reset_tol + ) + is_last_year = year == n_sim_years - 1 + if eol_reached or soh_reset_next or is_last_year: + cycle_cf_segments.append( + np.array(simulated_annual_capacity_factors[cycle_start : year + 1]) + ) + cycle_soh_segments.append(np.array(sim_soh_year_end[cycle_start : year + 1])) + cycle_reached_eol.append(bool(eol_reached or soh_reset_next)) + cycle_start = year + 1 + + # Complete the trailing cycle if the simulation ended before it reached end of life. + # Only extrapolated years are appended; observed years are left unchanged. + if not cycle_reached_eol[-1]: + trailing_cf = cycle_cf_segments[-1] + trailing_soh = cycle_soh_segments[-1] + + # Local annual degradation rate from the last one or two points of the incomplete + # trailing cycle (robust to non-linear degradation). + if trailing_soh.size >= 2: + local_annual_deg_rate = trailing_soh[-2] - trailing_soh[-1] + else: + trailing_start_year = n_sim_years - trailing_soh.size + start_ts = trailing_start_year * steps_per_year + end_ts = min(start_ts + steps_per_year, state_of_health_timeseries.size) + year_fraction = (end_ts - start_ts) / steps_per_year + year_fraction = year_fraction if year_fraction > 0.0 else 1.0 + local_annual_deg_rate = ( + state_of_health_timeseries[start_ts] - trailing_soh[-1] + ) / year_fraction + local_annual_deg_rate = max(float(local_annual_deg_rate), 0.0) + + last_observed_soh = float(trailing_soh[-1]) + last_observed_cf = float(trailing_cf[-1]) + extrapolated_soh = [] + extrapolated_cf = [] + projected_soh = last_observed_soh + # Cap the number of extrapolated years to avoid an unbounded loop if degradation + # stalls (rate of zero). + while ( + local_annual_deg_rate > 0.0 + and projected_soh > eol_soh + and len(extrapolated_soh) < plant_life + ): + projected_soh = projected_soh - local_annual_deg_rate + extrapolated_soh.append(projected_soh) + if last_observed_soh > 0.0: + extrapolated_cf.append( + last_observed_cf * max(projected_soh, 0.0) / last_observed_soh + ) + else: + extrapolated_cf.append(0.0) + + if extrapolated_soh: + cycle_cf_segments[-1] = np.concatenate([trailing_cf, np.array(extrapolated_cf)]) + cycle_reached_eol[-1] = extrapolated_soh[-1] <= eol_soh + + return cycle_cf_segments, cycle_reached_eol + + +def _project_capacity_factors(cycle_cf_segments, soh_cycle_repetition, plant_life): + """Capacity factor for each plant-life year. + + The simulated/completed cycles are kept at the front of the timeline, then the repeat + unit (all cycles or just the final one) is tiled to fill the plant life. + """ + front_cf = np.concatenate(cycle_cf_segments) + if soh_cycle_repetition == "all_soh_cycles": + repeat_cf = front_cf + else: # "final_soh_cycle" (already validated) + repeat_cf = cycle_cf_segments[-1] + + n_repeats = math.ceil(max(plant_life - front_cf.size, 0) / repeat_cf.size) + return np.concatenate([front_cf, np.tile(repeat_cf, n_repeats)])[:plant_life] + + +def _build_replacement_schedule( + cycle_cf_segments, cycle_reached_eol, soh_cycle_repetition, plant_life +): + """Replacement schedule marking the first year of each post-end-of-life cycle.""" + cycle_lengths = [segment.size for segment in cycle_cf_segments] + if soh_cycle_repetition == "all_soh_cycles": + repeat_lengths = cycle_lengths + repeat_reached_eol = list(cycle_reached_eol) + else: # "final_soh_cycle" (already validated) + repeat_lengths = [cycle_lengths[-1]] + repeat_reached_eol = [cycle_reached_eol[-1]] + + # Extend the cycle timeline until it spans the plant life. + timeline_cycle_lengths = list(cycle_lengths) + timeline_reached_eol = list(cycle_reached_eol) + while sum(timeline_cycle_lengths) < plant_life: + timeline_cycle_lengths.extend(repeat_lengths) + timeline_reached_eol.extend(repeat_reached_eol) + + # A replacement occurs at the first year of a cycle (plant year > 0) whenever the + # previous cycle reached end of life. + replacement_schedule = np.zeros(plant_life) + cycle_start_year = 0 + for cycle_index, cycle_length in enumerate(timeline_cycle_lengths): + if cycle_start_year >= plant_life: + break + if cycle_start_year > 0 and timeline_reached_eol[cycle_index - 1]: + replacement_schedule[cycle_start_year] = 1.0 + cycle_start_year += cycle_length + + return replacement_schedule + + class PerformanceModelBaseClass(om.ExplicitComponent): # (min, max) permitted simulation duration in years; default is annual-only. _simulation_duration_bounds = (1.0, 1.0) @@ -146,7 +399,6 @@ def apply_curtailment(self, outputs): command_value = self._inputs[command_value_key] outputs[commodity_out_key] = np.minimum(uncurtailed, command_value) - # TODO rewrite and reorganize this method def calculate_annual_cf_and_replacement_schedule( self, performance_timeseries, @@ -189,214 +441,48 @@ def calculate_annual_cf_and_replacement_schedule( tuple[np.ndarray, np.ndarray]: projected annual capacity factors and replacement schedule. """ - performance_timeseries = np.asarray(performance_timeseries, dtype=float) - if performance_timeseries.size == 0: - raise ValueError("performance_timeseries must contain at least one value.") - - rated_performance = float(rated_performance) - if rated_performance <= 0.0: - raise ValueError("rated_performance must be greater than zero.") - - valid_no_deg_modes = ("tile", "final_sim_value", "average_sim_value") - if no_degradation_extrapolation not in valid_no_deg_modes: - raise ValueError( - "no_degradation_extrapolation must be one of " - f"{valid_no_deg_modes}; got {no_degradation_extrapolation!r}." - ) - - valid_soh_cycle_repetition_modes = ("all_soh_cycles", "final_soh_cycle") - if soh_cycle_repetition not in valid_soh_cycle_repetition_modes: - raise ValueError( - "soh_cycle_repetition must be one of " - f"{valid_soh_cycle_repetition_modes}; got {soh_cycle_repetition!r}." - ) - - use_degradation = state_of_health_timeseries is not None - if use_degradation: - if eol_soh is None: - raise ValueError("eol_soh is required when state_of_health_timeseries is provided.") - state_of_health_timeseries = np.asarray(state_of_health_timeseries, dtype=float) - if state_of_health_timeseries.size == 0: - raise ValueError( - "state_of_health_timeseries must contain at least one value when provided." - ) - - steps_per_year = max(1, round(31_536_000 / self.dt)) - n_sim_years = math.ceil(performance_timeseries.size / steps_per_year) - simulated_annual_capacity_factors = np.zeros(n_sim_years) + ( + performance_timeseries, + rated_performance, + state_of_health_timeseries, + use_degradation, + ) = _validate_annualization_inputs( + performance_timeseries, + rated_performance, + state_of_health_timeseries, + eol_soh, + no_degradation_extrapolation, + soh_cycle_repetition, + ) - for year in range(n_sim_years): - start = year * steps_per_year - end = min(start + steps_per_year, performance_timeseries.size) - segment = performance_timeseries[start:end] - segment_seconds = (end - start) * self.dt - segment_hours = segment_seconds / 3600.0 - simulated_annual_capacity_factors[year] = ( - (segment.sum() * (self.dt / 3600.0)) / (rated_performance * segment_hours) - if segment_hours > 0.0 - else 0.0 - ) # performance units * h / (performance units * h) + simulated_annual_capacity_factors = _simulated_annual_capacity_factors( + performance_timeseries, rated_performance, self.dt + ) + # Without degradation, no replacements occur and capacity factors are simply + # extrapolated across the plant life. if not use_degradation: - if no_degradation_extrapolation == "tile": - n_tiles = math.ceil(self.plant_life / n_sim_years) - annual_capacity_factors = np.tile(simulated_annual_capacity_factors, n_tiles)[ - : self.plant_life - ] - elif no_degradation_extrapolation == "final_sim_value": - annual_capacity_factors = np.array( - [ - simulated_annual_capacity_factors[y] - if y < n_sim_years - else simulated_annual_capacity_factors[-1] - for y in range(self.plant_life) - ] - ) - elif no_degradation_extrapolation == "average_sim_value": - simulated_average_capacity_factor = float( - np.mean(simulated_annual_capacity_factors) - ) - annual_capacity_factors = np.array( - [ - simulated_annual_capacity_factors[y] - if y < n_sim_years - else simulated_average_capacity_factor - for y in range(self.plant_life) - ] - ) - else: - raise ValueError( - "no_degradation_extrapolation must be one of " - f"{valid_no_deg_modes}; got {no_degradation_extrapolation!r}." - ) - replacement_schedule = np.zeros(self.plant_life) - return annual_capacity_factors, replacement_schedule - - else: - n_sim_years_from_soh = math.ceil(state_of_health_timeseries.size / steps_per_year) - if n_sim_years_from_soh != n_sim_years: - raise ValueError( - "state_of_health_timeseries length must match the number of simulated years " - "implied by performance_timeseries and dt." - ) - - # Year-end SOH for each simulated year. - sim_soh_year_end = np.zeros(n_sim_years) - for year in range(n_sim_years): - start = year * steps_per_year - end = min(start + steps_per_year, state_of_health_timeseries.size) - sim_soh_year_end[year] = state_of_health_timeseries[end - 1] - - # Segment the observed simulated years into replacement cycles. A cycle ends when - # either the year-end SOH reaches end of life, or the following year's SOH resets - # upward (a replacement already recorded in the input series). Only the trailing - # cycle can be incomplete (ended by running out of simulated data). - soh_reset_tol = 1e-9 - cycle_cf_segments = [] - cycle_soh_segments = [] - cycle_reached_eol = [] - cycle_start = 0 - for year in range(n_sim_years): - eol_reached = sim_soh_year_end[year] <= eol_soh - soh_reset_next = (year + 1 < n_sim_years) and ( - sim_soh_year_end[year + 1] > sim_soh_year_end[year] + soh_reset_tol - ) - is_last_year = year == n_sim_years - 1 - if eol_reached or soh_reset_next or is_last_year: - cycle_cf_segments.append( - np.array(simulated_annual_capacity_factors[cycle_start : year + 1]) - ) - cycle_soh_segments.append(np.array(sim_soh_year_end[cycle_start : year + 1])) - cycle_reached_eol.append(bool(eol_reached or soh_reset_next)) - cycle_start = year + 1 - - # Complete the trailing cycle if the simulation ended before it reached end of - # life. Only extrapolated years are appended; observed years are left unchanged. - if not cycle_reached_eol[-1]: - trailing_cf = cycle_cf_segments[-1] - trailing_soh = cycle_soh_segments[-1] - - # Local annual degradation rate from the last one or two points of the - # incomplete trailing cycle (robust to non-linear degradation). - if trailing_soh.size >= 2: - local_annual_deg_rate = trailing_soh[-2] - trailing_soh[-1] - else: - trailing_start_year = n_sim_years - trailing_soh.size - start_ts = trailing_start_year * steps_per_year - end_ts = min(start_ts + steps_per_year, state_of_health_timeseries.size) - year_fraction = (end_ts - start_ts) / steps_per_year - year_fraction = year_fraction if year_fraction > 0.0 else 1.0 - local_annual_deg_rate = ( - state_of_health_timeseries[start_ts] - trailing_soh[-1] - ) / year_fraction - local_annual_deg_rate = max(float(local_annual_deg_rate), 0.0) - - last_observed_soh = float(trailing_soh[-1]) - last_observed_cf = float(trailing_cf[-1]) - extrapolated_soh = [] - extrapolated_cf = [] - projected_soh = last_observed_soh - # Cap the number of extrapolated years to avoid an unbounded loop if - # degradation stalls (rate of zero). - while ( - local_annual_deg_rate > 0.0 - and projected_soh > eol_soh - and len(extrapolated_soh) < self.plant_life - ): - projected_soh = projected_soh - local_annual_deg_rate - extrapolated_soh.append(projected_soh) - if last_observed_soh > 0.0: - extrapolated_cf.append( - last_observed_cf * max(projected_soh, 0.0) / last_observed_soh - ) - else: - extrapolated_cf.append(0.0) - - if extrapolated_soh: - cycle_cf_segments[-1] = np.concatenate([trailing_cf, np.array(extrapolated_cf)]) - cycle_soh_segments[-1] = np.concatenate( - [trailing_soh, np.array(extrapolated_soh)] - ) - cycle_reached_eol[-1] = extrapolated_soh[-1] <= eol_soh - - # The timeline is every simulated/completed cycle in order (the "front"), - # followed by the repeated cycle unit until the plant life is filled. - cycle_lengths = [segment.size for segment in cycle_cf_segments] - front_cf = np.concatenate(cycle_cf_segments) - - if soh_cycle_repetition == "all_soh_cycles": - repeat_cf = front_cf - repeat_lengths = cycle_lengths - repeat_reached_eol = list(cycle_reached_eol) - else: # "final_soh_cycle" (already validated above) - repeat_cf = cycle_cf_segments[-1] - repeat_lengths = [cycle_cf_segments[-1].size] - repeat_reached_eol = [cycle_reached_eol[-1]] - - # Capacity factors: keep the simulated/completed front, then tile the repeat unit. - n_repeats = math.ceil(max(self.plant_life - front_cf.size, 0) / repeat_cf.size) - annual_capacity_factors = np.concatenate([front_cf, np.tile(repeat_cf, n_repeats)])[ - : self.plant_life - ] - - # Replacement schedule: a replacement occurs at the first year of a cycle - # (plant year > 0) whenever the previous cycle reached end of life. - timeline_cycle_lengths = list(cycle_lengths) - timeline_reached_eol = list(cycle_reached_eol) - while sum(timeline_cycle_lengths) < self.plant_life: - timeline_cycle_lengths.extend(repeat_lengths) - timeline_reached_eol.extend(repeat_reached_eol) - - replacement_schedule = np.zeros(self.plant_life) - cycle_start_year = 0 - for cycle_index, cycle_length in enumerate(timeline_cycle_lengths): - if cycle_start_year >= self.plant_life: - break - if cycle_start_year > 0 and timeline_reached_eol[cycle_index - 1]: - replacement_schedule[cycle_start_year] = 1.0 - cycle_start_year += cycle_length - - return annual_capacity_factors, replacement_schedule + annual_capacity_factors = _extrapolate_annual_values( + simulated_annual_capacity_factors, self.plant_life, no_degradation_extrapolation + ) + return annual_capacity_factors, np.zeros(self.plant_life) + + # With degradation, both outputs derive from the same decomposition of the simulated + # years into replacement cycles. + cycle_cf_segments, cycle_reached_eol = _degraded_replacement_cycles( + simulated_annual_capacity_factors, + state_of_health_timeseries, + eol_soh, + self.dt, + self.plant_life, + ) + annual_capacity_factors = _project_capacity_factors( + cycle_cf_segments, soh_cycle_repetition, self.plant_life + ) + replacement_schedule = _build_replacement_schedule( + cycle_cf_segments, cycle_reached_eol, soh_cycle_repetition, self.plant_life + ) + return annual_capacity_factors, replacement_schedule def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ @@ -501,48 +587,29 @@ def calculate_annual_varopex( if varopex_timeseries.size == 0: raise ValueError("varopex_timeseries must contain at least one value.") - valid_extrapolation_methods = ("tile", "final_sim_value", "average_sim_value") - if extrapolation_method not in valid_extrapolation_methods: + if extrapolation_method not in _VALID_EXTRAPOLATION_METHODS: raise ValueError( "extrapolation_method must be one of " - f"{valid_extrapolation_methods}; got {extrapolation_method!r}." + f"{_VALID_EXTRAPOLATION_METHODS}; got {extrapolation_method!r}." ) - steps_per_year = max(1, round(31_536_000 / self.dt)) + steps_per_year = _annualization_steps_per_year(self.dt) n_sim_years = math.ceil(varopex_timeseries.size / steps_per_year) simulated_annual_varopex = np.zeros(n_sim_years) for year in range(n_sim_years): start = year * steps_per_year end = min(start + steps_per_year, varopex_timeseries.size) - segment = varopex_timeseries[start:end] segment_seconds = (end - start) * self.dt - if segment_seconds <= 0.0: - simulated_annual_varopex[year] = 0.0 - continue - - simulated_annual_varopex[year] = segment.sum() * (31_536_000 / segment_seconds) - - if extrapolation_method == "tile": - n_tiles = math.ceil(self.plant_life / n_sim_years) - annual_varopex = np.tile(simulated_annual_varopex, n_tiles)[: self.plant_life] - elif extrapolation_method == "final_sim_value": - annual_varopex = np.array( - [ - simulated_annual_varopex[y] if y < n_sim_years else simulated_annual_varopex[-1] - for y in range(self.plant_life) - ] - ) - else: - simulated_average_varopex = float(np.mean(simulated_annual_varopex)) - annual_varopex = np.array( - [ - simulated_annual_varopex[y] if y < n_sim_years else simulated_average_varopex - for y in range(self.plant_life) - ] + simulated_annual_varopex[year] = ( + varopex_timeseries[start:end].sum() * (31_536_000 / segment_seconds) + if segment_seconds > 0.0 + else 0.0 ) - return annual_varopex + return _extrapolate_annual_values( + simulated_annual_varopex, self.plant_life, extrapolation_method + ) def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): """ diff --git a/h2integrate/core/test/test_model_baseclasses.py b/h2integrate/core/test/test_model_baseclasses.py index 2a3eb34c9..f061566cb 100644 --- a/h2integrate/core/test/test_model_baseclasses.py +++ b/h2integrate/core/test/test_model_baseclasses.py @@ -3,7 +3,14 @@ import numpy as np import pytest -from h2integrate.core.model_baseclass import PerformanceModelBaseClass +from h2integrate.core.model_baseclass import ( + PerformanceModelBaseClass, + _project_capacity_factors, + _extrapolate_annual_values, + _build_replacement_schedule, + _annualization_steps_per_year, + _simulated_annual_capacity_factors, +) class _DummyPerformanceModel: @@ -17,10 +24,10 @@ def __init__(self, plant_life=6, dt=31_536_000): def _run(dummy, performance_timeseries, rated, soh, eol, **kwargs): return PerformanceModelBaseClass.calculate_annual_cf_and_replacement_schedule( dummy, - np.asarray(performance_timeseries, dtype=float), - rated, - None if soh is None else np.asarray(soh, dtype=float), - eol, + performance_timeseries=np.asarray(performance_timeseries, dtype=float), + rated_performance=rated, + state_of_health_timeseries=None if soh is None else np.asarray(soh, dtype=float), + eol_soh=eol, **kwargs, ) @@ -29,10 +36,10 @@ def _run(dummy, performance_timeseries, rated, soh, eol, **kwargs): def test_resets_on_replacement(): cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [1.0, 0.92, 0.84], - 1.0, - [0.97, 0.89, 0.8], - 0.8, + performance_timeseries=[1.0, 0.92, 0.84], + rated=1.0, + soh=[0.97, 0.89, 0.8], + eol=0.8, ) np.testing.assert_allclose(cf_values, [1.0, 0.92, 0.84, 1.0, 0.92, 0.84]) @@ -43,10 +50,10 @@ def test_resets_on_replacement(): def test_without_degradation_tile(): cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [0.6, 0.5, 0.4], - 1.0, - None, - None, + performance_timeseries=[0.6, 0.5, 0.4], + rated=1.0, + soh=None, + eol=None, ) np.testing.assert_allclose(cf_values, [0.6, 0.5, 0.4, 0.6, 0.5, 0.4]) @@ -57,10 +64,10 @@ def test_without_degradation_tile(): def test_without_degradation_final_sim_value(): cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [0.6, 0.5, 0.4], - 1.0, - None, - None, + performance_timeseries=[0.6, 0.5, 0.4], + rated=1.0, + soh=None, + eol=None, no_degradation_extrapolation="final_sim_value", ) @@ -72,10 +79,10 @@ def test_without_degradation_final_sim_value(): def test_without_degradation_average_sim_value(): cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [0.6, 0.5, 0.4], - 1.0, - None, - None, + performance_timeseries=[0.6, 0.5, 0.4], + rated=1.0, + soh=None, + eol=None, no_degradation_extrapolation="average_sim_value", ) @@ -89,10 +96,10 @@ def test_extrapolates_incomplete_trailing_cycle_then_tiles(): # extrapolation before the completed cycle is tiled across plant life. cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=8), - [1.0, 0.9, 0.8], - 1.0, - [0.95, 0.90, 0.85], - 0.8, + performance_timeseries=[1.0, 0.9, 0.8], + rated=1.0, + soh=[0.95, 0.90, 0.85], + eol=0.8, ) extrapolated_cf = 0.8 * 0.80 / 0.85 @@ -108,10 +115,10 @@ def test_preserves_multiple_simulated_cycles_all_soh_cycles(): # Two completed cycles are observed (EOL crossing at year 1 and year 4). cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [1.0, 0.95, 0.9, 0.85, 0.8], - 1.0, - [0.85, 0.80, 0.90, 0.85, 0.80], - 0.8, + performance_timeseries=[1.0, 0.95, 0.9, 0.85, 0.8], + rated=1.0, + soh=[0.85, 0.80, 0.90, 0.85, 0.80], + eol=0.8, ) np.testing.assert_allclose(cf_values, [1.0, 0.95, 0.9, 0.85, 0.8, 1.0]) @@ -125,10 +132,10 @@ def test_final_soh_cycle_vs_all_soh_cycles(): final_cf, final_repl = _run( _DummyPerformanceModel(plant_life=8), - performance_timeseries, - 1.0, - soh, - 0.8, + performance_timeseries=performance_timeseries, + rated=1.0, + soh=soh, + eol=0.8, soh_cycle_repetition="final_soh_cycle", ) np.testing.assert_allclose(final_cf, [1.0, 0.95, 0.9, 0.85, 0.8, 0.9, 0.85, 0.8]) @@ -136,10 +143,10 @@ def test_final_soh_cycle_vs_all_soh_cycles(): all_cf, all_repl = _run( _DummyPerformanceModel(plant_life=8), - performance_timeseries, - 1.0, - soh, - 0.8, + performance_timeseries=performance_timeseries, + rated=1.0, + soh=soh, + eol=0.8, soh_cycle_repetition="all_soh_cycles", ) np.testing.assert_allclose(all_cf, [1.0, 0.95, 0.9, 0.85, 0.8, 1.0, 0.95, 0.9]) @@ -152,10 +159,10 @@ def test_detects_reset_via_upward_soh_jump(): # completed cycle. The trailing cycle is then completed via extrapolation. cf_values, replacement_schedule = _run( _DummyPerformanceModel(plant_life=6), - [1.0, 0.95, 0.9, 0.85, 0.8], - 1.0, - [0.85, 0.82, 1.0, 0.9, 0.82], - 0.8, + performance_timeseries=[1.0, 0.95, 0.9, 0.85, 0.8], + rated=1.0, + soh=[0.85, 0.82, 1.0, 0.9, 0.82], + eol=0.8, ) extrapolated_cf = 0.8 * 0.74 / 0.82 @@ -168,10 +175,10 @@ def test_invalid_soh_cycle_repetition_raises(): with pytest.raises(ValueError): _run( _DummyPerformanceModel(plant_life=6), - [1.0, 0.9, 0.8], - 1.0, - [0.95, 0.9, 0.85], - 0.8, + performance_timeseries=[1.0, 0.9, 0.8], + rated=1.0, + soh=[0.95, 0.9, 0.85], + eol=0.8, soh_cycle_repetition="not_a_mode", ) @@ -181,9 +188,93 @@ def test_invalid_no_degradation_extrapolation_raises(): with pytest.raises(ValueError): _run( _DummyPerformanceModel(plant_life=6), - [0.6, 0.5, 0.4], - 1.0, - None, - None, + performance_timeseries=[0.6, 0.5, 0.4], + rated=1.0, + soh=None, + eol=None, no_degradation_extrapolation="not_a_mode", ) + + +@pytest.mark.unit +def test_annualization_steps_per_year(): + assert _annualization_steps_per_year(dt=3600) == 8760 + assert _annualization_steps_per_year(dt=31_536_000) == 1 + # Never returns less than one step per year. + assert _annualization_steps_per_year(dt=10 * 31_536_000) == 1 + + +@pytest.mark.unit +def test_simulated_annual_capacity_factors(): + # Two years at a 1-year timestep; CF is simply value / rated per year. + cf = _simulated_annual_capacity_factors( + performance_timeseries=np.array([0.5, 0.25]), rated_performance=1.0, dt=31_536_000 + ) + np.testing.assert_allclose(cf, [0.5, 0.25]) + + +@pytest.mark.unit +def test_extrapolate_annual_values_modes(): + simulated = np.array([0.6, 0.5, 0.4]) + np.testing.assert_allclose( + _extrapolate_annual_values(simulated_values=simulated, plant_life=6, method="tile"), + [0.6, 0.5, 0.4, 0.6, 0.5, 0.4], + ) + np.testing.assert_allclose( + _extrapolate_annual_values( + simulated_values=simulated, plant_life=6, method="final_sim_value" + ), + [0.6, 0.5, 0.4, 0.4, 0.4, 0.4], + ) + np.testing.assert_allclose( + _extrapolate_annual_values( + simulated_values=simulated, plant_life=6, method="average_sim_value" + ), + [0.6, 0.5, 0.4, 0.5, 0.5, 0.5], + ) + with pytest.raises(ValueError): + _extrapolate_annual_values(simulated_values=simulated, plant_life=6, method="not_a_mode") + + +@pytest.mark.unit +def test_project_capacity_factors_repetition_modes(): + # Two completed cycles of lengths 2 and 1. + cycle_cf_segments = [np.array([1.0, 0.9]), np.array([0.8])] + np.testing.assert_allclose( + _project_capacity_factors( + cycle_cf_segments, soh_cycle_repetition="all_soh_cycles", plant_life=5 + ), + [1.0, 0.9, 0.8, 1.0, 0.9], + ) + np.testing.assert_allclose( + _project_capacity_factors( + cycle_cf_segments, soh_cycle_repetition="final_soh_cycle", plant_life=5 + ), + [1.0, 0.9, 0.8, 0.8, 0.8], + ) + + +@pytest.mark.unit +def test_build_replacement_schedule_marks_post_eol_cycle_starts(): + # A single 3-year cycle that reaches end of life, tiled across a 5-year plant life, + # triggers one replacement at the start of the second cycle (year 3). + cycle_cf_segments = [np.array([1.0, 0.9, 0.8])] + np.testing.assert_allclose( + _build_replacement_schedule( + cycle_cf_segments, + cycle_reached_eol=[True], + soh_cycle_repetition="all_soh_cycles", + plant_life=5, + ), + [0.0, 0.0, 0.0, 1.0, 0.0], + ) + # No replacements when the cycle never reaches end of life. + np.testing.assert_allclose( + _build_replacement_schedule( + cycle_cf_segments, + cycle_reached_eol=[False], + soh_cycle_repetition="all_soh_cycles", + plant_life=5, + ), + np.zeros(5), + ) diff --git a/h2integrate/finances/profast_baseclass.py b/h2integrate/finances/profast_baseclass.py index f9f856082..25cd8106f 100644 --- a/h2integrate/finances/profast_baseclass.py +++ b/h2integrate/finances/profast_baseclass.py @@ -633,6 +633,9 @@ def populate_profast(self, inputs): rate_units_capacity = io_meta_data[f"rated_{self.options['commodity_type']}_production"][ "units" ] + # Convert the rated production (a rate, e.g. kg/h or kW) into commodity amount units by + # multiplying by a duration: one day gives ProFAST's daily capacity, one year the annual + # production. day_amount_per_unit_rate = convert_units( 86_400, "s", From fac83f5f03e9f783326e5c9caf0309e5005d9743 Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 9 Oct 2026 12:19:51 -0600 Subject: [PATCH 14/29] add sim bounds check to control base classes --- h2integrate/control/control_rules/pyomo_rule_baseclass.py | 2 ++ .../control/control_strategies/openloop_control_baseclass.py | 2 ++ .../control_strategies/pyomo_storage_controller_baseclass.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/h2integrate/control/control_rules/pyomo_rule_baseclass.py b/h2integrate/control/control_rules/pyomo_rule_baseclass.py index 021596b50..b3062f0c9 100644 --- a/h2integrate/control/control_rules/pyomo_rule_baseclass.py +++ b/h2integrate/control/control_rules/pyomo_rule_baseclass.py @@ -26,6 +26,8 @@ class PyomoRuleBaseClass(om.ExplicitComponent): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + # (min, max) permitted simulation duration in years; default is annual-only. + _simulation_duration_bounds = (1.0, 1.0) def initialize(self): self.options.declare("driver_config", types=dict) diff --git a/h2integrate/control/control_strategies/openloop_control_baseclass.py b/h2integrate/control/control_strategies/openloop_control_baseclass.py index eaea8d251..2b79c72cf 100644 --- a/h2integrate/control/control_strategies/openloop_control_baseclass.py +++ b/h2integrate/control/control_strategies/openloop_control_baseclass.py @@ -164,6 +164,8 @@ class OpenLoopControlBase(om.ExplicitComponent): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + # (min, max) permitted simulation duration in years; default is annual-only. + _simulation_duration_bounds = (1.0, 1.0) def initialize(self): self.options.declare("driver_config", types=dict) diff --git a/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py b/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py index 526efbe1d..01239ec99 100644 --- a/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py +++ b/h2integrate/control/control_strategies/pyomo_storage_controller_baseclass.py @@ -78,6 +78,8 @@ class PyomoStorageControllerBaseClass(om.ExplicitComponent): 3600, 3600, ) # (min, max) time step lengths (in seconds) compatible with this model + # (min, max) permitted simulation duration in years; default is annual-only. + _simulation_duration_bounds = (1.0, 1.0) def initialize(self): """ From f9e695e8620fbad5334575e18b1c1fd10c4d57d3 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:14 -0600 Subject: [PATCH 15/29] Updating top-level docs, config, etc --- CHANGELOG.md | 1 + README.md | 11 +-- docs/CONTRIBUTING.md | 27 ++++--- docs/_config.yml | 17 +++- docs/_toc.yml | 2 +- .../features_not_currently_implemented.md | 11 --- docs/developer_guide/why_make_h2integrate.md | 80 ++++++------------- docs/getting_started/environment_variables.md | 15 ++-- docs/getting_started/install.md | 18 +++-- docs/intro.md | 13 +-- 10 files changed, 89 insertions(+), 106 deletions(-) delete mode 100644 docs/developer_guide/features_not_currently_implemented.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6878dc694..4a424b7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ - Pinned `optiwindnet<0.3.1` in the `ard` extra because `optiwindnet` 0.3.1 is incompatible with `ard-nrel` 0.1.0b5. [PR 902](https://github.com/NatLabRockies/H2Integrate/pull/902) - Updates to all GH Actions, pre-commit, isort, and ruff versioning. [PR 904](https://github.com/NatLabRockies/H2Integrate/pull/904) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) +- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ### Fixes diff --git a/README.md b/README.md index dc4a76c27..60c483eeb 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,9 @@ H2Integrate is both a **hybrid systems engineering design tool** and a **technoe - **Cost inputs settable by the user** with examples based on the [Annual Technology Baseline (ATB)](https://atb.nlr.gov/) - **Optimization and scenario analysis** to explore design trade-offs across hybrid plant configurations -### Available Technologies +### Model Library -H2I includes models for a broad set of energy generation, conversion, and storage technologies. -This is a non-exhaustive list, and the library of available technologies is actively expanding: - -- **Electricity generation**: solar PV, wind, wave, tidal, natural gas combined cycle (NGCC), natural gas combustion turbines (NGCT), nuclear, grid -- **Hydrogen production**: PEM electrolysis, NG-SMR -- **Energy storage**: Li-ion batteries, long-duration energy storage (LDES), pumped storage hydropower (PSH), hydrogen storage, compressed gas storage -- **Fuel cells**: H2 PEM fuel cells -- **Industrial processes**: ammonia synthesis, iron ore reduction, steel production, and more +See the [Model Overview](./docs/user_guide/model_overview.md) for the current registered models and their categories. The [documentation](https://h2integrate.readthedocs.io/en/latest/intro.html) provides the project overview and links to model-specific guides. ## Getting Started diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 349f941f7..30b3ae111 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,8 +20,9 @@ GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submi * Keep style fixes to a separate commit to make your pull request more readable. * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). -* When you start working on a pull request, start by creating a new branch pointing at the latest - commit on [main](https://github.com/NatLabRockies/H2Integrate). +* Contributions are generally made against the `develop` branch. New models use the + [`@register` decorator](https://github.com/NatLabRockies/H2Integrate/blob/develop/h2integrate/core/supported_models.py); + see [Adding a New Technology](developer_guide/adding_a_new_technology.md) for the model requirements. * Code formatting is enforced using pre-commit hooks and is required for any code pushed up to the repository. The pre-commit package is included in the developer install of the repository. The pre-commit hooks can be installed by running ```bash pre-commit install @@ -76,22 +77,28 @@ jupytext notebook.md --to .ipynb ## Tests -The test suite can be run using `pytest tests/h2integrate`. Individual test files can be run by specifying them: +Run tests from the repository root in an environment with the development dependencies installed. +Every test must use one of the `unit`, `regression`, `integration`, or `hpc` markers. Collection +fails for unmarked tests. Common commands are: ```bash -pytest tests/h2integrate/test_hybrid.py +pytest -m unit +pytest -m regression +pytest -m integration +pytest -m hpc ``` -and individual tests can be run within those files +To run one test file or test function, provide its path and optional node ID: ```bash -pytest tests/h2integrate/test_hybrid.py::test_h2integrate_system +pytest h2integrate/core/test/test_utilities.py +pytest h2integrate/core/test/test_utilities.py::test_function_name ``` -When you push to your fork, or open a PR, your tests will be run against the -[Continuous Integration (CI)](https://github.com/NatLabRockies/H2Integrate/actions) suite. This will start a build -that runs all tests on your branch against multiple Python versions, and will also test -documentation builds. +See the [Testing Guide](developer_guide/testing.md) for test conventions, marker details, and +additional commands. Pull requests run the automated test suite through +[GitHub Actions](https://github.com/NatLabRockies/H2Integrate/actions). The documentation build is +also checked on every pull request by [Read the Docs](https://h2integrate.readthedocs.io/). ## Code Review Process diff --git a/docs/_config.yml b/docs/_config.yml index ba772c071..594721dd7 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,9 +1,9 @@ # Book settings # Learn more at https://jupyterbook.org/customize/config.html title: H2Integrate -author: National Renewable Energy Laboratory +author: NLR # logo: logo.png -copyright: '2025' +copyright: 2026, NLR only_build_toc_files: false # exclude_patterns: [_build, Thumbs.db, .DS_Store, "**.ipynb_checkpoints", .github] # Force re-execution of notebooks on each build. @@ -30,21 +30,30 @@ latex: repository: url: https://github.com/NatLabRockies/H2Integrate path_to_book: docs - branch: main + branch: develop # Add GitHub buttons to your book # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository html: use_issues_button: true use_repository_button: true - baseurl: https://nrel.github.io/H2Integrate/ + baseurl: https://h2integrate.readthedocs.io/en/latest/ sphinx: extra_extensions: - sphinx.ext.autodoc - sphinx.ext.autosummary - sphinx.ext.napoleon + - h2integrate.core.docs_sphinx config: language: python autosummary_generate: true + html_theme_options: + check_switcher: false + navbar_end: + - version-switcher + - navbar-icon-links + switcher: + json_url: https://h2integrate.readthedocs.io/en/latest/_static/switcher.json + version_match: latest autodoc_default_options: members: true member-order: bysource diff --git a/docs/_toc.yml b/docs/_toc.yml index aad60bffb..94562e793 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -125,10 +125,10 @@ parts: - file: developer_guide/class_structure - file: developer_guide/technology_model_types - file: developer_guide/testing - - file: developer_guide/features_not_currently_implemented - caption: Miscellaneous Resources chapters: - file: misc_resources/FAQ + - file: misc_resources/changelog - file: misc_resources/glossary - file: misc_resources/defining_filepaths - file: misc_resources/turbine_models_library_preprocessing diff --git a/docs/developer_guide/features_not_currently_implemented.md b/docs/developer_guide/features_not_currently_implemented.md deleted file mode 100644 index 71d899a1d..000000000 --- a/docs/developer_guide/features_not_currently_implemented.md +++ /dev/null @@ -1,11 +0,0 @@ -# Features not currently implemented - -There are many features that are not currently implemented in H2Integrate that are necessary for current or future projects. -These will become issues on the H2Integrate GitHub repo. -We are listing them here: - -- allowing different technologies within the same plant to be at different sites -- saving results elegantly for plant components so they do not always need to be rerun -- detailing the required inputs and outputs for different model types (e.g. electrolyzer) so that users can more easily add their own subsystems -- splitters and combiners for when resources are used or produced by multiple components -- simulating different timespans than one year at hourly timesteps (8760s) diff --git a/docs/developer_guide/why_make_h2integrate.md b/docs/developer_guide/why_make_h2integrate.md index fbe144930..54dfb9d22 100644 --- a/docs/developer_guide/why_make_h2integrate.md +++ b/docs/developer_guide/why_make_h2integrate.md @@ -1,53 +1,30 @@ -# Welcome to H2Integrate +# H2Integrate Design Rationale -This collection of docs is focused on presenting H2Integrate, acknowledging where we are now and where we want to be in the future. -Most of these docs are written in a way that we'll be able to reuse them immediately as proper docs in the actual H2Integrate repo. -For now, to reduce development overhead and encourage rapid iteration, we'll continue to work in this sandbox repo. +H2Integrate provides a framework for combining technology models into hybrid-system simulations, +technoeconomic analyses, and optimization studies. Its design emphasizes reusable model interfaces +and explicit connections between technologies. -Our goal with these doc pages is to communicate ideas that exist in our heads, put them to paper, and provide diagrams where helpful. -These docs are a work in progress; please ask questions about any and all parts presented here. +## Why modularity? -## Why make H2Integrate? - -H2Integrate has already proven itself useful as a tool that can analyze and design complex hybrid systems producing electricity, hydrogen, steel, and more. -However, these developments came in waves across multiple disparate projects, leading to a sometimes disjointed codebase. -In an effort to streamline and modularize H2Integrate to make it more effective and capable for necessary future studies, we are devoting time to redesigning it from the ground up. -This page discusses some of the high-level decisions and mindsets that we've used throughout this process. - -## What does it mean to "modularize" H2Integrate? - -Most of the current implementation of H2Integrate assumes that you are modeling a certain hybrid system architecture using a limited number of specific models. -As more projects call for using H2Integrate, we must make it easier to develop and add capability to the tool in a sustainable and clear way. -By making the internal framework of H2Integrate more agnostic to the technologies considered in the hybrid system design, we can allow for more user-defined modularized subsystems to be considered effectively. - -Getting into the details, this means creating generalized components that H2Integrate can expect to behave in a certain way. - -We introduce the ideas of "converters", which convert one resource into another. -Simple examples of converters include electrolyzers, wind turbines, solar PV panels, and more. -These converters can pass resources to other converters or "storage" components via "transporter" components. - -Transporters include hydrogen pipelines, electricity cables, or anything that transports a resource. -In a broad sense, these might include delivery trucks or shipping vessels, though those are more for future consideration. - -Storage components include batteries, hydrogen tanks; anything where you store a resource. - -By combining instances of these different generalized components, we can study distinct hybrid systems in H2Integrate. -Internally, the H2Integrate framework just needs to know if a something is a converter, transporter, storage, or some other type of component. -Additionally, this allows users to develop their own components using the expected interface, and add in custom subsystems to their hybrid plant. +Hybrid systems combine components with different physical behavior, cost assumptions, and operating +constraints. H2Integrate separates these concerns into model roles such as performance, cost, +finance, and control, and connects technologies through defined commodities and transport +components. Users can combine the registered models or add components that follow the framework's +interfaces. ## Why use OpenMDAO as the internal framework for H2Integrate? -Through a series of internal discussions, the H2Integrate dev team landed on using [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) for this new version of the tool. +H2Integrate uses [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/) to assemble and +run its models. -Using OpenMDAO for this gives quite a few benefits: +OpenMDAO provides: - a proven framework for complex data-passing within multidisciplinary systems - automatically-generated visualization tools to help understand models and debug them - internal units handling and conversion - built-in nonlinear solvers to resolve model coupling - built-in optimization and parameter sweep drivers - multiple existing NLR tools use OpenMDAO, including [WISDEM](https://github.com/NLRWindSystems/WISDEM/) and [WEIS](https://github.com/NLRWindSystems/WEIS), so we can draw from institutional knowledge -- set up with gradient-based optimization in mind, which is not currently a focus for H2Integrate but this positions the tool well for potential future additions -- parallelization done using MPI, which is also not currently a focus but useful for the future +- gradient-based optimization and MPI-based parallelization capabilities for applicable studies However, there are a few downsides to using OpenMDAO: - an additional layer of code that developers must consider @@ -55,24 +32,15 @@ However, there are a few downsides to using OpenMDAO: - potentially increased computational costs depending on problem type and size - it isn't great at optimizing mixed-integer problems -The benefits outweighed the downsides, hence the team's choice to use OpenMDAO going forward. -Additionally, we can code H2Integrate in a way to minimize some of the potential issues, given that we're aware of them before refactoring H2Integrate. - -## How are generation technologies handled today? - -H2Integrate now models generation technologies directly with native technology components -for wind, solar, storage, wave, and tidal systems. -This keeps technology behavior, dispatch interfaces, and cost modeling within a single -framework, with all components exposed consistently to the same configuration and -optimization infrastructure. - -## Where should code I develop and implement live? +These benefits support H2Integrate's component-level modeling and optimization goals. Contributors +should still consider OpenMDAO's additional abstraction layer, longer error traces, possible +computational overhead, and limitations with mixed-integer optimization. -Historically, H2Integrate has been a sort of hybrid itself, where it contains both the tool itself as well as project-specific code. -This has led to a somewhat disjointed codebase that is difficult to maintain and understand. +## Where should code live? -To address this, here are guidelines for where code you develop should live: -- If the code is specific to a project, it should live in the project's repository. -- If the code is generalizable, could be used in multiple projects, or is a useful addition to the H2Integrate tool, it should live in the H2Integrate repository. -- Wrappers to models that are used in H2Integrate should live in the H2Integrate repository. -- The actual models (physics, costs, financials) should live **outside** the H2Integrate repository. Complex models might live in their own repo (e.g. BERT), whereas other models might live in the project's repository. +- Reusable technology models, cost and finance models, and integrations that benefit multiple + projects belong in the H2Integrate repository. +- Project-specific or proprietary models belong in the project repository. They can be integrated + by implementing the interfaces expected by H2Integrate. +- For the model interfaces and contribution workflow, see + [Adding a New Technology](adding_a_new_technology.md). diff --git a/docs/getting_started/environment_variables.md b/docs/getting_started/environment_variables.md index 21f94609d..e2559cfd4 100644 --- a/docs/getting_started/environment_variables.md +++ b/docs/getting_started/environment_variables.md @@ -93,11 +93,16 @@ unset NLR_API_EMAIL (set-environment-variables-with-env-file)= ## Set Environment Variables with .env file -The ".env" file will be looked for in all of the following locations: - - H2Integrate root directory (`/path/to/H2Integrate/h2integrate/`) - - parent of H2Integrate root directory (`/path/to/H2Integrate/`) (preferred location to store your environment file) - - current working directory (this is not a preferred location to store your environment file) -1. Choose which of the above directories you want to host your .env file, and create a file named ".env" in that folder. +H2Integrate searches for a `.env` file in these locations, in order: + +1. The H2Integrate package directory (for a source checkout, `/path/to/H2Integrate/h2integrate/`). +2. The package directory's parent (for a source checkout, `/path/to/H2Integrate/`). +3. The current working directory. +4. The user's home directory. + +For a source checkout, the repository root is a convenient location. For a pip installation, the +package directory's parent is usually inside `site-packages`; use the home directory or current +working directory instead. Create a file named `.env` in the directory you choose. 2. Open the ".env" file and add the environment variables: ```bash NLR_API_KEY='api-key-value' diff --git a/docs/getting_started/install.md b/docs/getting_started/install.md index f78b34e84..cfec53dcc 100644 --- a/docs/getting_started/install.md +++ b/docs/getting_started/install.md @@ -8,6 +8,14 @@ If you just want to use H2Integrate and aren't developing new models, you should pip install h2integrate ``` +Pyomo is installed with H2Integrate, but solver software is installed separately. If you use a +Pyomo dispatch or optimization model, install at least one supported solver, such as GLPK, CBC, or +HiGHS. These are available from conda-forge: + +```bash +conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy +``` + ## Installing from Source If you want to develop new models or contribute to H2Integrate, you can install it from source. @@ -59,10 +67,10 @@ If you want to develop new models or contribute to H2Integrate, you can install 4. Create a conda environment and install H2Integrate and all its dependencies. Please read the following two notes about modified installation steps. - 1. If on a Unix machine, uncomment line 8 in `environment.yml` to install Cbc. Windows - users will need to manually install from . - 2. If you plan to use Ard, please uncomment line 9 in `environment.yml` to ensure WISDEM (an Ard - dependency) is installed from conda to avoid installation issues with some systems. + 1. The environment specification already installs GLPK, CBC, and HiGHS from conda-forge; no + solver lines need to be uncommented. + 2. If you plan to use Ard, uncomment the `wisdem` dependency in `environment.yml` so it is + installed from conda and avoids installation issues on some systems. ```bash conda env create -f environment.yml @@ -111,7 +119,7 @@ pip install -e ".[all]" 4. Install H2Integrate and its dependencies: ```bash - conda install -y -c conda-forge glpk coin-or-cbc>=2.10.12 highspy + conda install -y -c conda-forge glpk "coin-or-cbc>=2.10.12" highspy ``` - If you want to just use H2Integrate: diff --git a/docs/intro.md b/docs/intro.md index 0a9077217..5b8d21c10 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -9,15 +9,18 @@ H2Integrate (H2I) is an open-source Python package for hybrid systems engineering design and technoeconomic analysis. It models and optimizes hybrid energy plants that produce electricity, hydrogen, ammonia, steel, and other products, using high-resolution location-specific resource data to perform optimization and scenario analysis. +## About H2Integrate + +H2Integrate grew from projects that needed to model and analyze complex hybrid energy systems. It is now maintained as a reusable framework for composing technology performance, cost, finance, and control models, while still allowing users to add project-specific components. + Browse the example workflows in the GitHub repository: https://github.com/NatLabRockies/H2Integrate/tree/main/examples ## What is H2Integrate? -H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for various hybrid systems. -The tool currently includes renewable energy generation (land-based wind, offshore wind, solar PV, wave, tidal), conventional generation (natural gas combined cycle, combustion turbines, grid electricity), hydrogen production (PEM electrolysis, NG-SMR), energy storage (Li-ion batteries, long-duration energy storage, pumped storage hydropower), fuel cells, and industrial processes (ammonia synthesis, iron ore reduction, steel production, methanol, and more). -Other elements can also be included as developed by users. -H2Integrate is continually expanding to serve additional hybrid applications -- if you're interested in seeing what's being actively developed, please see the [current pull requests in the GitHub repository](https://github.com/NatLabRockies/H2Integrate/pulls). -Some modeling capabilities in H2Integrate are provided by integrating existing tools, such as [PySAM](https://github.com/NatLabRockies/pysam), [ORBIT](https://github.com/NLRWindSystems/ORBIT), and [ProFAST](https://github.com/NatLabRockies/ProFAST). +H2Integrate is designed to be flexible and extensible, allowing users to create their own components and models for hybrid systems. +Its registered models cover land-based wind and solar PV; wave, tidal, run-of-river hydro, natural gas, nuclear, and grid electricity; hydrogen production and storage; fuel cells; and industrial processes including ammonia, methanol, iron, and steel. +The [Model Overview](user_guide/model_overview.md) lists the current registered models, and model-specific pages describe their capabilities and configuration. +Some models integrate existing tools, including [PySAM](https://github.com/NatLabRockies/pysam) and [ProFAST](https://github.com/NatLabRockies/ProFAST). The H2Integrate tool is built on top of [NASA's OpenMDAO framework](https://github.com/OpenMDAO/OpenMDAO/), which provides a powerful and flexible environment for modeling and optimization. ```{note} From 542a012fecaad454404d0ace6bd13cfab58dc0ab Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 8 Oct 2026 11:45:46 -0600 Subject: [PATCH 16/29] Added sphinx docs file --- h2integrate/core/docs_sphinx.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 h2integrate/core/docs_sphinx.py diff --git a/h2integrate/core/docs_sphinx.py b/h2integrate/core/docs_sphinx.py new file mode 100644 index 000000000..a9b070ede --- /dev/null +++ b/h2integrate/core/docs_sphinx.py @@ -0,0 +1,23 @@ +import os +from importlib.metadata import version + +from sphinx.config import Config +from sphinx.application import Sphinx + + +def configure_version_switcher(app: Sphinx, config: Config) -> None: + readthedocs_version = os.environ.get("READTHEDOCS_VERSION", "latest") + if readthedocs_version == "develop": + readthedocs_version = "latest" + + theme_options = dict(config.html_theme_options or {}) + switcher_options = dict(theme_options.get("switcher", {})) + switcher_options["version_match"] = readthedocs_version + theme_options["switcher"] = switcher_options + theme_options["announcement"] = f"H2Integrate {version('h2integrate')} documentation" + config.html_theme_options = theme_options + + +def setup(app: Sphinx) -> dict[str, object]: + app.connect("config-inited", configure_version_switcher) + return {"version": "1.0", "parallel_read_safe": True} From 2be74d1442efc7f8c1b9f5a896e7dbddd169a145 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Fri, 9 Oct 2026 00:10:34 -0600 Subject: [PATCH 17/29] Fixed doc build --- docs/misc_resources/changelog.md | 2 ++ test/test_docs_consistency.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 docs/misc_resources/changelog.md diff --git a/docs/misc_resources/changelog.md b/docs/misc_resources/changelog.md new file mode 100644 index 000000000..3139cd42b --- /dev/null +++ b/docs/misc_resources/changelog.md @@ -0,0 +1,2 @@ +```{include} ../../CHANGELOG.md +``` diff --git a/test/test_docs_consistency.py b/test/test_docs_consistency.py index ce0ad4147..9c579bab4 100644 --- a/test/test_docs_consistency.py +++ b/test/test_docs_consistency.py @@ -51,7 +51,7 @@ } UNREFERENCED_EXAMPLES = {"04_geo_h2", "31_tidal", "36_nuclear_reactor_htse"} CUSTOM_MODEL_NAMES = {"CustomRiverResource", "SimpleLCOFinance"} -KNOWN_MISSING_PATHS = {("docs/CONTRIBUTING.md", "h2integrate/test_hybrid.py")} +KNOWN_MISSING_PATHS = set() def _markdown_pages() -> list[Path]: From 327ad8b09cfd4dd56becc6ca7637f5821b16d6c2 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 12 Oct 2026 18:05:35 -0600 Subject: [PATCH 18/29] Updating changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a424b7b9..6d6dbecdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased [TBD] + +- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR 917](https://github.com/NatLabRockies/H2Integrate/pull/917) + ## 0.10.0 [October 12, 2026] ### New Features From 76110a019579362ff67192d30cc755a2bd1578ed Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 12 Oct 2026 18:08:23 -0600 Subject: [PATCH 19/29] Merging --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c94f598..49c3ce731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased [TBD] - Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR 917](https://github.com/NatLabRockies/H2Integrate/pull/917) +- Added user references for PEM electrolysis, transport, FLORIS, configuration files, and additional registered models, with API and example links. [PR 918](https://github.com/NatLabRockies/H2Integrate/pull/918) ## 0.10.0 [October 12, 2026] From d4a404c367f07bcbff65c7492dd1c1b2c3021ff5 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Mon, 12 Oct 2026 18:20:26 -0600 Subject: [PATCH 20/29] Minor docs updates --- CHANGELOG.md | 2 -- docs/storage/storage_models_index.md | 5 +++-- docs/technology_models/floris_wind_plant.md | 9 +++++---- docs/technology_models/transport.md | 1 + docs/user_guide/plant_config_reference.md | 6 +----- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c3ce731..54d728c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,8 +70,6 @@ - Pinned `optiwindnet<0.3.1` in the `ard` extra because `optiwindnet` 0.3.1 is incompatible with `ard-nrel` 0.1.0b5. [PR 902](https://github.com/NatLabRockies/H2Integrate/pull/902) - Updates to all GH Actions, pre-commit, isort, and ruff versioning. [PR 904](https://github.com/NatLabRockies/H2Integrate/pull/904) - Updated the docs throughout, removing stale references and ensuring consistency with the latest model and configuration changes. [PR 910](https://github.com/NatLabRockies/H2Integrate/pull/910) -- Added user references for PEM electrolysis, transport, FLORIS, configuration files, and additional registered models, with API and example links. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) -- Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR TBD](https://github.com/NatLabRockies/H2Integrate/pull/TBD) ### Fixes diff --git a/docs/storage/storage_models_index.md b/docs/storage/storage_models_index.md index 32958493d..db81b50f5 100644 --- a/docs/storage/storage_models_index.md +++ b/docs/storage/storage_models_index.md @@ -79,8 +79,9 @@ The available storage cost models are: ### ATB battery cost model - `ATBBatteryCostModel` uses separate energy and power capital costs from the NLR Annual Technology - Baseline battery workbook. It derives storage duration from capacity and charge rate, calculates + `ATBBatteryCostModel` uses separate energy and power capital costs from the [NLR Annual Technology + Baseline utility-scale battery ATB](https://atb.nrel.gov/electricity/2024b/utility-scale_battery_storage). + It derives storage duration from capacity and charge rate, calculates CapEx from the energy and power cost inputs, and calculates annual OpEx as a fraction of CapEx. `commodity_rate_units` must be a power unit based on watts; the default amount unit is the rate unit multiplied by hours. diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md index 875f65acd..d1d82df00 100644 --- a/docs/technology_models/floris_wind_plant.md +++ b/docs/technology_models/floris_wind_plant.md @@ -1,10 +1,11 @@ # FLORIS Wind Plant -`FlorisWindPlantPerformanceModel` wraps the FLORIS wake model to calculate wind-farm electrical -output from wind resource data and turbine configuration. It supports a single turbine design per -farm and uses one-hour time steps. +`FlorisWindPlantPerformanceModel` wraps the [FLORIS](https://github.com/NatLabRockies/floris) wake +model to calculate wind-farm electrical output from wind resource data and turbine configuration. +It supports a single turbine design per farm and uses one-hour time steps. -The model requires a FLORIS wake configuration and a turbine configuration formatted for FLORIS. +The model requires a FLORIS wake configuration and a turbine configuration [formatted for +FLORIS](https://natlabrockies.github.io/floris/). The configurations can be included from YAML files as in [Example 26](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/26_floris). The `layout` configuration selects a layout generator and its options; do not also set turbine coordinates in `floris_wake_config.farm.layout_x` or `layout_y`, because the layout settings create diff --git a/docs/technology_models/transport.md b/docs/technology_models/transport.md index 2247681d2..e931d3091 100644 --- a/docs/technology_models/transport.md +++ b/docs/technology_models/transport.md @@ -85,6 +85,7 @@ connection syntax and stream variable names are covered in ## Transport cost models +- `LinearDistanceCostModel` calculates the geodesic distance between source and destination coordinates, then multiplies it by `capex_per_km` and `fixed_opex_per_km`. Connect site latitude and longitude to its `source_*` and `dest_*` inputs. - `LinearMassTransportCostModel` uses geodesic distance, an optional `circuity_ratio` (default 1), diff --git a/docs/user_guide/plant_config_reference.md b/docs/user_guide/plant_config_reference.md index 2b4e6256c..a2f523bc0 100644 --- a/docs/user_guide/plant_config_reference.md +++ b/docs/user_guide/plant_config_reference.md @@ -20,11 +20,7 @@ examples, and the [Technology Configuration Reference](technology_config_referen | `finance_parameters` | Finance groups, subgroups, and cost-year adjustment settings. | The plant schema currently validates the fields documented in -[`plant_schema.yaml`](../../h2integrate/core/inputs/plant_schema.yaml). The model also reads -`tech_to_dispatch_connections` and `system_level_control`, but these two keys are not currently -declared in that schema; they are therefore code-supported configuration fields that schema -validation does not check. - +[`plant_schema.yaml`](../../h2integrate/core/inputs/plant_schema.yaml). ## Sites Each site is identified by a user-chosen key. Latitude and longitude are required; elevation is From ac164e90c75cdbb956ee693fd6bc6da48f5ae63e Mon Sep 17 00:00:00 2001 From: John Jasa Date: Tue, 13 Oct 2026 12:57:51 -0600 Subject: [PATCH 21/29] Fixing consistency test --- test/test_docs_consistency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_docs_consistency.py b/test/test_docs_consistency.py index 9c579bab4..4274805fc 100644 --- a/test/test_docs_consistency.py +++ b/test/test_docs_consistency.py @@ -49,7 +49,7 @@ "SimpleGasConsumerPerformance", "SimpleGasConsumerCost", } -UNREFERENCED_EXAMPLES = {"04_geo_h2", "31_tidal", "36_nuclear_reactor_htse"} +UNREFERENCED_EXAMPLES = set() CUSTOM_MODEL_NAMES = {"CustomRiverResource", "SimpleLCOFinance"} KNOWN_MISSING_PATHS = set() From 0d9ee0680fe1d74c014a766f11588445c610e7f8 Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 14 Oct 2026 09:37:28 -0600 Subject: [PATCH 22/29] Updating and expanding docs based on PR feedback --- docs/_toc.yml | 1 + .../pyomo_controllers.md | 13 +- docs/storage/storage_models_index.md | 3 +- .../combiners_and_splitters.md | 156 ++++++++++++++++++ docs/technology_models/floris_wind_plant.md | 30 ++++ docs/technology_models/transport.md | 78 +-------- docs/user_guide/connecting_technologies.md | 7 +- docs/user_guide/driver_config_reference.md | 73 +++++++- docs/user_guide/how_to_set_up_an_analysis.md | 3 + docs/user_guide/plant_config_reference.md | 20 ++- docs/user_guide/postprocessing_results.md | 35 ---- .../user_guide/technology_config_reference.md | 4 +- 12 files changed, 302 insertions(+), 121 deletions(-) create mode 100644 docs/technology_models/combiners_and_splitters.md diff --git a/docs/_toc.yml b/docs/_toc.yml index 37980b3d3..3c369cea4 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -30,6 +30,7 @@ parts: - caption: Technology Models chapters: - file: technology_models/transport + - file: technology_models/combiners_and_splitters - file: technology_models/feedstocks - file: technology_models/marine_hydrokinetic - file: technology_models/run_of_river diff --git a/docs/control/technology_level_control/pyomo_controllers.md b/docs/control/technology_level_control/pyomo_controllers.md index 0c0f56bfa..baf3d887e 100644 --- a/docs/control/technology_level_control/pyomo_controllers.md +++ b/docs/control/technology_level_control/pyomo_controllers.md @@ -80,8 +80,17 @@ The optimized dispatch using Pyomo is implemented differently than the heuristic ```{note} Optimized dispatch passes a `pyomo_dispatch_solver` callable to a storage performance model. `StoragePerformanceBase` provides this interface, so optimized dispatch is not inherently limited -to the PySAM battery. Confirm that the selected storage model and controller support the requested -dispatch mode. +to the PySAM battery. To confirm that a storage model works with optimized dispatch: + +- Check that the storage `performance_model` is derived from `StoragePerformanceBase`; the models + listed in the [Storage Models](../../storage/storage_models_index.md) page are valid. +- Check that `tech_to_dispatch_connections` in `plant_config.yaml` lists the storage technology as the + dispatching technology, for example `[battery, battery]`. Only then does the storage model add the + `pyomo_dispatch_solver` input and run with feedback control. +- Check that the controller `commodity` matches the storage model `commodity`. + +[Example 30](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/30_pyomo_optimized_dispatch) +uses `PySAMBatteryPerformanceModel`. ``` ![](./figures/Pyomo_dispatch_figure.png) diff --git a/docs/storage/storage_models_index.md b/docs/storage/storage_models_index.md index db81b50f5..4c1d60d96 100644 --- a/docs/storage/storage_models_index.md +++ b/docs/storage/storage_models_index.md @@ -14,7 +14,8 @@ The following sections detail the inputs and outputs of the storage performance ### Automatic storage sizing `StorageAutoSizingModel` calculates the storage capacity and charge/discharge rate required to -balance a commodity production profile against demand, then simulates the storage operation. Set +balance a commodity production profile against demand, then simulates the storage operation. In the +`tech_config.yaml` set `set_demand_as_avg_commodity_in: true` to use average input as demand, or set it to `false` and provide `demand_profile`. The model requires either `round_trip_efficiency`, or both `charge_efficiency` and `discharge_efficiency`. `min_soc_fraction` and `max_soc_fraction` bound the diff --git a/docs/technology_models/combiners_and_splitters.md b/docs/technology_models/combiners_and_splitters.md new file mode 100644 index 000000000..ffe7cb9a2 --- /dev/null +++ b/docs/technology_models/combiners_and_splitters.md @@ -0,0 +1,156 @@ +# Combiners and Splitters + +Combiners merge several upstream streams into one, and splitters divide one upstream stream into +two. They are technologies declared in `tech_config.yaml`, and `plant_config.yaml` connects +other technologies to them as source or destination technologies in `technology_interconnections`. + +## Combiner + +`GenericCombinerPerformanceModel` sums one commodity from multiple sources into one output. It +accepts `commodity`, `commodity_rate_units`, and optional `in_streams` (defaults to 2). Set +`in_streams` to the number of incoming connections when combining more than two sources. + +```yaml +technologies: + electricity_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + in_streams: 3 +``` + +The combined commodity profile and rated production are sums of the input streams. The output +capacity factor is a rated-production-weighted average; it is zero when total rated production is +zero. Each input must provide its commodity profile, rated production, and capacity factor. + +In `plant_config.yaml`, connect each source to the combiner, then connect the combiner to whatever +uses the combined stream. The combiner's output is `{commodity}_out`. + +### Example: wind and natural gas + +[Example 23](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/23_solar_wind_ng_demand) +combines variable renewable generation with natural gas generation. The renewable generation is +first combined in `combiner`, and `fin_combiner` then adds the output of the natural gas plant so +that the total plant output can be used for finance calculations: + +```yaml +# tech_config.yaml +technologies: + fin_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW +``` + +```yaml +# plant_config.yaml +technology_interconnections: + - [combiner, fin_combiner, electricity, cable] + - [natural_gas_plant, fin_combiner, electricity, cable] +``` + +### Example: solar and battery + +[Example 24](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/24_solar_battery_grid) +combines the solar output with the battery output, so downstream technologies see the combined +electricity from both: + +```yaml +# tech_config.yaml +technologies: + bat_combiner: + performance_model: + model: GenericCombinerPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW +``` + +```yaml +# plant_config.yaml +technology_interconnections: + - [solar, battery, electricity, cable] + - [solar, bat_combiner, electricity, cable] + - [battery, bat_combiner, electricity, cable] + - [bat_combiner, electrical_load_demand, electricity, cable] +``` + +## Splitter + +`GenericSplitterPerformanceModel` takes one input and produces exactly two outputs. `out1` is the +priority branch; `out2` receives the remainder. Configure `commodity` and `commodity_rate_units` +under `model_inputs.performance_parameters`, together with one of these modes: + +- `split_mode: fraction` requires `fraction_to_priority_tech`, between 0 and 1. `out1` receives that + fraction of the input, and `out2` receives the rest. +- `split_mode: prescribed_commodity` requires `prescribed_commodity_to_priority_tech`, in the + configured commodity rate units. `out1` receives the requested amount, limited to the available + nonnegative input; `out2` receives the remainder. + +```yaml +technologies: + electricity_splitter: + performance_model: + model: GenericSplitterPerformanceModel + model_inputs: + performance_parameters: + commodity: electricity + commodity_rate_units: kW + split_mode: fraction + fraction_to_priority_tech: 0.7 +``` + +Connect the two downstream technologies to the splitter in the same order as the intended `out1` +and `out2` branches. + +### Example: splitting wind electricity + +[Example 17](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2) +splits wind electricity between a direct ocean capture plant and an electrolyzer. The first +downstream technology listed receives `out1`, and the second receives `out2`: + +```yaml +# plant_config.yaml +technology_interconnections: + - [wind, electricity_splitter, electricity, cable] + - [electricity_splitter, doc, electricity, cable] + - [electricity_splitter, electrolyzer, electricity, cable] +``` + +## Multivariable gas streams + +`GasStreamCombinerPerformanceModel` combines supported multivariable gas streams. It sums each +stream's mass flow and computes mass-weighted averages for intensive properties such as temperature, +pressure, and composition. Set `commodity` and `in_streams` in its performance parameters. The +connection syntax and stream variable names are covered in +{ref}`Connecting Technologies `. + +## API reference + +```{eval-rst} +.. autoclass:: h2integrate.transporters.generic_combiner.GenericCombinerPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.generic_splitter.GenericSplitterPerformanceConfig + :members: + :no-index: + +.. autoclass:: h2integrate.transporters.gas_stream_combiner.GasStreamCombinerPerformanceModelConfig + :members: + :no-index: +``` + +## Examples + +- [Example 17: Splitter, wind, and direct ocean capture](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2) +- [Example 23: Solar, wind, and natural gas with demand](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/23_solar_wind_ng_demand) +- [Example 24: Solar, battery, and grid](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/24_solar_battery_grid) +- [Example 32: Multivariable gas streams](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/32_multivariable_streams) diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md index d1d82df00..6b065e6d5 100644 --- a/docs/technology_models/floris_wind_plant.md +++ b/docs/technology_models/floris_wind_plant.md @@ -21,6 +21,36 @@ cache location when caching is enabled. Cache entries are based on the model con simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse is not desired. +## Turbine file preprocessing + +The `h2integrate.preprocess.wind_turbine_file_tools` module can create turbine files from the +[turbine-models](https://github.com/NatLabRockies/turbine-models) library, so you do not have to +write them by hand: + +- `export_turbine_to_floris_format(turbine_name)` writes a FLORIS turbine YAML file (named + `floris_turbine_{turbine_name}.yaml` by default) to the H2I library folder, or to `output_folder` + if given. Use its path as the FLORIS turbine configuration for this model. +- `FlorisTurbineDefaults` holds the defaults (`TSR`, `ref_air_density`, `ref_tilt`, and the + cosine loss exponents) that fill in values FLORIS requires but are not provided by the turbine-models + library. Pass a `FlorisTurbineDefaults` object or a dictionary as `floris_defaults` to override them. +- `export_turbine_to_pysam_format(turbine_name)` does the same for the PySAM Windpower model used + by `PYSAMWindPlantPerformanceModel`. + +See [Turbine Models Library Pre-Processing](../misc_resources/turbine_models_library_preprocessing.md) +for a worked example that exports a turbine, updates the `tech_config`, and runs Example 26. + +```{eval-rst} +.. autofunction:: h2integrate.preprocess.wind_turbine_file_tools.export_turbine_to_floris_format + :no-index: + +.. autoclass:: h2integrate.preprocess.wind_turbine_file_tools.FlorisTurbineDefaults + :members: + :no-index: + +.. autofunction:: h2integrate.preprocess.wind_turbine_file_tools.export_turbine_to_pysam_format + :no-index: +``` + ```{eval-rst} .. autoclass:: h2integrate.converters.wind.floris.FlorisWindPlantPerformanceModelConfig :members: diff --git a/docs/technology_models/transport.md b/docs/technology_models/transport.md index e931d3091..a8dfc65aa 100644 --- a/docs/technology_models/transport.md +++ b/docs/technology_models/transport.md @@ -21,67 +21,10 @@ nitrogen, natural gas, wellhead gas, water, oxygen, lignin, and diesel. Both are pass-through components; add a separate cost model if transport cost is needed. `GenericTransporterPerformanceModel` is a configurable pass-through for a commodity and its rate -units. It supports time steps from 1 second to $10^9$ seconds, unlike several production models -that require hourly simulation. +units. It supports time steps from 1 second to $10^9$ seconds. -## Combiner - -`GenericCombinerPerformanceModel` sums one commodity from multiple sources into one output. It -accepts `commodity`, `commodity_rate_units`, and optional `in_streams` (default 2). Set -`in_streams` to the number of incoming connections when combining more than two sources. - -```yaml -technologies: - electricity_combiner: - performance_model: - model: GenericCombinerPerformanceModel - model_inputs: - performance_parameters: - commodity: electricity - commodity_rate_units: kW - in_streams: 3 -``` - -The combined commodity profile and rated production are sums of the input streams. The output -capacity factor is a rated-production-weighted average; it is zero when total rated production is -zero. Each input must provide its commodity profile, rated production, and capacity factor. - -## Splitter - -`GenericSplitterPerformanceModel` takes one input and produces exactly two outputs. `out1` is the -priority branch; `out2` receives the remainder. Configure `commodity` and `commodity_rate_units` -under `model_inputs.performance_parameters`, together with one of these modes: - -- `split_mode: fraction` requires `fraction_to_priority_tech`, between 0 and 1. `out1` receives that - fraction of the input, and `out2` receives the rest. -- `split_mode: prescribed_commodity` requires `prescribed_commodity_to_priority_tech`, in the - configured commodity rate units. `out1` receives the requested amount, limited to the available - nonnegative input; `out2` receives the remainder. - -```yaml -technologies: - electricity_splitter: - performance_model: - model: GenericSplitterPerformanceModel - model_inputs: - performance_parameters: - commodity: electricity - commodity_rate_units: kW - split_mode: fraction - fraction_to_priority_tech: 0.7 -``` - -Connect the two downstream technologies to the splitter in the same order as the intended `out1` -and `out2` branches. The splitter does not combine sources; use a combiner upstream when multiple -technologies supply its input. - -## Multivariable gas streams - -`GasStreamCombinerPerformanceModel` combines supported multivariable gas streams. It sums each -stream's mass flow and computes mass-weighted averages for intensive properties such as temperature, -pressure, and composition. Set `commodity` and `in_streams` in its performance parameters. The -connection syntax and stream variable names are covered in -{ref}`Connecting Technologies `. +Combiners and splitters are documented separately in +[Combiners and Splitters](combiners_and_splitters.md). ## Transport cost models @@ -97,18 +40,6 @@ connection syntax and stream variable names are covered in :members: :no-index: -.. autoclass:: h2integrate.transporters.generic_combiner.GenericCombinerPerformanceConfig - :members: - :no-index: - -.. autoclass:: h2integrate.transporters.generic_splitter.GenericSplitterPerformanceConfig - :members: - :no-index: - -.. autoclass:: h2integrate.transporters.gas_stream_combiner.GasStreamCombinerPerformanceModelConfig - :members: - :no-index: - .. autoclass:: h2integrate.transporters.linear_transport_cost.LinearTransportCostConfig :members: :no-index: @@ -120,5 +51,4 @@ connection syntax and stream variable names are covered in ## Examples -- [Example 17: Splitter, wind, and direct ocean capture](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/17_splitter_wind_doc_h2) -- [Example 32: Multivariable gas streams](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/32_multivariable_streams) +- [Example 22: Site DOE with transport cost](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/22_site_doe) diff --git a/docs/user_guide/connecting_technologies.md b/docs/user_guide/connecting_technologies.md index 3b86f592e..1a87408ed 100644 --- a/docs/user_guide/connecting_technologies.md +++ b/docs/user_guide/connecting_technologies.md @@ -226,6 +226,7 @@ See [Example 32](https://github.com/NatLabRockies/H2Integrate/tree/main/examples (transport-connections)= ## Combiners, splitters, and transporters -The `cable`, `pipe`, generic transporter, combiner, and splitter models are documented in -[Transport Components](../technology_models/transport.md). This page focuses on how to express -their connections in `technology_interconnections`. +The `cable`, `pipe`, and generic transporter models are documented in +[Transport Components](../technology_models/transport.md), and the combiner and splitter models are +documented in [Combiners and Splitters](../technology_models/combiners_and_splitters.md). This page +focuses on how to express their connections in `technology_interconnections`. diff --git a/docs/user_guide/driver_config_reference.md b/docs/user_guide/driver_config_reference.md index f70a5a154..08ddd1558 100644 --- a/docs/user_guide/driver_config_reference.md +++ b/docs/user_guide/driver_config_reference.md @@ -20,13 +20,72 @@ options. The `parameter_sweep` block selects a generator and its inputs, such as `levels`, or a CSV `filename`. See [Design Optimization](design_optimization_in_h2i.md) and [Parameter Sweeps](parameter_sweep_in_h2i.md) for workflows and examples. +An optimization driver config selects a solver, then lists design variables, an objective, and +optionally constraints. This is the driver config from +[Example 5](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/05_wind_h2_opt): + +```{literalinclude} ../../examples/05_wind_h2_opt/driver_config.yaml +:language: yaml +:linenos: true +``` + +A parameter sweep uses the `parameter_sweep` block instead. This is the driver config from +[Example 22](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/22_site_doe), which +evaluates the cases listed in a CSV file (`csvgen` generator): + +```{literalinclude} ../../examples/22_site_doe/driver_config.yaml +:language: yaml +:linenos: true +``` + ## Design variables, objective, and constraints `design_variables` and `constraints` are grouped by technology or finance-subgroup name, then by OpenMDAO variable name. Set `flag` to enable each entry and provide its `units`; design variables may also specify bounds and scaling, while constraints require at least one of `lower`, `upper`, or -`equals`. The single `objective` identifies the full OpenMDAO variable name to minimize. To -maximize a result, minimize its negative using the objective's `ref` scaling as appropriate. +`equals`. The single `objective` identifies the full OpenMDAO variable name to minimize. + +The top-level key under `design_variables` and `constraints` is a technology name from +`tech_config.yaml` (or a finance subgroup name from `plant_config.yaml`), and the key beneath it is +the variable name. Together they form the full OpenMDAO variable name, `{technology}.{variable}`. For +example, this design variable is `electrolyzer.n_clusters`, and this constraint is +`electrical_load_demand.percent_load_missed`: + +```yaml +design_variables: + electrolyzer: # technology name in tech_config.yaml + n_clusters: # variable name + flag: true + lower: 2. + upper: 40. + units: unitless +constraints: + electrical_load_demand: + percent_load_missed: + flag: true + upper: 5. + units: percent +``` + +To find the correct variable name, run the model once and look at the results table that is printed +at the end of the run. The `prom_name` column lists the full variable names, and the technology and +variable name are the parts before and after the first period (see +[Postprocessing Results](postprocessing_results.md)). Design variables are inputs to a model, such as +the parameters you set under `model_inputs` in `tech_config.yaml`, and constraints are usually model +outputs. [Design Optimization](design_optimization_in_h2i.md) has more detail. + +The objective is given as the full variable name, with an optional `ref`. The `ref` is the reference +value that the driver uses to scale the objective to a magnitude of 1, which can help the optimizer +converge. The driver always minimizes, so to maximize a result, use a negative `ref`. In the example +below, the `ref` value of 10.0 means that the optimizer scales the objective so that an LCOH of 10.0 +is treated as 1. This allows the user to control the scaling of the objective and helps provide a +more well-posed optimization problem. + +```yaml +objective: + name: finance_subgroup_hydrogen.LCOH + ref: 10.0 # use a negative value, such as -1.0, to maximize instead +``` ## Recorder @@ -35,6 +94,16 @@ Set `recorder.flag` to enable an SQL recorder. The `file` name is written inside unique suffix is used. `recorder_attachment` may be `driver` or `model` (the driver is recommended for parallel runs). +```yaml +recorder: + flag: true + file: cases.sql + overwrite_recorder: true + recorder_attachment: driver + includes: ['*'] + excludes: ['*resource_data'] +``` + | Field | Purpose | |---|---| | `includes` | Glob patterns selecting variables to record. Defaults to `['*']`. | diff --git a/docs/user_guide/how_to_set_up_an_analysis.md b/docs/user_guide/how_to_set_up_an_analysis.md index 4e5848c24..1de759166 100644 --- a/docs/user_guide/how_to_set_up_an_analysis.md +++ b/docs/user_guide/how_to_set_up_an_analysis.md @@ -62,6 +62,7 @@ If you are running a basic analysis and not an optimization, the driver config f If you are running an optimization, the driver config file will contain additional keys to define the optimization settings, including design variables, constraints, and objective functions. Further details of more complex instances of the driver config file can be found in more advanced examples as they are developed. +See the [Driver Configuration Reference](driver_config_reference.md) for a description of every driver config field, and [Design Optimization](design_optimization_in_h2i.md) and [Parameter Sweeps](parameter_sweep_in_h2i.md) for complete workflows. ## Technology config file @@ -88,6 +89,7 @@ Model inputs vary by model. Configuration classes may provide defaults for some require others. Use [`populate_tech_yaml`](populate_tech_yaml.md) to generate a configuration template, and see the model documentation in the [API documentation](../api.md) for parameter details and valid values. The available models are registered with the `@register` decorator from `h2integrate/core/supported_models.py`; see the {ref}`technology models overview ` for the full list. +See the [Technology Configuration Reference](technology_config_reference.md) for a description of the technology config fields. ```{note} Every technology group also contains a controller that converts a `{commodity}_demand` signal into the `{commodity}_set_point` consumed by the performance model. If you do not specify a `control_strategy` for a technology, H2Integrate automatically inserts an implicit passthrough controller that simply maps demand to set-point. See the [technology-level control overview](../control/technology_level_control/technology_control_overview.md) for more details. @@ -115,6 +117,7 @@ If the list is length 4, then the third entry in the list is what's being passed If the list is length 3, then the third entry in the list is what is connected directly between the technologies. The `site_to_tech_connections` section defines how resources (like wind or solar data) are connected to the technologies that use them. +See the [Plant Configuration Reference](plant_config_reference.md) for a description of every plant config field. ```{note} For more information on how to define and interpret technology interconnections, see the {ref}`connecting_technologies:overview` page. diff --git a/docs/user_guide/plant_config_reference.md b/docs/user_guide/plant_config_reference.md index a2f523bc0..47d538b18 100644 --- a/docs/user_guide/plant_config_reference.md +++ b/docs/user_guide/plant_config_reference.md @@ -29,6 +29,18 @@ entry specifies a registered `resource_model` and its `resource_parameters`; the user-chosen name, not the model class name. Use `site_to_tech_connections` to connect site location or resource data to technologies. +```yaml +sites: + site_A: # user-chosen site name + latitude: 35.2018863 + longitude: -101.945027 + resources: + wind_resource: # user-chosen resource name + resource_model: WTKNLRDeveloperAPIWindResource + resource_parameters: + resource_year: 2012 +``` + Resource models have model-specific data ranges and time-step requirements. Some API-backed wind and solar resources support multiple years; see the [resource index](../resource/resource_index.md) and the wind and solar resource pages. @@ -58,13 +70,15 @@ requested simulation period must be compatible with the connected resource data. `technology_interconnections` lists source, destination, commodity or stream, and optionally a transporter. For example, `- [wind, battery, electricity, cable]` connects wind electricity to a battery through a cable. See [Connecting Technologies](connecting_technologies.md) for connection -forms and [Transport Components](../technology_models/transport.md) for cable, pipe, combiner, and -splitter behavior. +forms, [Transport Components](../technology_models/transport.md) for cable and pipe behavior, and +[Combiners and Splitters](../technology_models/combiners_and_splitters.md) for combiner and splitter +behavior. `site_to_tech_connections` connects a named site or resource to a technology. Resource data is typically connected with a three-element entry such as `[site.wind_resource, wind, wind_resource_data]`; site coordinates can also be mapped to technology -inputs. The site guide covers the supported forms and resource variable names. +inputs. See [Defining Sites and Connecting Resources](defining_sites_and_resources.md) for the +supported forms and resource variable names. `tech_to_dispatch_connections` is used when dispatch rules need information from technologies upstream of a dispatch controller. Each pair identifies a source technology and the technology diff --git a/docs/user_guide/postprocessing_results.md b/docs/user_guide/postprocessing_results.md index 27b25cd07..cd7af7424 100644 --- a/docs/user_guide/postprocessing_results.md +++ b/docs/user_guide/postprocessing_results.md @@ -218,41 +218,6 @@ The returned mapping has `inputs`, `explicit_outputs`, and `implicit_outputs` en :no-index: ``` -## Plotting energy and hydrogen flows - -The plotting helpers in `h2integrate.tools.plot` read the production `energy_flows.csv` file. They -accept start and end datetimes, a save path, and flags to display or save the figure. The default -input path is `./output/data/production/energy_flows.csv`. - -```python -import datetime as dt - -from h2integrate.tools.plot import plot_energy_flows, plot_hydrogen_flows - -plot_energy_flows( - energy_flow_data_path="output/data/production/energy_flows.csv", - start_date_time=dt.datetime(2024, 1, 5), - end_date_time=dt.datetime(2024, 1, 10), - show_fig=False, -) -plot_hydrogen_flows( - energy_flow_data_path="output/data/production/energy_flows.csv", - show_fig=False, -) -``` - -`plot_energy_flows` plots electricity generation and battery charge/discharge. -`plot_hydrogen_flows` plots hydrogen production, demand, and storage state of charge. The CSV must -contain the columns expected by the selected plotting function. - -```{eval-rst} -.. autofunction:: h2integrate.tools.plot.plot_energy_flows - :no-index: - -.. autofunction:: h2integrate.tools.plot.plot_hydrogen_flows - :no-index: -``` - ## ProFAST helpers The ProFAST utilities create and populate a `ProFAST` object from a configuration mapping, run its diff --git a/docs/user_guide/technology_config_reference.md b/docs/user_guide/technology_config_reference.md index a5ea2bac6..1ba6012a0 100644 --- a/docs/user_guide/technology_config_reference.md +++ b/docs/user_guide/technology_config_reference.md @@ -3,7 +3,9 @@ The technology configuration declares each technology, its model classes, and model-specific inputs. It is referenced from the top-level configuration with `technology_config`. The schema is maintained in [`tech_schema.yaml`](../../h2integrate/core/inputs/tech_schema.yaml), and -[`populate_tech_yaml`](populate_tech_yaml.md) can generate model input templates. +[`populate_tech_yaml`](populate_tech_yaml.md) can generate model input templates. The parameters +that each model accepts are documented in the [API documentation](../api.md), and the +{ref}`technology models overview ` lists the available models. ## Technology entries From 82bdc05eef90c827ff173bd7b5c7e7e92f527b3f Mon Sep 17 00:00:00 2001 From: John Jasa Date: Wed, 14 Oct 2026 10:06:46 -0600 Subject: [PATCH 23/29] Added a note about the recorder excludes vs includes --- docs/user_guide/driver_config_reference.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/user_guide/driver_config_reference.md b/docs/user_guide/driver_config_reference.md index 08ddd1558..9972db445 100644 --- a/docs/user_guide/driver_config_reference.md +++ b/docs/user_guide/driver_config_reference.md @@ -112,5 +112,11 @@ recorder: | `options_excludes` | Component option names to omit when attached to the model. | | `record_desvars`, `record_objectives`, `record_constraints`, `record_derivative` | Select driver data; used when attached to the driver. | +```{note} +The `excludes` supersede the `includes`. A variable that matches both an `includes` pattern and +an `excludes` pattern is not recorded. For example, with `includes: ['*']` and +`excludes: ['*resource_data']`, everything except the resource data is recorded. +``` + The schema is maintained in [`driver_schema.yaml`](../../h2integrate/core/inputs/driver_schema.yaml). See [Recording and Loading Data](recording_and_loading_data.md) for working recorder examples. From df7b3c48dc2874c795eec25891283b54c3db8ad5 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:49:07 -0600 Subject: [PATCH 24/29] updated wind model docs --- docs/technology_models/floris_wind_plant.md | 4 ++-- docs/technology_models/windpower_wind_plant.md | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md index 6b065e6d5..b8b898383 100644 --- a/docs/technology_models/floris_wind_plant.md +++ b/docs/technology_models/floris_wind_plant.md @@ -1,6 +1,6 @@ # FLORIS Wind Plant -`FlorisWindPlantPerformanceModel` wraps the [FLORIS](https://github.com/NatLabRockies/floris) wake +`FlorisWindPlantPerformanceModel` wraps the [FLORIS](https://github.com/NatLabRockies/floris) wind farm model to calculate wind-farm electrical output from wind resource data and turbine configuration. It supports a single turbine design per farm and uses one-hour time steps. @@ -16,7 +16,7 @@ Resource data is supplied through the plant's site resource configuration and hub height using `resource_data_averaging_method` (`weighted_average`, `average`, or `nearest`). Turbulence intensity can be taken from the resource data or supplied as a default. -FLORIS calculations can be expensive, so caching is enabled by default. `cache_dir` selects the +FLORIS calculations can be expensive for larger wind farms and more complicated wake models, so caching is enabled by default. `cache_dir` selects the cache location when caching is enabled. Cache entries are based on the model configuration and simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse is not desired. diff --git a/docs/technology_models/windpower_wind_plant.md b/docs/technology_models/windpower_wind_plant.md index 066936b3b..f07c6a3a7 100644 --- a/docs/technology_models/windpower_wind_plant.md +++ b/docs/technology_models/windpower_wind_plant.md @@ -145,3 +145,14 @@ The model automatically formats wind resource data from the H2I wind resource da - Wind direction in degrees east of north The model handles interpolation between available resource heights to match the specified hub height and uses the closest available data when exact matches are not available. + +## Turbine file preprocessing +The `h2integrate.preprocess.wind_turbine_file_tools` module can create turbine files from the +[turbine-models](https://github.com/NatLabRockies/turbine-models) library, so you do not have to +write them by hand: + +- `export_turbine_to_pysam_format(turbine_name)` writes a PySAM options turbine YAML file (named `pysam_options_{turbine_name}.yaml` by default) to the H2I library folder, or to `output_folder` +if given. Use its path as the `pysam_options` turbine configuration input for this model. + +See [Turbine Models Library Pre-Processing](../misc_resources/turbine_models_library_preprocessing.md) +for a worked example that exports a turbine, updates the `tech_config`, and runs [Example 26](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/26_floris). From 5cf8c3240461de8f207ce744a358beb85ce5e451 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:45:44 -0600 Subject: [PATCH 25/29] added more detail to floris doc page --- docs/technology_models/floris_wind_plant.md | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md index b8b898383..5f40e2277 100644 --- a/docs/technology_models/floris_wind_plant.md +++ b/docs/technology_models/floris_wind_plant.md @@ -21,6 +21,38 @@ cache location when caching is enabled. Cache entries are based on the model con simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse is not desired. +The `tech_config` performance parameters for this model are detailed [below](#floris-performance-parameters). + +(floris-performance-parameters)= +## Performance Parameters + +- `num_turbines` (required): number of wind turbines in the wind farm. +- `floris_wake_config` (required): dictionary containing wake parameters and options formatted for [FLORIS](https://natlabrockies.github.io/floris/wake_models.html). An example is provided in the H2I library named [`floris_v4_default_template.yaml`](https://github.com/NatLabRockies/H2Integrate/blob/develop/library/floris_v4_default_template.yaml) +- `floris_turbine_config` (required): dictionary containing wind turbine design information in the [FLORIS format](https://natlabrockies.github.io/floris/input_reference_turbine.html). Some examples are provided in the [H2I library](https://github.com/NatLabRockies/H2Integrate/tree/develop/library), with filenames starting with `floris_turbine_`. Please see the [section below](turbine-file-preprocessing) on some tips to create these files. +- `default_turbulence_intensity` (required): default turbulence intensity to use if not available in the wind resoure data, should be represented as a fraction (a value between 0 and 1). +- `operational_losses` (required): operational losses of the wind farm as a percentage (between 0 and 100). +- `hub_height` (optional): wind turbine hub height in meters. Can use -1 to indicate that the hub_height should be pulled from the `floris_turbine_config` +- `adjust_air_density_for_elevation` (optional): whether to adjust the air density for the elevation of the site (requires that elevation is available in the wind resource data). Defaults to False. +- `resource_data_averaging_method` (optional): The method to use to adjust or select resource data if no resource data is available at a height exactly equal to the turbine hub-height. Defaults to `'weighted_average'`. The available options are: + - `'weighted_average`: average the resource data at the heights that most closelu bound the hub-height, weighted by the difference between the resource heights and the hub-height. + - `'average'`: average the resource data at the heights that most closely bound the hub-height. + - `'nearest'`: use the resource data at the height closest to the hub-height. +- `operation_model`: FLORIS turbine operation model, defaults to `'cosine-loss'`. +- `layout` (optional): configuration for wind turbine layout within the wind farm + - `layout_mode` (str): currently only `"basicgrid"` is supported + - `layout_options` (dict): options specific to the layout mode + - For `"basicgrid"` mode: + - `row_D_spacing` (float): rotor diameter multiplier for spacing between rows of + turbines (y direction). + - `rotation_angle_deg` (float, Optional): rotation angle of layout in degrees where 0 is North, increasing counter-clockwise. 90 degrees is East, 180 degrees is South, + 270 degrees is West. Defaults to 0.0. + - `row_phase_offset` (float, Optional): offset of turbines along row from one row to the next. Value must be between 0 and 1. Defaults to 0.0. + - `layout_shape` (str, optional): layout shape with respect to the number of turbines. Must be either 'square' or 'rectangle'. Defaults to 'square'. + - `turbine_aspect_ratio` (float, optional): ratio of number turbines per row / number of rows if using a 'rectangle' layout_shape. Unused for 'square' layout_shape. +- `enable_caching` (optional): whether to save and FLORIS performance results to a file to future usage and whether to use previously cached performance results. The cache filename is based on the resource data, configuration inputs, and the openmdao inputs. +- `cache_dir` (optional): directory to save cached floris results to. + +(turbine-file-preprocessing)= ## Turbine file preprocessing The `h2integrate.preprocess.wind_turbine_file_tools` module can create turbine files from the From 47432c1de751d57ce14a1bbbfcc2dded24bcefa7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:56:46 -0600 Subject: [PATCH 26/29] changed how I added info --- docs/technology_models/floris_wind_plant.md | 38 +++------------------ 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/docs/technology_models/floris_wind_plant.md b/docs/technology_models/floris_wind_plant.md index 5f40e2277..25d363bcb 100644 --- a/docs/technology_models/floris_wind_plant.md +++ b/docs/technology_models/floris_wind_plant.md @@ -4,8 +4,9 @@ model to calculate wind-farm electrical output from wind resource data and turbine configuration. It supports a single turbine design per farm and uses one-hour time steps. -The model requires a FLORIS wake configuration and a turbine configuration [formatted for -FLORIS](https://natlabrockies.github.io/floris/). +The model requires a FLORIS wake configuration and a turbine configuration [formatted for FLORIS](https://natlabrockies.github.io/floris/). The `floris_wake_config` is a dictionary containing the [FLORIS wake parameters](https://natlabrockies.github.io/floris/wake_models.html), with an example in the H2I library named [`floris_v4_default_template.yaml`](https://github.com/NatLabRockies/H2Integrate/blob/develop/library/floris_v4_default_template.yaml). The `floris_turbine_config` is a dictionary containing [FLORIS wind turbine design information](https://natlabrockies.github.io/floris/input_reference_turbine.html), with some examples provided in the [H2I library](https://github.com/NatLabRockies/H2Integrate/tree/develop/library), with filenames starting with `floris_turbine_`. Please see the [section below](turbine-file-preprocessing) on some tips to create the floris turbine input files. + + The configurations can be included from YAML files as in [Example 26](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/26_floris). The `layout` configuration selects a layout generator and its options; do not also set turbine coordinates in `floris_wake_config.farm.layout_x` or `layout_y`, because the layout settings create @@ -14,44 +15,13 @@ those coordinates. Multiple turbine designs in the same farm are not currently s Resource data is supplied through the plant's site resource configuration and `site_to_tech_connections`. The model selects or averages the resource heights around the turbine hub height using `resource_data_averaging_method` (`weighted_average`, `average`, or `nearest`). -Turbulence intensity can be taken from the resource data or supplied as a default. +Turbulence intensity can be taken from the resource data or supplied as a default (defined as a fraction, with a value between 0 and 1). Additional farm losses are applied based on the `operational_losses` input (which is represented as a percent, with a value between 0 and 100) FLORIS calculations can be expensive for larger wind farms and more complicated wake models, so caching is enabled by default. `cache_dir` selects the cache location when caching is enabled. Cache entries are based on the model configuration and simulation inputs; disable caching with `enable_caching: false` when debugging or when cache reuse is not desired. -The `tech_config` performance parameters for this model are detailed [below](#floris-performance-parameters). - -(floris-performance-parameters)= -## Performance Parameters - -- `num_turbines` (required): number of wind turbines in the wind farm. -- `floris_wake_config` (required): dictionary containing wake parameters and options formatted for [FLORIS](https://natlabrockies.github.io/floris/wake_models.html). An example is provided in the H2I library named [`floris_v4_default_template.yaml`](https://github.com/NatLabRockies/H2Integrate/blob/develop/library/floris_v4_default_template.yaml) -- `floris_turbine_config` (required): dictionary containing wind turbine design information in the [FLORIS format](https://natlabrockies.github.io/floris/input_reference_turbine.html). Some examples are provided in the [H2I library](https://github.com/NatLabRockies/H2Integrate/tree/develop/library), with filenames starting with `floris_turbine_`. Please see the [section below](turbine-file-preprocessing) on some tips to create these files. -- `default_turbulence_intensity` (required): default turbulence intensity to use if not available in the wind resoure data, should be represented as a fraction (a value between 0 and 1). -- `operational_losses` (required): operational losses of the wind farm as a percentage (between 0 and 100). -- `hub_height` (optional): wind turbine hub height in meters. Can use -1 to indicate that the hub_height should be pulled from the `floris_turbine_config` -- `adjust_air_density_for_elevation` (optional): whether to adjust the air density for the elevation of the site (requires that elevation is available in the wind resource data). Defaults to False. -- `resource_data_averaging_method` (optional): The method to use to adjust or select resource data if no resource data is available at a height exactly equal to the turbine hub-height. Defaults to `'weighted_average'`. The available options are: - - `'weighted_average`: average the resource data at the heights that most closelu bound the hub-height, weighted by the difference between the resource heights and the hub-height. - - `'average'`: average the resource data at the heights that most closely bound the hub-height. - - `'nearest'`: use the resource data at the height closest to the hub-height. -- `operation_model`: FLORIS turbine operation model, defaults to `'cosine-loss'`. -- `layout` (optional): configuration for wind turbine layout within the wind farm - - `layout_mode` (str): currently only `"basicgrid"` is supported - - `layout_options` (dict): options specific to the layout mode - - For `"basicgrid"` mode: - - `row_D_spacing` (float): rotor diameter multiplier for spacing between rows of - turbines (y direction). - - `rotation_angle_deg` (float, Optional): rotation angle of layout in degrees where 0 is North, increasing counter-clockwise. 90 degrees is East, 180 degrees is South, - 270 degrees is West. Defaults to 0.0. - - `row_phase_offset` (float, Optional): offset of turbines along row from one row to the next. Value must be between 0 and 1. Defaults to 0.0. - - `layout_shape` (str, optional): layout shape with respect to the number of turbines. Must be either 'square' or 'rectangle'. Defaults to 'square'. - - `turbine_aspect_ratio` (float, optional): ratio of number turbines per row / number of rows if using a 'rectangle' layout_shape. Unused for 'square' layout_shape. -- `enable_caching` (optional): whether to save and FLORIS performance results to a file to future usage and whether to use previously cached performance results. The cache filename is based on the resource data, configuration inputs, and the openmdao inputs. -- `cache_dir` (optional): directory to save cached floris results to. - (turbine-file-preprocessing)= ## Turbine file preprocessing From cf9dd1bfb67bb5009f0d553ac5c56a733239083a Mon Sep 17 00:00:00 2001 From: John Jasa Date: Thu, 15 Oct 2026 09:59:53 -0600 Subject: [PATCH 27/29] Addressing Elenya's comments on profast docs and more --- .../combiners_and_splitters.md | 39 +++++++++ docs/user_guide/postprocessing_results.md | 87 +++++++++++++++++-- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/docs/technology_models/combiners_and_splitters.md b/docs/technology_models/combiners_and_splitters.md index ffe7cb9a2..5c3331ebc 100644 --- a/docs/technology_models/combiners_and_splitters.md +++ b/docs/technology_models/combiners_and_splitters.md @@ -26,6 +26,45 @@ The combined commodity profile and rated production are sums of the input stream capacity factor is a rated-production-weighted average; it is zero when total rated production is zero. Each input must provide its commodity profile, rated production, and capacity factor. +### Inputs and outputs + +For each input stream $i$ (numbered $1, 2, \ldots$, `in_streams`), the combiner has these inputs: + +- `{commodity}_in{i}`: time-series commodity profile from source $i$, in `commodity_rate_units`. +- `rated_{commodity}_production{i}`: rated (nameplate) production of source $i$, in + `commodity_rate_units` (scalar). +- `{commodity}_capacity_factor{i}`: annual capacity factor of source $i$ (unitless, with one + value per year of plant life). + +Its outputs are: + +- `{commodity}_out`: combined commodity time-series profile, the element-wise sum of all inputs. +- `rated_{commodity}_production`: total rated production, the sum of all input rated productions. +- `capacity_factor`: combined capacity factor (unitless, with one value per year of plant life). + +### Commodity output + +The combined output profile is the element-wise sum of all input profiles: + +$$ +\text{commodity\_out} = \sum_{i=1}^{N} \text{commodity\_in}_i +$$ + +where $N$ is `in_streams`. + +### Capacity factor calculation + +The combined capacity factor is a weighted average of the input capacity factors, weighted by each +stream's rated production: + +$$ +CF_{out} = \frac{\sum_{i=1}^{N} CF_i \cdot S_i}{\sum_{i=1}^{N} S_i} +$$ + +where $CF_i$ is the capacity factor and $S_i$ is the rated commodity production of input stream $i$. +This weighting ensures that larger sources contribute proportionally more to the combined capacity +factor. If the total rated production is zero, the output capacity factor is set to zero. + In `plant_config.yaml`, connect each source to the combiner, then connect the combiner to whatever uses the combined stream. The combiner's output is `{commodity}_out`. diff --git a/docs/user_guide/postprocessing_results.md b/docs/user_guide/postprocessing_results.md index cd7af7424..53a66b3cb 100644 --- a/docs/user_guide/postprocessing_results.md +++ b/docs/user_guide/postprocessing_results.md @@ -1,3 +1,16 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.18.1 +kernelspec: + display_name: h2integrate + language: python + name: python3 +--- + # Postprocessing results When running H2Integrate, results from the simulation and individual technologies are generated automatically. @@ -224,13 +237,17 @@ The ProFAST utilities create and populate a `ProFAST` object from a configuratio price solve, or convert an existing object back to configuration data. They are available from `h2integrate.tools.profast_tools`. -- `create_and_populate_profast(pf_config)` builds a ProFAST instance from a configuration mapping. -- `run_profast(pf)` returns the solved price, summary values, and cost-breakdown DataFrame. -- `make_price_breakdown(price_breakdown, pf_config)` distributes non-component financial costs - across primary cost items and returns a check against the solved levelized cost. +- `create_and_populate_profast(pf_config)` builds a ProFAST instance from a dictionary of financial + parameters and cost items. +- `run_profast(pf)` runs ProFAST to calculate the LCO and returns the solved price, summary values, + and cost-breakdown DataFrame. +- `make_price_breakdown(price_breakdown, pf_config)` outputs an LCO breakdown that includes the LCO + contribution of each technology cost. Non-component financial costs are distributed across the + primary cost items, and a check against the solved levelized cost is also returned. - `format_profast_price_breakdown_per_year(price_breakdown)` formats a ProFAST cost breakdown by year for reporting. -- `convert_pf_to_dict(pf)` converts a ProFAST instance into nested configuration data. +- `convert_pf_to_dict(pf)` converts a ProFAST instance into a dictionary of finance parameters and + cost inputs that is compatible with `create_and_populate_profast()`. ```python from h2integrate.tools.profast_tools import create_and_populate_profast, run_profast @@ -238,3 +255,63 @@ from h2integrate.tools.profast_tools import create_and_populate_profast, run_pro profast_model = create_and_populate_profast(pf_config) solution, summary, price_breakdown = run_profast(profast_model) ``` + +### Re-running ProFAST with different finance parameters + +Setting `save_profast_config: true` in the finance `model_inputs` of the plant config saves the +ProFAST configuration of each finance subgroup to the driver's `folder_output` after a run. +[Example 2](https://github.com/NatLabRockies/H2Integrate/tree/develop/examples/02_texas_ammonia) +does this, so running it writes `outputs/profast_output_ammonia_config.yaml` for the ammonia +subgroup. First, run Example 2: + +```{code-cell} ipython3 +import os +from copy import deepcopy +from pathlib import Path + +import numpy as np +import pandas as pd + +from h2integrate import EXAMPLE_DIR, H2IntegrateModel, load_yaml +from h2integrate.tools.profast_tools import run_profast, create_and_populate_profast + +os.chdir(EXAMPLE_DIR / "02_texas_ammonia") + +model = H2IntegrateModel("02_texas_ammonia.yaml") +model.setup() + +# Set the battery set point to the electrolyzer capacity, as in the Example 2 run script +model.prob.set_val("battery.electricity_set_point", np.ones(8760) * 640.0, units="MW") +model.run() + +lcoa_h2i = model.prob.get_val("finance_subgroup_ammonia.LCOA", units="USD/kg")[0] +print(f"LCOA from the H2I run: {lcoa_h2i:.3f} USD/kg") +``` + +The saved configuration can be loaded and its `params` changed, and ProFAST can then be run again +without re-running H2I. Here, the discount rate and debt interest rate are changed. The `baseline` +case reproduces the LCOA from the H2I run: + +```{code-cell} ipython3 +pf_config = load_yaml(Path("outputs") / "profast_output_ammonia_config.yaml") + +cases = { + "baseline": {}, + "8% discount rate": {"leverage after tax nominal discount rate": 0.08}, + "10% discount rate": {"leverage after tax nominal discount rate": 0.10}, + "5% debt interest rate": {"debt interest rate": 0.05}, +} + +results = [] +for case_name, updated_params in cases.items(): + case_config = deepcopy(pf_config) + case_config["params"].update(updated_params) + + pf = create_and_populate_profast(case_config) + solution, _summary, _price_breakdown = run_profast(pf) + results.append({"case": case_name, "LCOA (USD/kg)": solution["lco"]}) + +print(pd.DataFrame(results).round(3).to_string(index=False)) +``` + +A higher discount rate raises the LCOA, and a lower debt interest rate lowers it. From f02ca279a43787b3377770a542515692d2788d3a Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Thu, 15 Oct 2026 16:49:50 -0600 Subject: [PATCH 28/29] Update CHANGELOG.md Co-authored-by: elenya-grant <116225007+elenya-grant@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd82bccb8..ef93fd742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR 917](https://github.com/NatLabRockies/H2Integrate/pull/917) - Updated `make_price_breakdown()` and `run_profast()` in `profast_tools.py` so that fixed cost items and feedstock cost items are properly labelled. [PR 930](https://github.com/NatLabRockies/H2Integrate/pull/930) -- Add data resampling capabilities via pandas methods. [PR 933](https://github.com/NatLabRockies/H2Integrate/pull/933) +- Add data resampling capabilities to resource models via pandas methods. [PR 933](https://github.com/NatLabRockies/H2Integrate/pull/933) From 6d285b9873d32086086d408669b40436a158821f Mon Sep 17 00:00:00 2001 From: Jared Thomas Date: Fri, 16 Oct 2026 09:01:56 -0600 Subject: [PATCH 29/29] adjust cf and replacement helper to allow for multiple replacements in a given year --- CHANGELOG.md | 2 +- docs/developer_guide/class_structure.md | 26 ++ h2integrate/core/h2integrate_model.py | 10 + h2integrate/core/model_baseclass.py | 291 +++++++++--------- h2integrate/core/test/test_framework.py | 31 ++ .../core/test/test_model_baseclasses.py | 240 +++++++++++++++ 6 files changed, 456 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70a02db6..2ffffdc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Added a documentation changelog and version indicator, and updated installation, environment variable, and contribution guidance. [PR 917](https://github.com/NatLabRockies/H2Integrate/pull/917) - Updated `make_price_breakdown()` and `run_profast()` in `profast_tools.py` so that fixed cost items and feedstock cost items are properly labelled. [PR 930](https://github.com/NatLabRockies/H2Integrate/pull/930) - Add data resampling capabilities via pandas methods. [PR 933](https://github.com/NatLabRockies/H2Integrate/pull/933) -- Add core framework support for non-annual simulation durations. [PR 934](https://github.com/NatLabRockies/H2Integrate/pull/934) +- Add core framework support for non-annual simulation durations, including annual capacity-factor and replacement-count projections across the plant life. Leap-day-inclusive resources are rejected in full H2I runs until framework-wide calendar alignment is supported. [PR 934](https://github.com/NatLabRockies/H2Integrate/pull/934) ## 0.10.0 [October 12, 2026] diff --git a/docs/developer_guide/class_structure.md b/docs/developer_guide/class_structure.md index 9c62a4e81..9bd927674 100644 --- a/docs/developer_guide/class_structure.md +++ b/docs/developer_guide/class_structure.md @@ -70,6 +70,32 @@ directly) means the baseclass: - Declares `_time_step_bounds` (min, max in seconds) used to validate the configured simulation time step against the model. - Declares `_simulation_duration_bounds` (min, max in years, default annual-only) used to validate the configured simulation duration against the model. +Annualization uses fixed 365-day plant years. Until framework-wide calendar alignment is +supported, `H2IntegrateModel` rejects site resources configured with `include_leap_day: true` +during configuration loading. Set this resource option to `false` or omit it. Standalone +resource models retain their leap-day processing support. This restriction does not change +the timestamps generated from the simulation's `start_time` and `dt`. + +The CF/replacement helper requires finite, one-dimensional performance and SOH arrays +of identical length, positive rated performance and timestep, and a positive integer +plant life. SOH and its EOL threshold are fractions in [0, 1]. CF projection remains +annual: annual means retain their elapsed durations, including a partial final year. +When cycles repeat, their overlaps with plant years are duration-weighted; timestep +performance is not reconstructed or projected. + +An upward SOH jump greater than `1e-9` is assumed to mark a replacement at that sample's +elapsed offset. It closes the outgoing unit's cycle, not the new unit's cycle. SOH alone +cannot reveal a first-sample reset or repeated replacements with identical fresh SOH. +The trailing cycle is completed using the last two post-reset samples' SOH loss per +elapsed year. Projected EOL is checked only at the end of each full annual projection +interval; threshold-crossing times are not interpolated. Fewer than two post-reset samples +or stalled degradation do not imply an EOL replacement. The completed history then repeats +as either all cycles or the final cycle, using identical durations for CF and events. +Replacement counts are discrete events in `[year, year + 1)`, never annualized or rounded. +An event on a year boundary belongs to the following year; initial installation and an +event at retirement are excluded. Observed and projected events can share a plant year. +For example, a completed half-year cycle produces counts `[1, 2, 2]` over three years. + ### Multiple layers of inheritance Individual technology classes could inherit directly from the core baseclasses. If there are multiple technologies that have a lot of the same inputs, outputs, and/or methods we can use an additional layer of class inheritance that helps reduce duplicated code and potential errors. diff --git a/h2integrate/core/h2integrate_model.py b/h2integrate/core/h2integrate_model.py index ffdc69901..8fa5a9d30 100644 --- a/h2integrate/core/h2integrate_model.py +++ b/h2integrate/core/h2integrate_model.py @@ -204,6 +204,16 @@ def load_config(self, config_input): config.get("plant_config"), config_path, load_plant_yaml ) + for site_name, site_config in self.plant_config.get("sites", {}).items(): + for resource_name, resource_config in site_config.get("resources", {}).items(): + if resource_config.get("resource_parameters", {}).get("include_leap_day", False): + raise ValueError( + f"Resource '{site_name}.{resource_name}' sets include_leap_day=True. " + "H2Integrate currently annualizes using 365-day plant years and does " + "not support leap-day-inclusive resource timelines. " + "Set include_leap_day to False." + ) + for name, vals in self.technology_config["technologies"].items(): if "control_strategy" in vals: controller_model_name = vals["control_strategy"]["model"] diff --git a/h2integrate/core/model_baseclass.py b/h2integrate/core/model_baseclass.py index ebebeea88..fd4b1a1d8 100644 --- a/h2integrate/core/model_baseclass.py +++ b/h2integrate/core/model_baseclass.py @@ -2,6 +2,7 @@ import math import hashlib from pathlib import Path +from itertools import pairwise import dill import numpy as np @@ -67,10 +68,12 @@ def _validate_annualization_inputs( performance_timeseries = np.asarray(performance_timeseries, dtype=float) if performance_timeseries.size == 0: raise ValueError("performance_timeseries must contain at least one value.") + if performance_timeseries.ndim != 1 or not np.all(np.isfinite(performance_timeseries)): + raise ValueError("performance_timeseries must be a finite one-dimensional array.") rated_performance = float(rated_performance) - if rated_performance <= 0.0: - raise ValueError("rated_performance must be greater than zero.") + if not np.isfinite(rated_performance) or rated_performance <= 0.0: + raise ValueError("rated_performance must be finite and greater than zero.") if no_degradation_extrapolation not in _VALID_EXTRAPOLATION_METHODS: raise ValueError( @@ -94,173 +97,144 @@ def _validate_annualization_inputs( raise ValueError( "state_of_health_timeseries must contain at least one value when provided." ) + if ( + state_of_health_timeseries.ndim != 1 + or not np.all(np.isfinite(state_of_health_timeseries)) + or state_of_health_timeseries.shape != performance_timeseries.shape + ): + raise ValueError( + "state_of_health_timeseries must be finite, one-dimensional, and match " + "performance_timeseries length." + ) + if not np.isfinite(eol_soh) or not 0.0 <= eol_soh <= 1.0: + raise ValueError("eol_soh must be finite and between zero and one.") + if np.any((state_of_health_timeseries < 0.0) | (state_of_health_timeseries > 1.0)): + raise ValueError("state_of_health_timeseries values must be between zero and one.") return performance_timeseries, rated_performance, state_of_health_timeseries, use_degradation def _simulated_annual_capacity_factors(performance_timeseries, rated_performance, dt): """Capacity factor for each simulated year of ``performance_timeseries``.""" - steps_per_year = _annualization_steps_per_year(dt) - n_sim_years = math.ceil(performance_timeseries.size / steps_per_year) - simulated_annual_capacity_factors = np.zeros(n_sim_years) - - for year in range(n_sim_years): - start = year * steps_per_year - end = min(start + steps_per_year, performance_timeseries.size) - segment = performance_timeseries[start:end] - segment_hours = ((end - start) * dt) / 3600.0 - simulated_annual_capacity_factors[year] = ( - (segment.sum() * (dt / 3600.0)) / (rated_performance * segment_hours) - if segment_hours > 0.0 - else 0.0 - ) # performance units * h / (performance units * h) - - return simulated_annual_capacity_factors + sample_edges = np.arange(performance_timeseries.size + 1) * (dt / 31_536_000) + annual_edges = np.append(np.arange(math.ceil(sample_edges[-1])), sample_edges[-1]) + cumulative_performance = np.concatenate(([0.0], np.cumsum(performance_timeseries))) + annual_totals = np.diff(np.interp(annual_edges, sample_edges, cumulative_performance)) + return annual_totals * (dt / 31_536_000) / (rated_performance * np.diff(annual_edges)) def _degraded_replacement_cycles( simulated_annual_capacity_factors, state_of_health_timeseries, eol_soh, dt, plant_life ): - """Decompose the simulated years into replacement cycles. + """Split annual CF means at exact reset offsets, then complete the trailing cycle. - A cycle ends when the year-end state of health (SOH) reaches end of life, when the - following year's SOH resets upward (a replacement already recorded in the input series), - or when the simulation runs out of data. Only the trailing cycle can be incomplete; when - it is, it is completed by extrapolating the local degradation rate until end of life is - reached. - - Returns: - tuple[list[np.ndarray], list[bool]]: per-cycle capacity-factor segments and, for each - cycle, whether it reached end of life. + Each completed cycle has one replacement event at its end. Segment durations are + measured in fixed 365-day years, including partial observed annual intervals. """ - steps_per_year = _annualization_steps_per_year(dt) - n_sim_years = simulated_annual_capacity_factors.size - if math.ceil(state_of_health_timeseries.size / steps_per_year) != n_sim_years: - raise ValueError( - "state_of_health_timeseries length must match the number of simulated years " - "implied by performance_timeseries and dt." - ) - - # Year-end SOH for each simulated year. - sim_soh_year_end = np.zeros(n_sim_years) - for year in range(n_sim_years): - end = min((year + 1) * steps_per_year, state_of_health_timeseries.size) - sim_soh_year_end[year] = state_of_health_timeseries[end - 1] - - soh_reset_tol = 1e-9 + sample_duration = dt / 31_536_000 + simulation_duration = state_of_health_timeseries.size * sample_duration + reset_indices = np.flatnonzero(np.diff(state_of_health_timeseries) > 1e-9) + 1 + cycle_boundaries = np.concatenate(([0], reset_indices, [state_of_health_timeseries.size])) cycle_cf_segments = [] - cycle_soh_segments = [] + cycle_segment_durations = [] cycle_reached_eol = [] - cycle_start = 0 - for year in range(n_sim_years): - eol_reached = sim_soh_year_end[year] <= eol_soh - soh_reset_next = (year + 1 < n_sim_years) and ( - sim_soh_year_end[year + 1] > sim_soh_year_end[year] + soh_reset_tol + for start_index, end_index in pairwise(cycle_boundaries): + start = start_index * sample_duration + end = end_index * sample_duration + annual_boundaries = np.arange(math.floor(start) + 1, math.ceil(end), dtype=float) + boundaries = np.concatenate(([start], annual_boundaries, [end])) + annual_indices = np.floor(boundaries[:-1]).astype(int) + cycle_cf_segments.append(simulated_annual_capacity_factors[annual_indices].copy()) + cycle_segment_durations.append(np.diff(boundaries)) + cycle_reached_eol.append( + end_index < state_of_health_timeseries.size or state_of_health_timeseries[-1] <= eol_soh ) - is_last_year = year == n_sim_years - 1 - if eol_reached or soh_reset_next or is_last_year: - cycle_cf_segments.append( - np.array(simulated_annual_capacity_factors[cycle_start : year + 1]) - ) - cycle_soh_segments.append(np.array(sim_soh_year_end[cycle_start : year + 1])) - cycle_reached_eol.append(bool(eol_reached or soh_reset_next)) - cycle_start = year + 1 - # Complete the trailing cycle if the simulation ended before it reached end of life. - # Only extrapolated years are appended; observed years are left unchanged. if not cycle_reached_eol[-1]: - trailing_cf = cycle_cf_segments[-1] - trailing_soh = cycle_soh_segments[-1] - - # Local annual degradation rate from the last one or two points of the incomplete - # trailing cycle (robust to non-linear degradation). - if trailing_soh.size >= 2: - local_annual_deg_rate = trailing_soh[-2] - trailing_soh[-1] - else: - trailing_start_year = n_sim_years - trailing_soh.size - start_ts = trailing_start_year * steps_per_year - end_ts = min(start_ts + steps_per_year, state_of_health_timeseries.size) - year_fraction = (end_ts - start_ts) / steps_per_year - year_fraction = year_fraction if year_fraction > 0.0 else 1.0 - local_annual_deg_rate = ( - state_of_health_timeseries[start_ts] - trailing_soh[-1] - ) / year_fraction - local_annual_deg_rate = max(float(local_annual_deg_rate), 0.0) - + trailing_soh = state_of_health_timeseries[cycle_boundaries[-2] :] + annual_degradation = ( + max(float(trailing_soh[-2] - trailing_soh[-1]) / sample_duration, 0.0) + if trailing_soh.size >= 2 + else 0.0 + ) last_observed_soh = float(trailing_soh[-1]) - last_observed_cf = float(trailing_cf[-1]) - extrapolated_soh = [] + last_observed_cf = float(cycle_cf_segments[-1][-1]) extrapolated_cf = [] projected_soh = last_observed_soh - # Cap the number of extrapolated years to avoid an unbounded loop if degradation - # stalls (rate of zero). while ( - local_annual_deg_rate > 0.0 - and projected_soh > eol_soh - and len(extrapolated_soh) < plant_life + annual_degradation > 0.0 + and projected_soh > eol_soh + 1e-12 + and simulation_duration + len(extrapolated_cf) < plant_life ): - projected_soh = projected_soh - local_annual_deg_rate - extrapolated_soh.append(projected_soh) - if last_observed_soh > 0.0: - extrapolated_cf.append( - last_observed_cf * max(projected_soh, 0.0) / last_observed_soh - ) - else: - extrapolated_cf.append(0.0) - - if extrapolated_soh: - cycle_cf_segments[-1] = np.concatenate([trailing_cf, np.array(extrapolated_cf)]) - cycle_reached_eol[-1] = extrapolated_soh[-1] <= eol_soh + projected_soh -= annual_degradation + extrapolated_cf.append(last_observed_cf * max(projected_soh, 0.0) / last_observed_soh) + cycle_cf_segments[-1] = np.concatenate([cycle_cf_segments[-1], extrapolated_cf]) + cycle_segment_durations[-1] = np.concatenate( + [cycle_segment_durations[-1], np.ones(len(extrapolated_cf))] + ) + cycle_reached_eol[-1] = projected_soh <= eol_soh + 1e-12 - return cycle_cf_segments, cycle_reached_eol + return cycle_cf_segments, cycle_reached_eol, cycle_segment_durations -def _project_capacity_factors(cycle_cf_segments, soh_cycle_repetition, plant_life): - """Capacity factor for each plant-life year. +def _cycle_timeline(cycle_cf_segments, cycle_segment_durations, soh_cycle_repetition, plant_life): + """Yield cycle indices and offsets on the shared CF/replacement clock.""" + start = 0.0 + indices = range(len(cycle_cf_segments)) + while start < plant_life: + for index in indices: + if start >= plant_life: + return + durations = cycle_segment_durations[index] + yield index, start, durations + start += float(np.sum(durations)) + indices = ( + range(len(cycle_cf_segments)) + if soh_cycle_repetition == "all_soh_cycles" + else [len(cycle_cf_segments) - 1] + ) - The simulated/completed cycles are kept at the front of the timeline, then the repeat - unit (all cycles or just the final one) is tiled to fill the plant life. - """ - front_cf = np.concatenate(cycle_cf_segments) - if soh_cycle_repetition == "all_soh_cycles": - repeat_cf = front_cf - else: # "final_soh_cycle" (already validated) - repeat_cf = cycle_cf_segments[-1] - n_repeats = math.ceil(max(plant_life - front_cf.size, 0) / repeat_cf.size) - return np.concatenate([front_cf, np.tile(repeat_cf, n_repeats)])[:plant_life] +def _project_capacity_factors( + cycle_cf_segments, soh_cycle_repetition, plant_life, cycle_segment_durations=None +): + """Duration-weight annual CF segments into fixed plant-year bins.""" + if cycle_segment_durations is None: + cycle_segment_durations = [np.ones(segment.size) for segment in cycle_cf_segments] + annual_cf = np.zeros(plant_life) + for index, start, durations in _cycle_timeline( + cycle_cf_segments, cycle_segment_durations, soh_cycle_repetition, plant_life + ): + for value, duration in zip(cycle_cf_segments[index], durations, strict=True): + end = min(start + duration, plant_life) + for year in range(math.floor(start), math.ceil(end)): + annual_cf[year] += value * (min(end, year + 1) - max(start, year)) + start += duration + if start >= plant_life: + break + return annual_cf def _build_replacement_schedule( - cycle_cf_segments, cycle_reached_eol, soh_cycle_repetition, plant_life + cycle_cf_segments, + cycle_reached_eol, + soh_cycle_repetition, + plant_life, + cycle_segment_durations=None, ): - """Replacement schedule marking the first year of each post-end-of-life cycle.""" - cycle_lengths = [segment.size for segment in cycle_cf_segments] - if soh_cycle_repetition == "all_soh_cycles": - repeat_lengths = cycle_lengths - repeat_reached_eol = list(cycle_reached_eol) - else: # "final_soh_cycle" (already validated) - repeat_lengths = [cycle_lengths[-1]] - repeat_reached_eol = [cycle_reached_eol[-1]] - - # Extend the cycle timeline until it spans the plant life. - timeline_cycle_lengths = list(cycle_lengths) - timeline_reached_eol = list(cycle_reached_eol) - while sum(timeline_cycle_lengths) < plant_life: - timeline_cycle_lengths.extend(repeat_lengths) - timeline_reached_eol.extend(repeat_reached_eol) - - # A replacement occurs at the first year of a cycle (plant year > 0) whenever the - # previous cycle reached end of life. - replacement_schedule = np.zeros(plant_life) - cycle_start_year = 0 - for cycle_index, cycle_length in enumerate(timeline_cycle_lengths): - if cycle_start_year >= plant_life: - break - if cycle_start_year > 0 and timeline_reached_eol[cycle_index - 1]: - replacement_schedule[cycle_start_year] = 1.0 - cycle_start_year += cycle_length - + """Count discrete cycle-end events in [year, year + 1), excluding retirement.""" + if cycle_segment_durations is None: + cycle_segment_durations = [np.ones(segment.size) for segment in cycle_cf_segments] + replacement_schedule = np.zeros(plant_life, dtype=int) + for index, start, durations in _cycle_timeline( + cycle_cf_segments, cycle_segment_durations, soh_cycle_repetition, plant_life + ): + event_time = start + float(np.sum(durations)) + nearest_year = round(event_time) + if math.isclose(event_time, nearest_year, rel_tol=0.0, abs_tol=1e-10): + event_time = float(nearest_year) + if cycle_reached_eol[index] and 0.0 < event_time < plant_life: + replacement_schedule[math.floor(event_time)] += 1 return replacement_schedule @@ -437,6 +411,20 @@ def calculate_annual_cf_and_replacement_schedule( - ``"all_soh_cycles"``: repeat the full ordered set of simulated cycles. - ``"final_soh_cycle"``: repeat only the final completed cycle. + Notes: + Inputs must be finite one-dimensional arrays of identical length. SOH and + its EOL threshold use fractions in [0, 1]. Resets are upward SOH jumps greater + than 1e-9 at sample offsets; SOH alone cannot identify a first-sample reset or + resets without an upward jump. Each reset closes the outgoing unit's cycle. + Annual CF means retain their elapsed durations, including partial years, and + are duration-weighted into fixed 365-day plant years when cycles repeat. + No timestep CF is projected. Trailing degradation uses the last two post-reset + samples divided by their elapsed time. EOL is checked at the end of each full + projected annual interval, without interpolating a threshold crossing. With + fewer than two post-reset samples or stalled degradation, no EOL is inferred. + Cycle-end events are discrete counts in [year, year + 1); initial installation + and retirement are excluded. CF and events use the same cycle durations. + Returns: tuple[np.ndarray, np.ndarray]: projected annual capacity factors and replacement schedule. @@ -454,6 +442,15 @@ def calculate_annual_cf_and_replacement_schedule( no_degradation_extrapolation, soh_cycle_repetition, ) + if not np.isfinite(self.dt) or self.dt <= 0.0: + raise ValueError("dt must be finite and greater than zero.") + if ( + not np.isfinite(self.plant_life) + or self.plant_life < 1 + or int(self.plant_life) != self.plant_life + ): + raise ValueError("plant_life must be a positive integer number of years.") + plant_life = int(self.plant_life) simulated_annual_capacity_factors = _simulated_annual_capacity_factors( performance_timeseries, rated_performance, self.dt @@ -463,24 +460,32 @@ def calculate_annual_cf_and_replacement_schedule( # extrapolated across the plant life. if not use_degradation: annual_capacity_factors = _extrapolate_annual_values( - simulated_annual_capacity_factors, self.plant_life, no_degradation_extrapolation + simulated_annual_capacity_factors, plant_life, no_degradation_extrapolation ) - return annual_capacity_factors, np.zeros(self.plant_life) + return annual_capacity_factors, np.zeros(plant_life, dtype=int) # With degradation, both outputs derive from the same decomposition of the simulated # years into replacement cycles. - cycle_cf_segments, cycle_reached_eol = _degraded_replacement_cycles( + ( + cycle_cf_segments, + cycle_reached_eol, + cycle_segment_durations, + ) = _degraded_replacement_cycles( simulated_annual_capacity_factors, state_of_health_timeseries, eol_soh, self.dt, - self.plant_life, + plant_life, ) annual_capacity_factors = _project_capacity_factors( - cycle_cf_segments, soh_cycle_repetition, self.plant_life + cycle_cf_segments, soh_cycle_repetition, plant_life, cycle_segment_durations ) replacement_schedule = _build_replacement_schedule( - cycle_cf_segments, cycle_reached_eol, soh_cycle_repetition, self.plant_life + cycle_cf_segments, + cycle_reached_eol, + soh_cycle_repetition, + plant_life, + cycle_segment_durations=cycle_segment_durations, ) return annual_capacity_factors, replacement_schedule diff --git a/h2integrate/core/test/test_framework.py b/h2integrate/core/test/test_framework.py index 01bda2f8d..352afce10 100644 --- a/h2integrate/core/test/test_framework.py +++ b/h2integrate/core/test/test_framework.py @@ -886,6 +886,37 @@ def test_custom_financial_model_grouping(temp_dir, subtests): H2IntegrateModel(temp_highlevel_yaml) +@pytest.mark.unit +@pytest.mark.parametrize("include_leap_day", [True, False, None]) +def test_h2i_config_rejects_leap_day_resources(include_leap_day): + example_folder = EXAMPLE_DIR / "01_onshore_steel_mn" + plant_config = load_plant_yaml(example_folder / "plant_config.yaml") + resource_parameters = {} + if include_leap_day is not None: + resource_parameters["include_leap_day"] = include_leap_day + plant_config["sites"]["leap_test_site"] = { + "latitude": 0.0, + "longitude": 0.0, + "resources": { + "weather": { + "resource_model": "GOESAggregatedSolarAPI", + "resource_parameters": resource_parameters, + } + }, + } + config = { + "plant_config": plant_config, + "technology_config": example_folder / "tech_config.yaml", + "driver_config": example_folder / "driver_config.yaml", + } + model = object.__new__(H2IntegrateModel) + if include_leap_day: + with pytest.raises(ValueError, match=r"leap_test_site\.weather.*include_leap_day=True"): + model.load_config(config) + else: + model.load_config(config) + + # docs fencepost start: DO NOT REMOVE @pytest.mark.unit def test_unsupported_simulation_parameters(temp_dir): diff --git a/h2integrate/core/test/test_model_baseclasses.py b/h2integrate/core/test/test_model_baseclasses.py index f061566cb..9c395545c 100644 --- a/h2integrate/core/test/test_model_baseclasses.py +++ b/h2integrate/core/test/test_model_baseclasses.py @@ -1,5 +1,7 @@ """Tests for ``PerformanceModelBaseClass.calculate_annual_cf_and_replacement_schedule``.""" +import math + import numpy as np import pytest @@ -170,6 +172,29 @@ def test_detects_reset_via_upward_soh_jump(): np.testing.assert_allclose(replacement_schedule, [0, 0, 1, 0, 0, 0]) +@pytest.mark.unit +def test_replacement_schedule_counts_multiple_soh_resets_per_year(subtests): + steps_per_year = 8760 + n_sim_years = 2 + soh = np.linspace(0.99, 0.95, n_sim_years * steps_per_year) + soh[[100, 200, steps_per_year + 300]] = 1.0 + + _, replacement_schedule = _run( + _DummyPerformanceModel(plant_life=6, dt=3600), + performance_timeseries=np.ones(n_sim_years * steps_per_year), + rated=1.0, + soh=soh, + eol=0.8, + ) + + with subtests.test("two replacements are counted in simulated year one"): + assert replacement_schedule[0] == pytest.approx(2.0) + with subtests.test("one replacement is counted in simulated year two"): + assert replacement_schedule[1] == pytest.approx(1.0) + with subtests.test("schedule covers full plant life"): + assert replacement_schedule.shape == (6,) + + @pytest.mark.unit def test_invalid_soh_cycle_repetition_raises(): with pytest.raises(ValueError): @@ -183,6 +208,221 @@ def test_invalid_soh_cycle_repetition_raises(): ) +@pytest.mark.unit +@pytest.mark.parametrize( + "mode,expected_cf,expected_schedule", + [ + ("all_soh_cycles", [0.8, 0.725, 0.725, 0.8], [1, 1, 1, 2]), + ("final_soh_cycle", [0.8, 0.725, 0.725, 0.725], [1, 1, 1, 1]), + ], +) +def test_subyear_cycles_project_cf_and_replacements_together( + subtests, mode, expected_cf, expected_schedule +): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=4, dt=31_536_000 / 4), + performance_timeseries=[1.0, 0.5, 0.9, 0.8, 0.7, 0.6], + rated=1.0, + soh=[0.9, 0.8, 0.97, 0.92, 0.86, 0.8], + eol=0.8, + soh_cycle_repetition=mode, + ) + with subtests.test("annual means 0.8 and 0.65 retain their half-year overlaps"): + np.testing.assert_allclose(cf_values, expected_cf) + with subtests.test("replacement counts follow the same cycle starts"): + np.testing.assert_array_equal(schedule, expected_schedule) + + +@pytest.mark.unit +def test_partial_year_cycle_projects_multiple_replacements(subtests): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=3, dt=31_536_000 / 4), + performance_timeseries=[1.0, 0.5], + rated=1.0, + soh=[0.9, 0.8], + eol=0.8, + ) + with subtests.test("partial-year cycle fills the entire projected lifetime"): + np.testing.assert_allclose(cf_values, [0.75, 0.75, 0.75]) + with subtests.test("year-boundary replacements belong to the following plant year"): + np.testing.assert_array_equal(schedule, [1, 2, 2]) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "mode,expected_cf,expected_schedule", + [ + ("all_soh_cycles", [0.8, 0.65, 0.75, 0.7], [1, 2, 1, 2]), + ("final_soh_cycle", [0.8, 0.6, 0.6, 0.6], [1, 2, 2, 2]), + ], +) +def test_midyear_resets_preserve_trailing_cycle_phase( + subtests, mode, expected_cf, expected_schedule +): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=4, dt=31_536_000 / 4), + performance_timeseries=[1.0, 0.5, 0.9, 0.8, 0.7, 0.6, 0.5], + rated=1.0, + soh=[1.0, 0.9, 1.0, 0.9, 0.8, 1.0, 0.8], + eol=0.8, + soh_cycle_repetition=mode, + ) + with subtests.test("annual means retain observed and repeated partial-year durations"): + np.testing.assert_allclose(cf_values, expected_cf) + with subtests.test("observed event at 1.25 and projected event at 1.75 share year one"): + np.testing.assert_array_equal(schedule, expected_schedule) + with subtests.test("all observed and projected replacements are whole units"): + assert np.issubdtype(schedule.dtype, np.integer) + + +@pytest.mark.unit +@pytest.mark.parametrize("mode", ["all_soh_cycles", "final_soh_cycle"]) +def test_multiple_observed_and_extrapolated_resets(subtests, mode): + _, schedule = _run( + _DummyPerformanceModel(plant_life=5, dt=31_536_000 / 4), + performance_timeseries=np.ones(7), + rated=1.0, + soh=[0.8, 1.0, 0.8, 1.0, 0.8, 1.0, 0.9], + eol=0.8, + soh_cycle_repetition=mode, + ) + expected = [2, 1, 1, 2, 1] if mode == "all_soh_cycles" else [2, 1, 1, 0, 1] + with subtests.test("events at 0.25, 0.75, 1.25 and extrapolated 2.75 precede repetition"): + np.testing.assert_array_equal(schedule, expected) + with subtests.test("reset never also creates a synthetic start-of-cycle event"): + assert schedule[:3].sum() == 4 + + +@pytest.mark.unit +@pytest.mark.parametrize("mode", ["all_soh_cycles", "final_soh_cycle"]) +@pytest.mark.parametrize("soh", [[1.0] * 6, [0.9] * 6, [0.9, 1.0]]) +def test_stalled_or_unmeasured_post_reset_degradation(subtests, mode, soh): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=4, dt=31_536_000 / 4), + performance_timeseries=np.ones(len(soh)), + rated=1.0, + soh=soh, + eol=0.8, + soh_cycle_repetition=mode, + ) + expected = [2, 2, 2, 2] if soh == [0.9, 1.0] else [0, 0, 0, 0] + if mode == "final_soh_cycle" and soh == [0.9, 1.0]: + expected = [1, 0, 0, 0] + with subtests.test("stalling does not invent an EOL replacement"): + np.testing.assert_array_equal(schedule, expected) + with subtests.test("annual CF remains available across the full plant life"): + np.testing.assert_allclose(cf_values, np.ones(4)) + + +@pytest.mark.unit +@pytest.mark.parametrize("sample_count,cycle_duration", [(8, 4.0), (6, 4.5)]) +def test_partial_final_year_normalizes_soh_degradation(subtests, sample_count, cycle_duration): + soh = np.linspace(1.0, 0.9, 8)[:sample_count] + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=10, dt=31_536_000 / 4), + performance_timeseries=np.ones(sample_count), + rated=1.0, + soh=soh, + eol=0.8, + ) + annual_rate = (1.0 - 0.9) / (7 / 4) + first_projected_cf = (soh[-1] - annual_rate) / soh[-1] + observed_duration = sample_count / 4 + overlap = math.ceil(observed_duration) - observed_duration + expected_first_cf = overlap + (1.0 - overlap) * first_projected_cf + with subtests.test("partial final year uses the same SOH loss per elapsed year"): + assert cf_values[math.floor(observed_duration)] == pytest.approx(expected_first_cf) + with subtests.test("EOL event remains at the end of a full projected annual interval"): + expected = np.zeros(10, dtype=int) + for event_time in np.arange(cycle_duration, 10, cycle_duration): + expected[math.floor(event_time)] += 1 + np.testing.assert_array_equal(schedule, expected) + + +@pytest.mark.unit +def test_reset_tolerance_does_not_infer_unobservable_events(subtests): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=3, dt=31_536_000 / 4), + performance_timeseries=np.ones(4), + rated=1.0, + soh=[1.0 - 5e-10, 1.0, 1.0, 1.0], + eol=0.8, + ) + with subtests.test("SOH jumps no larger than tolerance are not counted"): + np.testing.assert_array_equal(schedule, [0, 0, 0]) + with subtests.test("fresh equal samples do not imply repeated replacements"): + np.testing.assert_allclose(cf_values, [1, 1, 1]) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "overrides,match", + [ + ({"performance_timeseries": []}, "at least one"), + ({"performance_timeseries": [[1.0] * 8]}, "one-dimensional"), + ({"performance_timeseries": [np.nan] * 8}, "finite"), + ({"performance_timeseries": [np.inf] * 8}, "finite"), + ({"soh": np.ones(5)}, "match"), + ({"soh": []}, "at least one"), + ({"soh": [[1.0] * 8]}, "one-dimensional"), + ({"soh": [np.nan] * 8}, "finite"), + ({"soh": [np.inf] * 8}, "finite"), + ({"soh": [-0.1] * 8}, "between zero and one"), + ({"soh": [1.1] * 8}, "between zero and one"), + ({"rated": 0}, "rated_performance"), + ({"rated": -1}, "rated_performance"), + ({"rated": np.inf}, "rated_performance"), + ({"rated": np.nan}, "rated_performance"), + ({"eol": None}, "eol_soh is required"), + ({"eol": np.nan}, "eol_soh"), + ({"eol": np.inf}, "eol_soh"), + ({"eol": -0.1}, "eol_soh"), + ({"eol": 1.1}, "eol_soh"), + ], +) +def test_annualization_rejects_invalid_arrays_and_quantities(overrides, match): + inputs = {"performance_timeseries": np.ones(8), "rated": 1.0, "soh": np.ones(8), "eol": 0.8} + inputs.update(overrides) + with pytest.raises(ValueError, match=match): + _run(_DummyPerformanceModel(dt=31_536_000 / 4), **inputs) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "attribute,value", + [ + ("dt", 0), + ("dt", np.inf), + ("dt", -1), + ("plant_life", 0), + ("plant_life", 1.5), + ("plant_life", np.nan), + ], +) +def test_annualization_rejects_invalid_clock(attribute, value): + dummy = _DummyPerformanceModel() + setattr(dummy, attribute, value) + with pytest.raises(ValueError, match=attribute): + _run(dummy, performance_timeseries=[1.0], rated=1.0, soh=None, eol=None) + + +@pytest.mark.unit +def test_annual_means_weight_timesteps_crossing_fixed_year_boundaries(subtests): + cf_values, schedule = _run( + _DummyPerformanceModel(plant_life=3, dt=0.3 * 31_536_000), + performance_timeseries=[0.2, 0.4, 0.6, 0.8], + rated=1.0, + soh=[0.9, 0.85, 0.8, 0.75], + eol=0.8, + ) + with subtests.test("sample crossing year one contributes by elapsed overlap"): + assert cf_values[0] == pytest.approx(0.44) + with subtests.test("completed 1.2-year cycles share the same projection clock"): + np.testing.assert_allclose(cf_values, [0.44, 0.512, 0.512]) + with subtests.test("replacement events at 1.2 and 2.4 remain whole"): + np.testing.assert_array_equal(schedule, [0, 1, 1]) + + @pytest.mark.unit def test_invalid_no_degradation_extrapolation_raises(): with pytest.raises(ValueError):