Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f6b1c18
add initial prototype for weibull reliability
RHammond2 Aug 10, 2026
19e3e1c
simplify relibaility model non openmdao model
RHammond2 Aug 11, 2026
6bcd000
Merge branch 'develop' into feature/reliability
RHammond2 Aug 11, 2026
fccb5bd
add model retrieval and remove extrasfrom baseclasses
RHammond2 Aug 11, 2026
6340023
Merge branch 'feature/reliability' of https://github.com/RHammond2/H2…
RHammond2 Aug 11, 2026
e02b34d
Merge branch 'develop' into feature/reliability
RHammond2 Aug 12, 2026
58bdff9
make downtime and reliability separate models
RHammond2 Aug 17, 2026
4283ab5
update sampling and fix interval availability
RHammond2 Aug 18, 2026
f5e61da
update downtime for matrix and generation helpers
RHammond2 Aug 19, 2026
dce308e
make weibull model definiton multi-component-compatible
RHammond2 Aug 19, 2026
2463e78
getting setup partially working
RHammond2 Aug 19, 2026
efaf665
update docstrings and add fixed downtime
RHammond2 Aug 19, 2026
e4bfcb8
fix small bugs and ensure matrix works for weibull
RHammond2 Aug 19, 2026
6388656
system availability
RHammond2 Aug 19, 2026
081386b
move calculate_availability to base and simplify broadcasting
RHammond2 Aug 19, 2026
e5cbdd9
convert fixexd interval to componentizable model and update n_compone…
RHammond2 Aug 20, 2026
4e326bb
update fixed downtime
RHammond2 Aug 20, 2026
b3544be
update lognormal downtime model
RHammond2 Aug 20, 2026
c809904
add burn-in and fix bugs in n_components setting
RHammond2 Aug 20, 2026
2bde5e3
update use_reliability
RHammond2 Aug 20, 2026
1d9112f
merge develop and fix conflicts
RHammond2 Aug 20, 2026
33f80a3
add validator for array variation of validators.ge
RHammond2 Aug 20, 2026
8568a2c
standardize dimensionality update
RHammond2 Aug 20, 2026
7e9d473
minor reorg
RHammond2 Aug 20, 2026
b4a0ff5
add missing return
RHammond2 Aug 20, 2026
40ac1c9
add shape checking validator
RHammond2 Aug 20, 2026
823ea19
remove extra shape check
RHammond2 Aug 20, 2026
ff76626
Merge branch 'develop' into feature/reliability
RHammond2 Aug 21, 2026
6850587
move downtime event definition to base class and add array_gt
RHammond2 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions h2integrate/converters/natural_gas/natural_gas_cc_ct.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from h2integrate.core.utilities import BaseConfig, merge_shared_inputs
from h2integrate.core.validators import gt_zero, gte_zero
from h2integrate.reliability.models import WeibullReliabilityModel
from h2integrate.core.model_baseclasses import (
CostModelBaseClass,
CostModelBaseConfig,
Expand Down Expand Up @@ -67,6 +68,8 @@ def initialize(self):
self.commodity = "electricity"
self.commodity_rate_units = "MW"
self.commodity_amount_units = "MW*h"
self.reliability_model = None
self.use_reliability = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these be parameters in the NaturalGasPerformanceConfig? So a user can input reliability_model and use_reliability? Where the __attrs_post_init__ checks that reliability_model is provided if use_reliability is True?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question, it didn't dawn on me that a user could want to provide a definition, but not use it. Though it could be easier to iterate on a problem by simply turning it on/off instead of commenting out a whole section of the inputs. I'll also add this to the to do section.


def setup(self):
super().setup()
Expand All @@ -75,6 +78,12 @@ def setup(self):
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance"),
additional_cls_name=self.__class__.__name__,
)
if self.options["tech_config"]["model_inputs"]["reliability"]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to use self.options["tech_config"]["model_inputs"].get("reliability", False) here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not exactly, but that did get me thinking on a better way to use use_reliability, so I appreciate the question!

self.reliability_model = WeibullReliabilityModel.from_dict(
merge_shared_inputs(self.options["tech_config"]["model_inputs"], "reliability"),
additional_cls_name=self.__class__.__name__,
)
self.use_reliability = True

