diff --git a/.gitignore b/.gitignore index 364a034..5fe0fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ wheels/ *.egg-info/ .installed.cfg *.egg +.DS_Store +*.DS_Store # PyInstaller # Usually these files are written by a python script from a template diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0dd561d..2b23913 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,38 +1,36 @@ default_language_version: - python: python3 - + python: python3 repos: -- repo: https://github.com/pycqa/isort + - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - - id: isort - name: isort - stages: [pre-commit] - -- repo: https://github.com/psf/black - rev: 22.3.0 - hooks: - - id: black - name: black - stages: [pre-commit] - language_version: python3 - -- repo: https://github.com/pre-commit/pre-commit-hooks + - id: isort + name: isort + stages: [pre-commit] + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-executables-have-shebangs - - id: check-json - - id: check-yaml - - id: check-merge-conflict - - id: check-symlinks - - id: mixed-line-ending - - id: pretty-format-json + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-executables-have-shebangs + - id: check-json + - id: check-yaml + args: [--unsafe] # allow Python constructors + - id: check-merge-conflict + - id: check-symlinks + - id: mixed-line-ending + - id: pretty-format-json args: [--autofix] - -- repo: https://github.com/pycqa/flake8 - rev: '5.0.4' + - repo: https://github.com/lyz-code/yamlfix/ + rev: 1.19.1 + hooks: + - id: yamlfix + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.8.1 hooks: - - id: flake8 - args: [--max-line-length=88, "--ignore=E741,W503,E203", "--per-file-ignores=*/__init__.py:F401"] + - id: ruff-format + types_or: [python, pyi, jupyter] + - id: ruff + types_or: [python, pyi, jupyter] + args: [--fix, --unsafe-fixes] diff --git a/README.md b/README.md index 6cce044..d1a81f8 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ includes levelized cost of hydrogen (LCOH) analysis utilities. Python 3.11+ is required. +Create conda environment: + ```bash -pip install . +conda create --name bert python=3.11 -y +conda activate bert ``` Optional extras: diff --git a/electrolyzer/__init__.py b/electrolyzer/__init__.py index 2b08a6e..d602829 100644 --- a/electrolyzer/__init__.py +++ b/electrolyzer/__init__.py @@ -4,4 +4,9 @@ __email__ = "christopher.bay@nrel.gov" __version__ = "0.2.0" -# noqa +from pathlib import Path + + +BERT_ROOT_DIR = Path(__file__).resolve().parent +BERT_EXAMPLE_DIR = BERT_ROOT_DIR.parent / "examples" +# BERT_LIBRARY_DIR = BERT_ROOT_DIR.parent / "library" diff --git a/electrolyzer/components/__init__.py b/electrolyzer/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/cell/__init__.py b/electrolyzer/components/cell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/cell/cell.py b/electrolyzer/components/cell/cell.py new file mode 100644 index 0000000..e568084 --- /dev/null +++ b/electrolyzer/components/cell/cell.py @@ -0,0 +1,62 @@ +import openmdao.api as om +from attrs import field, define, validators + +from electrolyzer.core.utilities import BaseConfig + + +@define(kw_only=True) +class CellBaseConfig(BaseConfig): + A_cell: float = field(validator=(validators.gt(0.0))) + membrane_thickness: float = field(validator=(validators.gt(0.0))) + temperature: float = field(validator=(validators.ge(50.0))) + P_anode: float = field(validator=(validators.ge(0.0))) + P_cathode: float = field(validator=(validators.ge(0.0))) + R_ohmic_elec: float = field(validator=(validators.ge(0.0))) + + +class CellBaseClass(om.ExplicitComponent): + def initialize(self): + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + self.n_timesteps = self.options["plant_config"]["simulation"]["n_timesteps"] + self.dt = self.options["plant_config"]["simulation"]["dt"] + + # design variables + self.add_input("cell_active_area", val=self.config.A_cell, units="A/(cm**2)") + self.add_input("membrane_thickness", val=self.config.membrane_thickness, units="cm") + self.add_input("operating_temperature", val=self.config.temperature, units="degC") + self.add_input("anode_pressure", self.config.P_anode, units="bar") + self.add_input("cathode_pressure", self.config.P_cathode, units="bar") + self.add_input("R_elec", self.config.R_ohmic_elec, units="ohm*(cm**2)") + + # input profiles + self.add_input("current_in", val=0.0, shape_by_conn=True, units="A") # OR current density? + self.add_input("current_density_in", val=0.0, shape_by_conn=True, units="A/(cm**2)") + + # output profiles + self.add_output("cell_voltage", val=0.0, copy_shape="current_in", units="V") + self.add_output( + "hydrogen_produced", val=0.0, copy_shape="current_in", units=f"kg/({self.dt}*s)" + ) + self.add_output( + "oxygen_produced", val=0.0, copy_shape="current_in", units=f"kg/({self.dt}*s)" + ) + self.add_output( + "water_consumed", val=0.0, copy_shape="current_in", units=f"kg/({self.dt}*s)" + ) + self.add_output("hydrogen_production_rate", val=0.0, copy_shape="current_in", units="kg/s") + self.add_input("current_density_out", val=0.0, copy_shape="current_in", units="A/(cm**2)") + + # output design variables + self.add_output("rated_cell_voltage", val=0.0, units="V") + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """ + Computation for the OM component. + + For a template class this is not implement and raises an error. + """ + + raise NotImplementedError("This method should be implemented in a subclass.") diff --git a/electrolyzer/components/cell/pem_cell.py b/electrolyzer/components/cell/pem_cell.py new file mode 100644 index 0000000..250a29b --- /dev/null +++ b/electrolyzer/components/cell/pem_cell.py @@ -0,0 +1,37 @@ +from attrs import field, define, validators + +from electrolyzer.tools.validators import contains, range_val +from electrolyzer.components.cell.cell import CellBaseClass, CellBaseConfig + + +@define(kw_only=True) +class PEMCellConfig(CellBaseConfig): + f1: float = field(default=250, validator=validators.ge(0)) + f2: float = field(default=0.996, validator=range_val(0.5, 1.0)) + i_0a: float = field(default=2.0e-7, validator=range_val(0.0, 1.0)) + i_0c: float = field(default=2.0e-3, validator=range_val(0.0, 1.0)) + alpha_a: float = field(default=2.0, validator=range_val(0.0, 4.0)) + alpha_c: float = field(default=0.5, validator=range_val(0.0, 4.0)) + + water_vaporization_method: str = field( + default="buck", converter=(str.lower, str.strip), validator=contains(["buck", "antoine"]) + ) + activation_method: str = field( + default="arcsinh", validator=contains(["ln", "log10", "arcsinh"]) + ) + Urev0_calc_method: str = field( + default="normal", validator=contains(["normal", "temp_adjusted"]) + ) + + +class PEMCell(CellBaseClass): + def setup(self): + self.config = PEMCellConfig.from_dict(self.options["tech_config"]["cell_parameters"]) + super().setup() + # Design parameters + self.add_input("i_0a", val=self.config.i_0a, shape=1, units="A/(cm**2)") + self.add_input("i_0c", val=self.config.i_0c, shape=1, units="A/(cm**2)") + self.add_input("f1", val=self.config.f1, shape=1, units="(mA**2)/(cm**4)") + self.add_input("f2", val=self.config.f2, shape=1, units="unitless") + self.add_input("alpha_a", val=self.config.alpha_a, shape=1, units="unitless") + self.add_input("alpha_c", val=self.config.alpha_c, shape=1, units="unitless") diff --git a/electrolyzer/components/cluster/__init__.py b/electrolyzer/components/cluster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/cluster/cluster.py b/electrolyzer/components/cluster/cluster.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/stack/__init__.py b/electrolyzer/components/stack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/components/stack/stack.py b/electrolyzer/components/stack/stack.py new file mode 100644 index 0000000..57d45f9 --- /dev/null +++ b/electrolyzer/components/stack/stack.py @@ -0,0 +1,26 @@ +import openmdao.api as om + + +class StackBaseClass(om.ExplicitComponent): + def initialize(self): + self.options.declare("plant_config", types=dict) + self.options.declare("tech_config", types=dict) + + def setup(self): + # design variables + + # input profiles + self.add_input("current", val=0.0, shape=self.n_timesteps, units="A") + + # output profiles + + # constraints + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + """ + Computation for the OM component. + + For a template class this is not implement and raises an error. + """ + + raise NotImplementedError("This method should be implemented in a subclass.") diff --git a/electrolyzer/control/__init__.py b/electrolyzer/control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/control/feedback/__init__.py b/electrolyzer/control/feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/control/openloop/__init__.py b/electrolyzer/control/openloop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/core/__init__.py b/electrolyzer/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/core/bert.py b/electrolyzer/core/bert.py new file mode 100644 index 0000000..ad064a3 --- /dev/null +++ b/electrolyzer/core/bert.py @@ -0,0 +1,19 @@ +class BERT: + def __init__(self, config_input): + # read in config file; it's a yaml dict that looks like this: + self.load_config(config_input) + + def load_config(config_input): + pass + + def create_custom_models(self, model_config, config_parent_path, model_types, prefix=""): + pass + + def setup(self): + pass + + def run(self): + pass + + def post_process(self): + pass diff --git a/electrolyzer/core/file_utils.py b/electrolyzer/core/file_utils.py new file mode 100644 index 0000000..38b6837 --- /dev/null +++ b/electrolyzer/core/file_utils.py @@ -0,0 +1,2 @@ +def play_func(txt): + return txt diff --git a/electrolyzer/core/pose_optimization.py b/electrolyzer/core/pose_optimization.py new file mode 100644 index 0000000..e69de29 diff --git a/electrolyzer/core/utilities.py b/electrolyzer/core/utilities.py new file mode 100644 index 0000000..978cd88 --- /dev/null +++ b/electrolyzer/core/utilities.py @@ -0,0 +1,95 @@ +from typing import Any + +import attrs +import numpy as np +from attrs import Attribute, define + + +@define(kw_only=True) +class BaseConfig: + """ + A Mixin class to allow for kwargs overloading when a data class doesn't + have a specific parameter defined. This allows passing of larger dictionaries + to a data class without throwing an error. + """ + + @classmethod + def from_dict(cls, data: dict, strict=True, additional_cls_name: str | None = None): + """Maps a data dictionary to an ``attrs``-defined class. + + Args: + data (dict): The data dictionary to be mapped. + strict (bool): A flag enabling strict parameter processing, meaning that no extra + parameters may be passed in or an AttributeError will be raised. + additional_cls_name (str | None): The name of the model class creating the configuration + data class. Provides an easier to diagnose error message for end users when + the class name is provided. + + Returns: + cls: The ``attrs``-defined class. + """ + # Check for any inputs that aren't part of the class definition + if strict is True: + class_attr_names = [a.name for a in cls.__attrs_attrs__] + extra_args = [d for d in data if d not in class_attr_names] + if len(extra_args): + if additional_cls_name is not None: + msg = ( + f"{additional_cls_name} setup failed as a result of {cls.__name__}" + f" receiving extraneous inputs: {extra_args}" + ) + else: + msg = ( + f"The initialization for {cls.__name__} was given extraneous " + f"inputs: {extra_args}" + ) + raise AttributeError(msg) + + kwargs = {a.name: data[a.name] for a in cls.__attrs_attrs__ if a.name in data and a.init} + + # Map the inputs must be provided: 1) must be initialized, 2) no default value defined + required_inputs = [ + a.name for a in cls.__attrs_attrs__ if a.init and a.default is attrs.NOTHING + ] + undefined = sorted(set(required_inputs) - set(kwargs)) + + if undefined: + if additional_cls_name is not None: + msg = ( + f"{additional_cls_name} setup failed as a result of {cls.__name__}" + f" missing the following inputs: {undefined}" + ) + else: + msg = ( + f"The class definition for {cls.__name__} is missing the following inputs: " + f"{undefined}" + ) + raise AttributeError(msg) + return cls(**kwargs) + + def as_dict(self) -> dict: + """Creates a JSON and YAML friendly dictionary that can be save for future reloading. + This dictionary will contain only `Python` types that can later be converted to their + proper `Turbine` formats. + + Returns: + dict: All key, value pairs required for class re-creation. + """ + return attrs.asdict(self, filter=attr_filter, value_serializer=attr_serializer) + + +def attr_serializer(inst: type, field: Attribute, value: Any): + if isinstance(value, np.ndarray): + return value.tolist() + return value + + +def attr_filter(inst: Attribute, value: Any) -> bool: + if inst.init is False: + return False + if value is None: + return False + if isinstance(value, np.ndarray): + if value.size == 0: + return False + return True diff --git a/pyproject.toml b/pyproject.toml index 5ecffd2..c52b487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "attrs", "jsonschema", "ruamel.yaml", + "openmdao", ] keywords = [ "electrolyzer", @@ -39,16 +40,19 @@ classifiers = [ [project.optional-dependencies] develop = [ - "pytest", + "pytest>=9", "pytest-subtests", "pytest-dependency", "pre-commit", "pytest_mock", "responses", - "jupyter-book", + "jupyter-book<2", "readthedocs-sphinx-ext", - "Sphinx", + "ruff", + "sphinx", "sphinxcontrib-napoleon", + "isort", + "networkx", ] examples = ["jupyterlab"] all = ["electrolyzer[develop,examples]"] @@ -87,29 +91,8 @@ testpaths = [ ] -[tool.black] -line-length = 88 -target-version = ["py37", "py38", "py39"] -include = '\.pyi?$' -extend-exclude = ''' -/( - \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | _build - | buck-out - | build - | dist - | docs -)/ -''' - - [tool.isort] multi_line_output = 3 -ensure_newline_before_comments = true sections = [ "FUTURE", "STDLIB", @@ -130,4 +113,99 @@ include_trailing_comma = true use_parentheses = true length_sort = true lines_after_imports = 2 -line_length = 88 +line_length = 100 + + + +[tool.ruff] +src = ["electrolyzer", "tests"] +line-length = 100 +target-version = "py311" +fix = true +exclude = [ + ".git", + "__pycache__", + "old", + "build", + "dist", + ".ruff_cache", +] + +[tool.ruff.lint] +fixable = ["ALL"] +unfixable = [] + +# https://docs.astral.sh/ruff/rules/ +select = [ + "F", + "E", + "W", + # "N", # TODO: renaming needs to be seriously cleaned up prior to using + "C4", + # "D", # leave out until rebrand/refactor + "UP", + "BLE", + # "FBT", # TODO: consider alongside rebrand/refactor + "B", + "A", + "LOG", + "G", + # "PT", # TODO: very tedious for now + "NPY", + "PD", + "PTH", + "PERF", + "Q", + "FURB", + "RUF", +] + +ignore = [ + "E731", + "E402", + "D202", + "D205", + "D212", + "D401", + "PERF203", + "PD011", + "PD901", + "RUF015", + "N806", # NOTE: many variable names use an uppercase for legitimate reasons + "N802", # NOTE: many function names use an uppercase for legitimate reasons + "B905", + + # TODO: People who wrote warnings should decide on the appropriate stacklevel + "B028", + + # TODO: People responsible for these errors will need to fix them accordingly as they are bugs + "B006", + "B008", + "B023", +] + +[tool.ruff.lint.per-file-ignores] +"*/__init__.py" = ["E501", "D104", "F401"] +"test/*" = ["D100", "D101", "D102", "D103"] + +[tool.ruff.lint.pycodestyle] +ignore-overlong-task-comments = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.isort] +known-third-party = ["pytest"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +[tool.yamlfix] +allow_duplicate_keys = true +line_length = 120 +none_representation = "null" +explicit_start = false +# quote_representation = '"' # NOTE: uncomment when issue is resolved: https://github.com/lyz-code/yamlfix/issues/252 +sequence_style = "keep_style"