# Add natural gas consumed output
self.add_output(
Expand Down Expand Up @@ -154,6 +163,8 @@ def compute(self, inputs, outputs):
inputs["electricity_command_value"],
)
natural_gas_demand = electricity_command_value * heat_rate_mmbtu_per_mwh
if self.use_reliability:
natural_gas_demand * self.reliability_model.availability

# available feedstock, saturated at maximum system feedstock consumption
natural_gas_available = np.where(
Expand Down
Empty file.
206 changes: 206 additions & 0 deletions h2integrate/reliability/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from abc import ABC, abstractmethod
from typing import Any

import numpy as np
from attrs import field, define, validators

from h2integrate.core.utilities import BaseConfig


# generated from np.random.SeedSequence().entropy
rng = np.random.default_rng(279299947538423226929715083173412195503)


N_TIMESTEPS = 8760


def create_failure_model(config: dict):
"""Retrieves and initializes a matching reliability model."""
name = config.pop("failure_model")
match name:
case "WeibullReliability":
return WeibullReliability.from_dict(config)
case "FixedIntervalReliability":
return FixedIntervalReliability.from_dict(config)
case _:
raise NotImplementedError(f"{name} is not a valid model name")


def create_maintenance_model(config: dict):
"""Retrieves and initializes a matching reliability model."""
name = config.pop("maintenance_model")
match name:
case "WeibullReliability":
return WeibullReliability.from_dict(config)
case "FixedIntervalReliability":
return FixedIntervalReliability.from_dict(config)
case _:
raise NotImplementedError(f"{name} is not a valid model name")


def generate_downtime_model(config: dict):
name = config.pop("model")
match name:
case "LogNormalDowntime":
return LogNormalDowntime.from_dict(config)
case _:
raise NotImplementedError(f"{name} is not a valid model name")


@define(kw_only=True)
class BaseDowntime(ABC, BaseConfig):
@abstractmethod
def sample_downtime(self) -> np.ndarray: ...


@define(kw_only=True)
class BaseReliability(ABC, BaseConfig):
@abstractmethod
def sample_events(self) -> np.ndarray: ...


@define(kw_only=True)
class LogNormalDowntime(BaseDowntime):
"""Basic log-normal downtime model for generating the length of downtime for a given event.

Args:
mean (float): Average length of downtime per event, in hours.
sigma (float): Standard deviation of the distribution(s), in hours.
n_components (int): Number of identical components to sample. Primarily for convenience.
Defaults to 1.
"""

mean: float = field(validator=(validators.instance_of(float), validators.ge(0)))
sigma: float = field(validator=(validators.instance_of(float), validators.ge(0)))
n_components: int = field(default=1, validator=(validators.instance_of(int), validators.ge(1)))

def sample_downtime(self) -> np.ndarray:
size = (self.n_components, 100) if isinstance(self.mean, int | float) else 100
return rng.lognormal(self.mean, self.sigma, size=size)


@define(kw_only=True)
class WeibullReliability(BaseReliability):
r"""Basic reliability model for operating/not operating statuses.

Assumes a full operational shutdown with zero ramping of production for an hourly, 1 year
simulation.

Args:
scale (float): Also referred to as :math:`\lambda` or :math:`\alpha`. Determines
the scale of distribution, and is equivalent to the mean time
between failure in years (MTBF), or 1 / annual failure rate.
shape (float): Also referred to as ``k`` or :math:`\beta`. A value less than 1
corresponds to a decreasing hazard rate over time (break-in period failures);
a value greater than 1 corresponds to an increasing hazard rate over time (
aging/wear-out failures); and a value of 1 corresponds to a constant hazard
rate over time (exponential distribution).
downtime (float): Average amount of downtime per failure.

Attributes:
rng (np.random._generator.Generator): NumPy random generator object.

TODO:
- how to pass n_timesteps through from plant?
- stabilize random generator/determine how to manage random seeding across library
"""

scale: float = field(validator=validators.instance_of(float))
shape: float = field(validator=validators.instance_of(float))
downtime: Any = field(converter=generate_downtime_model)
downtime_per_event: np.ndarray = field(init=False, validator=validators.instance_of(np.ndarray))
availability: np.ndarray = field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we make n_timesteps a configuration parameter? Then we could have availability be initialized in __attrs_post_init__ as np.ones(n_timesteps)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generation of availability is now done in calculate_availability to make it easier to automate, and adding the n_timesteps to the to do section.

default=np.ones(N_TIMESTEPS), init=False, validator=validators.instance_of(np.ndarray)
)

def __attrs_post_init__(self):
self.create_downtime_events()
self.calculate_availability()

def sample_events(self):
return np.ceil(self.rng.weibul(self.shape, size=100) * self.scale * N_TIMESTEPS).astype(int)

def create_downtime_events(self):
"""Creates a ``time_to_failure`` and ``downtime_per_event``."""
# NOTE: Arrays are default length 30 to ensure enough events are created for a 1-year
# simulation without burdening the memory usage.
self.time_to_failures = self.sample_events()
self.downtime_per_event = self.downtime.sample_downtime()

def calculate_availability(self):
"""Determine the timing and duration of outages for a single year of simulation time."""
accumulated = 0
Comment thread
RHammond2 marked this conversation as resolved.
Outdated
while accumulated < N_TIMESTEPS:
event, self.time_to_failures = self.time_to_failures[0], self.time_to_failures[1:]
duration, self.downtime_per_event = (
self.downtime_per_event[0],
self.downtime_per_event[1:],
)
if event + accumulated > N_TIMESTEPS:
break

start = accumulated + event
end = start + duration
self.availability[start:end] = 0
accumulated = end
if not self.time_to_failures:
self.create_downtime_events()


@define(kw_only=True)
class FixedIntervalReliability(BaseReliability):
"""Basic fixed interval downtime reliability model.

Args:
frequency (int | float | array-like): The annual frequency of events, e.g., 4 is equivalent
to a quarterly downtime event and 0.25 is equivalent to an every 4 years downtime event.
For all events the timing of the first event will be sampled within the first year or
interval period to offset events from being based on January 1st in an 8760.
downtime (int | float | dict): Either fixed length of each downtime, in hours, or a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we name this downtime_hrs or something? Also - can these models be updated to handle varying timesteps? It seems like a lot of logic is intended for hourly? If so - I think we should should make dt an input parameter too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be tricky, but I'll add it to the to do section.

configuration dictionary for a downtime length model.

"""

frequency: float = field(validator=(validators.instance_of((float, int)), validators.gt(0)))
downtime: Any = field(converter=generate_downtime_model)
downtime_per_event: np.ndarray = field(init=False, validator=validators.instance_of(np.ndarray))
availability: np.ndarray = field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment here about having n_timesteps as a configuration parameter.

default=np.ones(N_TIMESTEPS), init=False, validator=validators.instance_of(np.ndarray)
)

def __attrs_post_init__(self):
self.create_downtime_events()
self.calculate_availability()

def sample_events(self):
frequency = np.array([0.1, 20, 30]).reshape(-1, 1)
interval = np.ceil(8760 / frequency).astype(int)
first_occurrence = rng.integers(0, np.where(interval > 8760, 8760, interval))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should these 8760's be n_timesteps?

return first_occurrence, interval

def create_downtime_events(self):
"""Creates a ``time_to_failure`` and ``downtime_per_event``."""
self.first_occurrence, self.interval = self.sample_events()
self.downtime_per_event = self.downtime.sample_downtime()

def calculate_availability(self):
"""Determine the timing and duration of outages for a single year of simulation time."""
duration, self.downtime_per_event = (
self.downtime_per_event[0],
self.downtime_per_event[1:],
)
end = self.first_occurrence + duration
self.availability[self.first_occurrence : end] = 0
accumulated = end
while (start := accumulated + self.interval) < N_TIMESTEPS:
duration, self.downtime_per_event = (
self.downtime_per_event[0],
self.downtime_per_event[1:],
)
# TODO: handle start > 8760
end = start + duration
end = np.where(end > 8760, 8760, end)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here - should 8760 be n_timesteps or N_TIMESTEPS (as you have it now?)

self.availability[start:end] = 0
accumulated = end
if not self.downtime_per_event:
self.downtime.sample_downtime()