From ce3458bf6224e17d1ccde48e6f3450d3ccb4d7d0 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 2 Nov 2023 10:45:02 -0700 Subject: [PATCH 01/73] outputs --- idaes/core/base/flowsheet_model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/idaes/core/base/flowsheet_model.py b/idaes/core/base/flowsheet_model.py index d0775ac2b7..f8e92c104b 100644 --- a/idaes/core/base/flowsheet_model.py +++ b/idaes/core/base/flowsheet_model.py @@ -74,10 +74,7 @@ def __init__(self): self.visualize = self._visualize_null self.installed = False else: - # FIXME the explicit submodule import is needed because the idaes_ui doesn't import its fv submodule - # otherwise, you get "AttributeError: module 'idaes_ui' has no 'fv' attribute" - import idaes_ui.fv - + import idaes_ui self.visualize = idaes_ui.fv.visualize self.installed = True From 39510a935c7f960955ab1a7944dbf818395adcba Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 06:36:55 -0800 Subject: [PATCH 02/73] initial --- .../core/util/structured_notebook/__init__.py | 0 .../core/util/structured_notebook/fsrunner.py | 113 +++++++++ .../core/util/structured_notebook/logutil.py | 46 ++++ idaes/core/util/structured_notebook/runner.py | 237 ++++++++++++++++++ .../structured_notebook/runner_actions.py | 84 +++++++ .../structured_notebook/tests/__init__.py | 0 .../tests/test_fsrunner.py | 70 ++++++ .../structured_notebook/tests/test_logutil.py | 86 +++++++ .../structured_notebook/tests/test_runner.py | 62 +++++ .../tests/test_runner_actions.py | 76 ++++++ 10 files changed, 774 insertions(+) create mode 100644 idaes/core/util/structured_notebook/__init__.py create mode 100644 idaes/core/util/structured_notebook/fsrunner.py create mode 100644 idaes/core/util/structured_notebook/logutil.py create mode 100644 idaes/core/util/structured_notebook/runner.py create mode 100644 idaes/core/util/structured_notebook/runner_actions.py create mode 100644 idaes/core/util/structured_notebook/tests/__init__.py create mode 100644 idaes/core/util/structured_notebook/tests/test_fsrunner.py create mode 100644 idaes/core/util/structured_notebook/tests/test_logutil.py create mode 100644 idaes/core/util/structured_notebook/tests/test_runner.py create mode 100644 idaes/core/util/structured_notebook/tests/test_runner_actions.py diff --git a/idaes/core/util/structured_notebook/__init__.py b/idaes/core/util/structured_notebook/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idaes/core/util/structured_notebook/fsrunner.py b/idaes/core/util/structured_notebook/fsrunner.py new file mode 100644 index 0000000000..d999cb0654 --- /dev/null +++ b/idaes/core/util/structured_notebook/fsrunner.py @@ -0,0 +1,113 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +from typing import Sequence +from pyomo.environ import ConcreteModel +from idaes.core import FlowsheetBlock +from pyomo.network.port import ScalarPort +from idaes.core.util.model_statistics import degrees_of_freedom +from structured_notebook.runner import Runner + + +class Context(dict): + @property + def model(self): + return self["model"] + + @model.setter + def model(self, value): + self["model"] = value + + @property + def solver(self): + return self["solver"] + + @solver.setter + def solver(self, value): + self["solver"] = value + + +class FlowsheetRunner(Runner): + STEPS = ( + "build", + "set_operating_conditions", + "set_scaling", + "initialize", + "set_solver", + "solve_initial", + "add_costing", + "check_model_structure", + "initialize_costing", + "solve_optimization", + "check_model_numerics", + ) + + def __init__(self, solver=None, tee=False): + self.build_step = self.STEPS[0] + self._solver, self._tee = solver, tee + super().__init__(self.STEPS) + + def run_steps(self, from_name: str = "", to_name: str = ""): + from_step_name = self._norm_name(from_name) + if ( + from_step_name == "" + or from_step_name == self.build_step + or self._context.model is None + ): + self._context.model = self._create_model() + super().run_steps(from_name, to_name) + + def reset(self): + self._context = Context(solver=self._solver, tee=self._tee, model=None) + self._component_dof = {} + + def _create_model(self): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + return m + + @property + def model(self): + return self._context.model + + @property + def results(self): + return self._context["results"] + + def component_degrees_of_freedom(self): + return self._component_dof + + def check_dof(self, block, fix_inlets: bool = True, expected: int | None = 0): + name = block.name + if fix_inlets: + inlets = [ + c + for c in block.component_objects(descend_into=False) + if isinstance(c, ScalarPort) + and (c.name.endswith("inlet") or c.name.endswith("recycle")) + ] + free_me = [] + for inlet in inlets: + if not inlet.is_fixed(): + inlet.fix() + free_me.append(inlet) + dof = degrees_of_freedom(block) + self._component_dof[name] = dof + if fix_inlets: + for inlet in free_me: + inlet.free() + if expected is not None: + assert dof == expected, ( + f"Degrees of freedom for component " + f"{name} ({dof}) does not match " + f"expected ({expected})" + ) diff --git a/idaes/core/util/structured_notebook/logutil.py b/idaes/core/util/structured_notebook/logutil.py new file mode 100644 index 0000000000..9565506139 --- /dev/null +++ b/idaes/core/util/structured_notebook/logutil.py @@ -0,0 +1,46 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +""" +Utility functions for logging +""" + +import logging +import warnings + +g_quiet = {} + + +def quiet(roots=("idaes", "pyomo"), level=logging.CRITICAL): + """Be very quiet. I'm hunting wabbits. + + Ignore warnings and set all loggers starting with one of + the values in 'roots' to the given level (default=CRITICAL). + """ + warnings.filterwarnings("ignore") + all_loggers = [logging.getLogger()] + [ + logging.getLogger(name) for name in logging.root.manager.loggerDict + ] + for lg in all_loggers: + for root in roots: + if lg.name.startswith(root + "."): + g_quiet[lg.name] = lg.level + lg.setLevel(logging.CRITICAL) + + +def unquiet(): + """Reverse previous quiet()""" + for k in list(g_quiet.keys()): + v = g_quiet[k] + lg = logging.getLogger(k) + lg.setLevel(v) + del g_quiet[k] diff --git a/idaes/core/util/structured_notebook/runner.py b/idaes/core/util/structured_notebook/runner.py new file mode 100644 index 0000000000..09c221411e --- /dev/null +++ b/idaes/core/util/structured_notebook/runner.py @@ -0,0 +1,237 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +""" +Run functions in a module in a defined, named, sequence. +""" + +# stdlib +import logging +from typing import Callable, Optional, Tuple, Sequence + +__author__ = "Dan Gunter (LBNL)" + +_log = logging.Logger(__name__) + + +class Step: + def __init__(self, name: str, func: Callable): + self.name: str = name + self.func: Callable | None = func + self.substeps: list[Tuple[str, Callable]] = [] + + def add_substep(self, name: str, func: Callable): + self.substeps.append((name, func)) + + +type ActionType = Action + + +class Runner: + """Run a set of defined steps.""" + + def __init__(self, steps: Sequence[str]): + self._actions: dict[str, ActionType] = {} + self._step_names = list(steps) + self._steps: dict[str, Step] = {} + self.reset() + + def __getitem__(self, key): + return self._context[key] + + def add_step(self, name: str, func: Callable): + step_name = self._norm_name(name) + + if step_name not in self._step_names: + raise KeyError(f"Unknown step: {step_name}") + self._steps[step_name] = Step(step_name, func) + + def add_substep(self, base_name, name, func): + substep_name = self._norm_name(name) + base_step_name = self._norm_name(base_name) + if base_step_name not in self._step_names: + raise KeyError( + f"Unknown base step {base_step_name} for substep {substep_name}" + ) + try: + step = self._steps[base_step_name] + except KeyError: + raise ValueError( + f"Empty base step {base_step_name} for substep {substep_name}" + ) + step.add_substep(substep_name, func) + + def run_step(self, name): + self.run_steps(from_name=name, to_name=name) + + def run_steps(self, from_name: str = "", to_name: str = ""): + if not self._steps: + return # nothing to do, no steps defined + + names = (self._norm_name(from_name), self._norm_name(to_name)) + + step_range = [-1, -1] + for i, step_name in enumerate(names): + if step_name == "": + idx = self._first_step() if i == 0 else self._last_step() + else: + try: + idx = self._step_names.index(step_name) + except ValueError: + raise KeyError(f"Unknown step: {step_name}") + if step_name not in self._steps: + raise KeyError(f"Empty step: {step_name}") + step_range[i] = idx + + for action in self._actions.values(): + action.before_run() + + for i in range(step_range[0], step_range[1] + 1): + step = self._steps.get(self._step_names[i], None) + if step: + step.func(self._context) + + for action in self._actions.values(): + action.after_run() + + def reset(self): + self._context = {} + + def add_action(self, name: str, action_class: type, *args, **kwargs): + obj = action_class(self, *args, **kwargs) + self._actions[name] = obj + + def get_action(self, name: str) -> ActionType: + return self._actions[name] + + def remove_action(self, name: str): + del self._actions[name] + + def _step_index(self, name: str): + return self._step_names.index(name) + + def _first_step(self): + for i, name in enumerate(self._step_names): + if name in self._steps: + return i + return -1 + + def _last_step(self): + for i in range(len(self._step_names) - 1, -1, -1): + name = self._step_names[i] + if name in self._steps: + return i + return -1 + + @staticmethod + def _norm_name(s: str | None) -> str: + return "" if s is None else s.lower() + + def _step_begin(self, name: str): + for action in self._actions.values(): + action.before_step(name) + + def _step_end(self, name: str): + for action in self._actions.values(): + action.after_step(name) + + def step(self, name: str): + """Decorator function for creating a new step. + + Args: + name: Step name + + Returns: + Decorator function. + """ + + def step_decorator(func): + + def wrapper(*args, **kwargs): + self._step_begin(name) + result = func(*args, **kwargs) + self._step_end(name) + return result + + self.add_step(name, wrapper) + + return wrapper + + return step_decorator + + def substep(self, base: str, name: str): + """Decorator function for creating a new substep. + + Substeps are not run directly, and must have an already + existing base step as their parent. + + Args: + base: Base step name + name: Substep name + + Returns: + Decorator function. + """ + + def step_decorator(func): + + def wrapper(*args, **kwargs): + self._step_begin(name) + result = func(*args, **kwargs) + self._step_end(name) + return result + + self.add_substep(base, name, wrapper) + + return wrapper + + return step_decorator + + +class Action: + """Do something before and/or after each step / run. + + ``` + class GreetingAction(Action): + def before_step(self, name, runner): + print(f"Hello, step {name}") + def after_step(self, name, runner): + print(f"Goodbye, step {name}") + + class DivaAction(Action): + def after_step(self, name, runner): + if name == "act": + print(f"I am now an ac-TORRRR!") + + myrunner = Runner(steps=["plan", "act", "inspect", "revise"]) + myrunner.add_action(GreetingAction) + myrunner.add_action(DivaAction) + ``` + """ + + def __init__(self, runner: Runner, log: logging.Logger | None = None): + self._runner = runner + if log is None: + log = _log + self.log = log + + def before_step(self, step_name: str): + return + + def after_step(self, step_name: str): + return + + def before_run(self): + return + + def after_run(self): + return diff --git a/idaes/core/util/structured_notebook/runner_actions.py b/idaes/core/util/structured_notebook/runner_actions.py new file mode 100644 index 0000000000..6ee77800b2 --- /dev/null +++ b/idaes/core/util/structured_notebook/runner_actions.py @@ -0,0 +1,84 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +""" +Predefined Actions for the generic Runner. +""" + +from collections import defaultdict +import time + +from .runner import Action + + +class Timer(Action): + """Simple step/run timer action.""" + + def __init__(self, runner): + """Constructor. + + Args: + runner: Associated Runner object + + Attributes: + step_times: Dict with key step name and value a list of + timings for that step + run_times: List of timings for a run (sequence of steps) + """ + super().__init__(runner) + self.step_times = defaultdict(list) + self.run_times = [] + self._step_begin = {} + self._run_begin = None + self._step_order = [] + + def before_step(self, step_name): + self._step_begin[step_name] = time.time() + if len(self.run_times) == 0: + self._step_order.append(step_name) + + def after_step(self, step_name): + t1 = time.time() + t0 = self._step_begin.get(step_name, None) + if t0 is None: + self.log.warning(f"Timer: step {step_name} end without begin") + else: + dt = t1 - t0 + self.step_times[step_name].append(dt) + self._step_begin[step_name] = None + + def before_run(self): + self._run_begin = time.time() + + def after_run(self): + t1 = time.time() + if self._run_begin is None: + self.log.warning(f"Timer: run end without begin") + else: + dt = t1 - self._run_begin + self.run_times.append(dt) + self._run_begin = None + + def summary(self): + data = [] + for i, run_time in enumerate(self.run_times): + step_times = [(k, self.step_times[k][i]) for k in self._step_order] + step_total = sum((item[1] for item in step_times)) + data.append( + { + "run": run_time, + "steps": step_times, + "inclusive": step_total, + "exclusive": run_time - step_total, + } + ) + return data diff --git a/idaes/core/util/structured_notebook/tests/__init__.py b/idaes/core/util/structured_notebook/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idaes/core/util/structured_notebook/tests/test_fsrunner.py b/idaes/core/util/structured_notebook/tests/test_fsrunner.py new file mode 100644 index 0000000000..68fadb9367 --- /dev/null +++ b/idaes/core/util/structured_notebook/tests/test_fsrunner.py @@ -0,0 +1,70 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +import pytest +from structured_notebook.fsrunner import FlowsheetRunner + +# -- setup -- + +fsr = FlowsheetRunner() + + +@fsr.step("build") +def build_it(context): + print("flowsheet - build") + assert context.model.fs is not None + add_units(context.model) + + +@fsr.substep("build", "add_units") +def add_units(m): + print("flowsheet - add units (substep)") + assert m.fs is not None + + +@fsr.step("add_costing") +def add_costing(context): + print("flowsheet - costing") + assert context.model is not None + + +@fsr.step("solve_optimization") +def solve_opt(context): + print("flowsheet - solve") + assert context.model is not None + context["results"] = 123 + + +# -- end setup -- + + +@pytest.mark.unit +def test_run_all(): + fsr.run_steps() + assert fsr.results == 123 + + +@pytest.mark.unit +def test_rerun(): + fsr.run_steps() + first_model = fsr.model + # model not changed + fsr.run_steps("solve_optimization") + assert fsr.model == first_model + # reset forces new model + fsr.reset() + fsr.run_steps("solve_optimization") + assert fsr.model != first_model + second_model = fsr.model + # running from build also creates new model + fsr.run_steps("build", "add_costing") + assert fsr.model != second_model diff --git a/idaes/core/util/structured_notebook/tests/test_logutil.py b/idaes/core/util/structured_notebook/tests/test_logutil.py new file mode 100644 index 0000000000..af481907b1 --- /dev/null +++ b/idaes/core/util/structured_notebook/tests/test_logutil.py @@ -0,0 +1,86 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +""" +Tests for logutil module +""" + +# standard library +import logging +import time + +# third-party +import pytest + +# package +from .. import logutil + +DEBUG = False + + +def numbytes(p): + return p.stat().st_size + + +def dump_console(p): + if DEBUG: + with open(p) as f: + print(f.read()) + + +@pytest.mark.unit +def test_quiet(tmp_path): + log = logging.getLogger("idaes.test_quiet") + ni_log = logging.getLogger("ni.test_quiet") + logfile = tmp_path / "test_quiet.log" + handler = logging.FileHandler(logfile) + log.addHandler(handler) + ni_log.addHandler(handler) + assert numbytes(logfile) == 0 + log.setLevel(logging.INFO) + ni_log.setLevel(logging.INFO) + + # messages initially logged + for i in range(2): + log.info("this is a log message 1") + time.sleep(0.1) + dump_console(logfile) + sz1 = numbytes(logfile) + assert sz1 > 0 + + logutil.quiet() + + # no more messages from idaes. logger + for i in range(2): + log.info("this is a log message 2") + time.sleep(0.1) + dump_console(logfile) + sz2 = numbytes(logfile) + assert sz2 == sz1 + + # still get messages from non-idaes. logger + for i in range(2): + ni_log.info("this is a log message 3") + time.sleep(0.1) + dump_console(logfile) + sz3 = numbytes(logfile) + assert sz3 > sz2 + + logutil.unquiet() + + # get messages from idaes. logger again + for i in range(2): + log.info("this is a log message 4") + time.sleep(0.1) + dump_console(logfile) + sz4 = numbytes(logfile) + assert sz4 > sz3 diff --git a/idaes/core/util/structured_notebook/tests/test_runner.py b/idaes/core/util/structured_notebook/tests/test_runner.py new file mode 100644 index 0000000000..df83351501 --- /dev/null +++ b/idaes/core/util/structured_notebook/tests/test_runner.py @@ -0,0 +1,62 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +import pytest +from structured_notebook.runner import Runner + +## -- setup -- + +simple = Runner(("notrun-1", "hello", "hello.dude", "world", "notrun-2")) + + +@simple.step("hello") +def say_hello(context): + context["greeting"] = "Hello" + dude("yo") + + +@simple.substep("hello", "dude") +def dude(s): + print(f"{s}! this is called from hello, not directly by runner") + + +@simple.step("world") +def say_to_world(context): + msg = f"{context['greeting']}, World!" + print(msg) + context["greeting"] = msg + + +# -- end setup -- + + +@pytest.mark.unit +def test_simple_run_all(): + simple.run_steps() + assert simple._context["greeting"] == "Hello, World!" + + +@pytest.mark.unit +def test_runner_actions(): + + rn = Runner(("a step",)) + + def do_nothing(context): + print("do nothing") + + rn.add_action("nothing", do_nothing) + rn.get_action("nothing") + with pytest.raises(KeyError): + rn.get_action("something") + rn.remove_action("nothing") + with pytest.raises(KeyError): + rn.get_action("nothing") diff --git a/idaes/core/util/structured_notebook/tests/test_runner_actions.py b/idaes/core/util/structured_notebook/tests/test_runner_actions.py new file mode 100644 index 0000000000..b8b5e4b573 --- /dev/null +++ b/idaes/core/util/structured_notebook/tests/test_runner_actions.py @@ -0,0 +1,76 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### +import pytest +from pytest import approx + +from structured_notebook import runner_actions, runner +import time + + +@pytest.mark.unit +def test_class_timer(): + timer = runner_actions.Timer(runner.Runner([])) + n, m = 2, 3 + for i in range(n): + timer.before_run() + time.sleep(0.1) + for j in range(m): + name = f"step{j}" + timer.before_step(name) + time.sleep(0.1 * (j + 1)) + timer.after_step(name) + time.sleep(0.1) + timer.after_run() + + s = timer.summary() + # tests/test_runner_actions.py [ + # {'run': 0.8005404472351074, + # 'steps': [('step0', 0.10010385513305664), ('step1', 0.20009303092956543), + # ('step2', 0.3000965118408203)], 'inclusive': 0.6002933979034424, 'exclusive': 0.20024704933166504}, + # + # {'run': 0.8004975318908691, 'steps': [('step0', 0.10008621215820312), + # ('step1', 0.20008587837219238), + # ('step2', 0.3000912666320801)], 'inclusive': 0.6002633571624756, 'exclusive': 0.20023417472839355}] + eps = 5e-3 + for r in s: + assert r["run"] == approx(0.8, abs=eps) + assert r["inclusive"] + r["exclusive"] == approx(r["run"]) + for i, (name, t) in enumerate(r["steps"]): + assert name == f"step{i}" + assert t == approx(0.1 + 0.1 * i, abs=eps) + + +@pytest.mark.component +def test_timer_runner(): + rn = runner.Runner(["step1", "step2", "step3"]) + + def sleepy(context): + time.sleep(0.1) + + rn.add_step("step1", sleepy) + rn.add_step("step2", sleepy) + rn.add_step("step3", sleepy) + + rn.add_action("timer", runner_actions.Timer) + + rn.run_steps() + + s = rn.get_action("timer").summary() + + eps = 0.01 + for r in s: + assert r["run"] == approx(0.3, abs=eps) + assert r["inclusive"] + r["exclusive"] == approx(r["run"]) + for i, (name, t) in enumerate(r["steps"]): + assert name == f"step{i + 1}" + assert t == approx(0.1, abs=eps) From 97499d6aad3d733eacf622e55bf3f9692e01fdf4 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 06:42:52 -0800 Subject: [PATCH 03/73] rename --- idaes/core/util/{structured_notebook => structfs}/__init__.py | 0 idaes/core/util/{structured_notebook => structfs}/fsrunner.py | 0 idaes/core/util/{structured_notebook => structfs}/logutil.py | 0 idaes/core/util/{structured_notebook => structfs}/runner.py | 0 .../core/util/{structured_notebook => structfs}/runner_actions.py | 0 .../core/util/{structured_notebook => structfs}/tests/__init__.py | 0 .../util/{structured_notebook => structfs}/tests/test_fsrunner.py | 0 .../util/{structured_notebook => structfs}/tests/test_logutil.py | 0 .../util/{structured_notebook => structfs}/tests/test_runner.py | 0 .../tests/test_runner_actions.py | 0 10 files changed, 0 insertions(+), 0 deletions(-) rename idaes/core/util/{structured_notebook => structfs}/__init__.py (100%) rename idaes/core/util/{structured_notebook => structfs}/fsrunner.py (100%) rename idaes/core/util/{structured_notebook => structfs}/logutil.py (100%) rename idaes/core/util/{structured_notebook => structfs}/runner.py (100%) rename idaes/core/util/{structured_notebook => structfs}/runner_actions.py (100%) rename idaes/core/util/{structured_notebook => structfs}/tests/__init__.py (100%) rename idaes/core/util/{structured_notebook => structfs}/tests/test_fsrunner.py (100%) rename idaes/core/util/{structured_notebook => structfs}/tests/test_logutil.py (100%) rename idaes/core/util/{structured_notebook => structfs}/tests/test_runner.py (100%) rename idaes/core/util/{structured_notebook => structfs}/tests/test_runner_actions.py (100%) diff --git a/idaes/core/util/structured_notebook/__init__.py b/idaes/core/util/structfs/__init__.py similarity index 100% rename from idaes/core/util/structured_notebook/__init__.py rename to idaes/core/util/structfs/__init__.py diff --git a/idaes/core/util/structured_notebook/fsrunner.py b/idaes/core/util/structfs/fsrunner.py similarity index 100% rename from idaes/core/util/structured_notebook/fsrunner.py rename to idaes/core/util/structfs/fsrunner.py diff --git a/idaes/core/util/structured_notebook/logutil.py b/idaes/core/util/structfs/logutil.py similarity index 100% rename from idaes/core/util/structured_notebook/logutil.py rename to idaes/core/util/structfs/logutil.py diff --git a/idaes/core/util/structured_notebook/runner.py b/idaes/core/util/structfs/runner.py similarity index 100% rename from idaes/core/util/structured_notebook/runner.py rename to idaes/core/util/structfs/runner.py diff --git a/idaes/core/util/structured_notebook/runner_actions.py b/idaes/core/util/structfs/runner_actions.py similarity index 100% rename from idaes/core/util/structured_notebook/runner_actions.py rename to idaes/core/util/structfs/runner_actions.py diff --git a/idaes/core/util/structured_notebook/tests/__init__.py b/idaes/core/util/structfs/tests/__init__.py similarity index 100% rename from idaes/core/util/structured_notebook/tests/__init__.py rename to idaes/core/util/structfs/tests/__init__.py diff --git a/idaes/core/util/structured_notebook/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py similarity index 100% rename from idaes/core/util/structured_notebook/tests/test_fsrunner.py rename to idaes/core/util/structfs/tests/test_fsrunner.py diff --git a/idaes/core/util/structured_notebook/tests/test_logutil.py b/idaes/core/util/structfs/tests/test_logutil.py similarity index 100% rename from idaes/core/util/structured_notebook/tests/test_logutil.py rename to idaes/core/util/structfs/tests/test_logutil.py diff --git a/idaes/core/util/structured_notebook/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py similarity index 100% rename from idaes/core/util/structured_notebook/tests/test_runner.py rename to idaes/core/util/structfs/tests/test_runner.py diff --git a/idaes/core/util/structured_notebook/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py similarity index 100% rename from idaes/core/util/structured_notebook/tests/test_runner_actions.py rename to idaes/core/util/structfs/tests/test_runner_actions.py From 9ee2b0406d78be6319cc023d5673e576abb17fad Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 08:23:06 -0800 Subject: [PATCH 04/73] fix imports, add tests --- idaes/core/util/structfs/fsrunner.py | 7 ++++++- idaes/core/util/structfs/runner.py | 15 +++++++++++++++ idaes/core/util/structfs/tests/test_fsrunner.py | 6 +++++- idaes/core/util/structfs/tests/test_runner.py | 8 +++++++- .../util/structfs/tests/test_runner_actions.py | 2 +- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index d999cb0654..26e9c951ca 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -10,12 +10,17 @@ # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. ############################################################################### +# stdlib from typing import Sequence + +# third-party from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock from pyomo.network.port import ScalarPort from idaes.core.util.model_statistics import degrees_of_freedom -from structured_notebook.runner import Runner + +# package +from .runner import Runner class Context(dict): diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 09c221411e..dfb1949fa2 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -71,9 +71,20 @@ def add_substep(self, base_name, name, func): step.add_substep(substep_name, func) def run_step(self, name): + """Syntactic sugar for calling `run_steps` for a single step.""" self.run_steps(from_name=name, to_name=name) def run_steps(self, from_name: str = "", to_name: str = ""): + """Run steps from `from_name` to step `to_name`. + + Args: + from_name: First step to run + to_name: Last step to run + + Raises: + KeyError: Unknown or undefined step given + ValueError: Steps out of order (`from` after `to`) + """ if not self._steps: return # nothing to do, no steps defined @@ -92,6 +103,10 @@ def run_steps(self, from_name: str = "", to_name: str = ""): raise KeyError(f"Empty step: {step_name}") step_range[i] = idx + if step_range[0] > step_range[1]: + raise ValueError( + "Steps out of order: {names[0]}={step_range[0]} > {names[1]}={step_range[1]}" + ) for action in self._actions.values(): action.before_run() diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 68fadb9367..afff82cddc 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -11,7 +11,7 @@ # for full copyright and license information. ############################################################################### import pytest -from structured_notebook.fsrunner import FlowsheetRunner +from ..fsrunner import FlowsheetRunner # -- setup -- @@ -55,16 +55,20 @@ def test_run_all(): @pytest.mark.unit def test_rerun(): + fsr.run_steps() first_model = fsr.model + # model not changed fsr.run_steps("solve_optimization") assert fsr.model == first_model + # reset forces new model fsr.reset() fsr.run_steps("solve_optimization") assert fsr.model != first_model second_model = fsr.model + # running from build also creates new model fsr.run_steps("build", "add_costing") assert fsr.model != second_model diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index df83351501..e9c7aa57b7 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -11,7 +11,7 @@ # for full copyright and license information. ############################################################################### import pytest -from structured_notebook.runner import Runner +from ..runner import Runner ## -- setup -- @@ -60,3 +60,9 @@ def do_nothing(context): rn.remove_action("nothing") with pytest.raises(KeyError): rn.get_action("nothing") + + +@pytest.mark.unit +def test_run_steps_order(): + with pytest.raises(ValueError): + simple.run_steps("world", "hello") diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index b8b5e4b573..0578c603b1 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -13,7 +13,7 @@ import pytest from pytest import approx -from structured_notebook import runner_actions, runner +from .. import runner_actions, runner import time From 1bdad43ff1a389a69fbf562cd7b0761160867214 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 08:27:27 -0800 Subject: [PATCH 05/73] ctor doc --- idaes/core/util/structfs/runner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index dfb1949fa2..c75a765276 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -40,6 +40,11 @@ class Runner: """Run a set of defined steps.""" def __init__(self, steps: Sequence[str]): + """Constructor. + + Args: + steps: List of step names + """ self._actions: dict[str, ActionType] = {} self._step_names = list(steps) self._steps: dict[str, Step] = {} @@ -107,6 +112,7 @@ def run_steps(self, from_name: str = "", to_name: str = ""): raise ValueError( "Steps out of order: {names[0]}={step_range[0]} > {names[1]}={step_range[1]}" ) + for action in self._actions.values(): action.before_run() From c8a7eb5d32ae23352242b575765a55b5363a4fe3 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 09:16:30 -0800 Subject: [PATCH 06/73] forward ref for older pythons --- idaes/core/util/structfs/runner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index c75a765276..61ba569514 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -16,7 +16,7 @@ # stdlib import logging -from typing import Callable, Optional, Tuple, Sequence +from typing import Callable, Tuple, Sequence, TypeVar __author__ = "Dan Gunter (LBNL)" @@ -33,7 +33,8 @@ def add_substep(self, name: str, func: Callable): self.substeps.append((name, func)) -type ActionType = Action +# Python 3.9-compatible forward reference +ActionType = TypeVar("ActionType", bound="Action") class Runner: From 0c4e2e1a0346d6c6114056480e5cb14c24b72e26 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 13 Nov 2025 13:48:48 -0800 Subject: [PATCH 07/73] changes for py39 --- idaes/core/util/structfs/fsrunner.py | 35 +------------- idaes/core/util/structfs/runner.py | 10 ++-- idaes/core/util/structfs/runner_actions.py | 56 +++++++++++++++++++++- 3 files changed, 62 insertions(+), 39 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 26e9c951ca..8833a9ba22 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -11,13 +11,11 @@ # for full copyright and license information. ############################################################################### # stdlib -from typing import Sequence +from typing import Optional # third-party from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock -from pyomo.network.port import ScalarPort -from idaes.core.util.model_statistics import degrees_of_freedom # package from .runner import Runner @@ -59,7 +57,7 @@ class FlowsheetRunner(Runner): def __init__(self, solver=None, tee=False): self.build_step = self.STEPS[0] self._solver, self._tee = solver, tee - super().__init__(self.STEPS) + super().__init__(self.STEPS) # needs to be last def run_steps(self, from_name: str = "", to_name: str = ""): from_step_name = self._norm_name(from_name) @@ -87,32 +85,3 @@ def model(self): @property def results(self): return self._context["results"] - - def component_degrees_of_freedom(self): - return self._component_dof - - def check_dof(self, block, fix_inlets: bool = True, expected: int | None = 0): - name = block.name - if fix_inlets: - inlets = [ - c - for c in block.component_objects(descend_into=False) - if isinstance(c, ScalarPort) - and (c.name.endswith("inlet") or c.name.endswith("recycle")) - ] - free_me = [] - for inlet in inlets: - if not inlet.is_fixed(): - inlet.fix() - free_me.append(inlet) - dof = degrees_of_freedom(block) - self._component_dof[name] = dof - if fix_inlets: - for inlet in free_me: - inlet.free() - if expected is not None: - assert dof == expected, ( - f"Degrees of freedom for component " - f"{name} ({dof}) does not match " - f"expected ({expected})" - ) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 61ba569514..74ab78241b 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -16,7 +16,7 @@ # stdlib import logging -from typing import Callable, Tuple, Sequence, TypeVar +from typing import Callable, Optional, Tuple, Sequence, TypeVar __author__ = "Dan Gunter (LBNL)" @@ -26,7 +26,7 @@ class Step: def __init__(self, name: str, func: Callable): self.name: str = name - self.func: Callable | None = func + self.func: Callable = func self.substeps: list[Tuple[str, Callable]] = [] def add_substep(self, name: str, func: Callable): @@ -46,12 +46,14 @@ def __init__(self, steps: Sequence[str]): Args: steps: List of step names """ + self._context = {} self._actions: dict[str, ActionType] = {} self._step_names = list(steps) self._steps: dict[str, Step] = {} self.reset() def __getitem__(self, key): + """Look for key in `context`""" return self._context[key] def add_step(self, name: str, func: Callable): @@ -155,7 +157,7 @@ def _last_step(self): return -1 @staticmethod - def _norm_name(s: str | None) -> str: + def _norm_name(s: Optional[str]) -> str: return "" if s is None else s.lower() def _step_begin(self, name: str): @@ -240,7 +242,7 @@ def after_step(self, name, runner): ``` """ - def __init__(self, runner: Runner, log: logging.Logger | None = None): + def __init__(self, runner: Runner, log: Optional[logging.Logger] = None): self._runner = runner if log is None: log = _log diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 6ee77800b2..52203b7a09 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -17,24 +17,29 @@ from collections import defaultdict import time +from pyomo.network.port import ScalarPort +from idaes.core.util.model_statistics import degrees_of_freedom + from .runner import Action +from .fsrunner import FlowsheetRunner class Timer(Action): """Simple step/run timer action.""" - def __init__(self, runner): + def __init__(self, runner, **kwargs): """Constructor. Args: runner: Associated Runner object + kwargs: Additional optional arguments for Action constructor Attributes: step_times: Dict with key step name and value a list of timings for that step run_times: List of timings for a run (sequence of steps) """ - super().__init__(runner) + super().__init__(runner, **kwargs) self.step_times = defaultdict(list) self.run_times = [] self._step_begin = {} @@ -82,3 +87,50 @@ def summary(self): } ) return data + + +class DOFChecker(Action): + def __init__(self, runner: FlowsheetRunner, **kwargs): + """Constructor. + + Args: + runner: Associated Runner object + kwargs: Additional optional arguments for Action constructor + """ + super().__init__(runner, **kwargs) + + def after_step(self, step_name: str): + m = self._runner.model + if step_name == "special step": + unit_dof = {} + for unit in m.component_objects(descend_into=False): + # XXX: Check whether really IDAES unit model + unit_dof[unit.name] = self._get_dof(unit) + # XXX: check that DOF is what was wanted? + + +def _get_dof(self, block, fix_inlets: bool = True): + name = block.name + if fix_inlets: + inlets = [ + c + for c in block.component_objects(descend_into=False) + if isinstance(c, ScalarPort) + and (c.name.endswith("inlet") or c.name.endswith("recycle")) + ] + free_me = [] + for inlet in inlets: + if not inlet.is_fixed(): + inlet.fix() + free_me.append(inlet) + dof = degrees_of_freedom(block) + self._component_dof[name] = dof + if fix_inlets: + for inlet in free_me: + inlet.free() + # if expected is not None: + # assert dof == expected, ( + # f"Degrees of freedom for component " + # f"{name} ({dof}) does not match " + # f"expected ({expected})" + # ) From 40e64c49f90cef8fc165ab91afdd165c5ed4e31c Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 14 Nov 2025 11:53:33 -0800 Subject: [PATCH 08/73] tested dof action --- idaes/core/util/structfs/runner_actions.py | 151 +++++++++++++----- .../util/structfs/tests/flash_flowsheet.py | 73 +++++++++ .../structfs/tests/test_runner_actions.py | 40 ++++- 3 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 idaes/core/util/structfs/tests/flash_flowsheet.py diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 52203b7a09..0ed2a3f0c9 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -13,13 +13,19 @@ """ Predefined Actions for the generic Runner. """ - +# stdlib from collections import defaultdict +from collections.abc import Callable import time +from typing import Union, Optional -from pyomo.network.port import ScalarPort +# third-party from idaes.core.util.model_statistics import degrees_of_freedom +from idaes.core.base.unit_model import ProcessBlockData +import pandas as pd +from pyomo.network.port import ScalarPort +# package from .runner import Action from .fsrunner import FlowsheetRunner @@ -89,48 +95,117 @@ def summary(self): return data -class DOFChecker(Action): - def __init__(self, runner: FlowsheetRunner, **kwargs): +# Hold degrees of freedom for one FlowsheetRunner 'step' +# {key=component: value=dof} +UnitDofType = dict[str, int] + + +class UnitDofChecker(Action): + """Check degrees of freedom on unit models.""" + + def __init__( + self, + runner: FlowsheetRunner, + flowsheet: str, + steps: Union[str, list[str]], + step_func: Optional[Callable[[str, UnitDofType], None]], + run_func: Optional[Callable[[dict[str, UnitDofType], int], None]], + **kwargs, + ): """Constructor. Args: - runner: Associated Runner object + runner: Associated Runner object (provided by `add_action`) + flowsheet: Variable name for flowsheet, e.g. "fs" + steps: Step or steps at which to run the checking action + step_func: Function to call with calculated DoF values for one step. + Takes name of step and dictionary with per-unit degrees of freedom + (see `UnitDofType` alias). + run_func: Function to call with calculated DoF values for each step, as well + as overall model DoF. kwargs: Additional optional arguments for Action constructor + + Raises: + ValueError: if `steps` list is empty, or no callback functions provided """ super().__init__(runner, **kwargs) + if hasattr(steps, "lower"): # string-like + self._steps = {steps} + else: # assume it is list-like + if len(steps) == 0: + raise ValueError("At least one step name must be provided") + self._steps = set(steps) + self._steps_dof: dict[str, UnitDofType] = {} + self._step_func, self._run_func = step_func, run_func + self._fs = flowsheet def after_step(self, step_name: str): + step_name = self._runner._norm_name(step_name) + if step_name not in self._steps: + return + + fs = self._get_flowsheet() + + units_dof = {} + for unit in fs.component_objects(descend_into=True): + if self._is_unit_model(unit): + units_dof[unit.name] = self._get_dof(unit) + self._steps_dof[step_name] = units_dof # save + if self._step_func: + self._step_func(step_name, units_dof) + + def after_run(self): + if self._run_func: + fs = self._get_flowsheet() + model_dof = degrees_of_freedom(fs) + self._run_func(self._steps_dof, model_dof) + + def _get_flowsheet(self): m = self._runner.model - if step_name == "special step": - unit_dof = {} - for unit in m.component_objects(descend_into=False): - # XXX: Check whether really IDAES unit model - unit_dof[unit.name] = self._get_dof(unit) - # XXX: check that DOF is what was wanted? - - -def _get_dof(self, block, fix_inlets: bool = True): - name = block.name - if fix_inlets: - inlets = [ - c - for c in block.component_objects(descend_into=False) - if isinstance(c, ScalarPort) - and (c.name.endswith("inlet") or c.name.endswith("recycle")) - ] - free_me = [] - for inlet in inlets: - if not inlet.is_fixed(): - inlet.fix() - free_me.append(inlet) - dof = degrees_of_freedom(block) - self._component_dof[name] = dof - if fix_inlets: - for inlet in free_me: - inlet.free() - # if expected is not None: - # assert dof == expected, ( - # f"Degrees of freedom for component " - # f"{name} ({dof}) does not match " - # f"expected ({expected})" - # ) + if self._fs: + return getattr(m, self._fs) + return m + + @staticmethod + def _is_unit_model(block): + return isinstance(block, ProcessBlockData) + + def as_dataframe(self) -> pd.DataFrame: + """Format per-step DoF as a Pandas `DataFrame`. + + Returns: + DataFrame: Step (str), Unit (str), DoF (int) + """ + step_names, unit_names, dofs = [], [], [] + for sn, data in self._steps_dof.items(): + for un, dof in data.items(): + step_names.append(sn) + unit_names.append(un) + dofs.append(dof) + return pd.DataFrame( + {"after_step": step_names, "unit_name": unit_names, "dof": dofs} + ) + + @staticmethod + def _get_dof(block, fix_inlets: bool = True): + name = block.name + if fix_inlets: + inlets = [ + c + for c in block.component_objects(descend_into=False) + if isinstance(c, ScalarPort) + and (c.name.endswith("inlet") or c.name.endswith("recycle")) + ] + free_me = [] + for inlet in inlets: + if not inlet.is_fixed(): + inlet.fix() + free_me.append(inlet) + + dof = degrees_of_freedom(block) + + if fix_inlets: + for inlet in free_me: + inlet.free() + + return dof diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py new file mode 100644 index 0000000000..3e7e663002 --- /dev/null +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -0,0 +1,73 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2025 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +# +############################################################################### + + +from pyomo.environ import ConcreteModel, SolverFactory, Constraint, value +from idaes.core import FlowsheetBlock + +# Import idaes logger to set output levels +import idaes.logger as idaeslog +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, +) +from idaes.models.unit_models import Flash +from ..fsrunner import FlowsheetRunner + +FS = FlowsheetRunner() + +# # Flash Unit Model +# +# Author: Jaffer Ghouse +# Maintainer: Andrew Lee +# Updated: 2023-06-01 + + +@FS.step("build") +def build_model(ctx): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + # assert degrees_of_freedom(m) == 7 + ctx.model = m + + +@FS.step("set_operating_conditions") +def set_operating_conditions(ctx): + m = ctx.model + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + + +@FS.step("initialize") +def init_model(ctx): + m = ctx.model + m.fs.flash.initialize(outlvl=idaeslog.INFO) + + +@FS.step("set_solver") +def set_solver(ctx): + ctx.solver = SolverFactory("ipopt") + + +@FS.step("solve_initial") +def solve(ctx): + status = ctx.solver.solve(ctx.model, tee=ctx["tee"]) diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 0578c603b1..203bbf7b5b 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -10,16 +10,19 @@ # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. ############################################################################### +import pprint +import time + import pytest from pytest import approx - -from .. import runner_actions, runner -import time +from .. import runner +from ..runner_actions import Timer, UnitDofChecker +from . import flash_flowsheet @pytest.mark.unit def test_class_timer(): - timer = runner_actions.Timer(runner.Runner([])) + timer = Timer(runner.Runner([])) n, m = 2, 3 for i in range(n): timer.before_run() @@ -61,7 +64,7 @@ def sleepy(context): rn.add_step("step2", sleepy) rn.add_step("step3", sleepy) - rn.add_action("timer", runner_actions.Timer) + rn.add_action("timer", Timer) rn.run_steps() @@ -74,3 +77,30 @@ def sleepy(context): for i, (name, t) in enumerate(r["steps"]): assert name == f"step{i + 1}" assert t == approx(0.1, abs=eps) + + +@pytest.mark.unit +def test_unit_dof_action(): + rn = flash_flowsheet.FS + + def check_step(name, data): + # print(f"@@ check_step {name} data: {data}") + assert "fs.flash" in data + if name == "solve_initial": + assert data["fs.flash"] == 0 + + def check_run(step_dof, model_dof): + assert model_dof == 0 + + rn.add_action( + "check_dof", + UnitDofChecker, + "fs", + ["build", "solve_initial"], + check_step, + check_run, + ) + + rn.run_steps("build", "solve_initial") + + pprint.pprint(rn.get_action("check_dof").as_dataframe()) From cb9480e8a67b84ccb67475218fcca712cbdad407 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 15 Nov 2025 07:49:22 -0800 Subject: [PATCH 09/73] testing dof --- idaes/core/util/structfs/runner.py | 5 +- idaes/core/util/structfs/runner_actions.py | 64 +++++++++++++++++-- .../util/structfs/tests/flash_flowsheet.py | 1 + idaes/core/util/structfs/tests/test_runner.py | 59 +++++++++++++++++ .../structfs/tests/test_runner_actions.py | 30 ++++++++- 5 files changed, 149 insertions(+), 10 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 74ab78241b..a06e994629 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -140,14 +140,11 @@ def get_action(self, name: str) -> ActionType: def remove_action(self, name: str): del self._actions[name] - def _step_index(self, name: str): - return self._step_names.index(name) - def _first_step(self): for i, name in enumerate(self._step_names): if name in self._steps: return i - return -1 + assert False, "No first step defined" # should not get here def _last_step(self): for i in range(len(self._step_names) - 1, -1, -1): diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 0ed2a3f0c9..525dc1443e 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -101,15 +101,25 @@ def summary(self): class UnitDofChecker(Action): - """Check degrees of freedom on unit models.""" + """Check degrees of freedom on unit models. + + After a (caller-named) step or steps, check the degrees + of freedom on each unit model by the method of + fixing the inlet, applying the `degrees_of_freedom()` function, + and unfixing the inlet again. The calculated values are + saved and passed to an optional caller-provided function. + + At the end of a run, the degrees of freedom for the entire + model are checked, saved, and passed to an optional function. + """ def __init__( self, runner: FlowsheetRunner, flowsheet: str, steps: Union[str, list[str]], - step_func: Optional[Callable[[str, UnitDofType], None]], - run_func: Optional[Callable[[dict[str, UnitDofType], int], None]], + step_func: Optional[Callable[[str, UnitDofType], None]] = None, + run_func: Optional[Callable[[dict[str, UnitDofType], int], None]] = None, **kwargs, ): """Constructor. @@ -136,6 +146,7 @@ def __init__( raise ValueError("At least one step name must be provided") self._steps = set(steps) self._steps_dof: dict[str, UnitDofType] = {} + self._model_dof = None self._step_func, self._run_func = step_func, run_func self._fs = flowsheet @@ -155,9 +166,10 @@ def after_step(self, step_name: str): self._step_func(step_name, units_dof) def after_run(self): + fs = self._get_flowsheet() + model_dof = degrees_of_freedom(fs) + self._model_dof = model_dof if self._run_func: - fs = self._get_flowsheet() - model_dof = degrees_of_freedom(fs) self._run_func(self._steps_dof, model_dof) def _get_flowsheet(self): @@ -177,15 +189,57 @@ def as_dataframe(self) -> pd.DataFrame: DataFrame: Step (str), Unit (str), DoF (int) """ step_names, unit_names, dofs = [], [], [] + + # add DoF for each step for sn, data in self._steps_dof.items(): for un, dof in data.items(): step_names.append(sn) unit_names.append(un) dofs.append(dof) + + # add model DoF + step_names.append("RUN") + unit_names.append(self._fs) + dofs.append(self._model_dof) + return pd.DataFrame( {"after_step": step_names, "unit_name": unit_names, "dof": dofs} ) + def get_unit_dof(self, step_name: str) -> UnitDofType: + """Get DoF for each unit, as measured after the given step. + + Args: + step_name: Step for which to get the per-unit degrees of freedom. + + Returns: + UnitDofType + + Raises: + KeyError: If `step_name` is unknown, or has no data + ValueError: There is no degrees_of_freedom data at all + """ + if not self._steps_dof: + raise ValueError("No degrees of freedom have been calculated") + if step_name not in self._steps: + raise KeyError( + f"Unknown step. name={step_name} " f"known={','.join(self._steps)}" + ) + return self._steps_dof[step_name] + + def steps(self, only_with_data: bool = False) -> list[str]: + """Get list of steps for which unit degrees of freedom are calculated. + + Args: + only_with_data: If True, do not return steps with no data + + Returns: + list of step names + """ + if only_with_data: + return [s for s in self._steps if s in self._steps_dof] + return list(self._steps) + @staticmethod def _get_dof(block, fix_inlets: bool = True): name = block.name diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index 3e7e663002..b2da3eb2e9 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -24,6 +24,7 @@ from idaes.models.unit_models import Flash from ..fsrunner import FlowsheetRunner + FS = FlowsheetRunner() # # Flash Unit Model diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index e9c7aa57b7..895b0605c5 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -36,6 +36,8 @@ def say_to_world(context): context["greeting"] = msg +empty = Runner(("hi", "bye")) + # -- end setup -- @@ -66,3 +68,60 @@ def do_nothing(context): def test_run_steps_order(): with pytest.raises(ValueError): simple.run_steps("world", "hello") + + +@pytest.mark.unit +def test_run_steps_args(): + simple.run_steps(from_name="hello") + simple.run_steps(to_name="world") + + +@pytest.mark.unit +def test_run_1step(): + simple.run_step("hello") + + +@pytest.mark.unit +def test_run_empty_steps(): + empty.run_steps() + + +@pytest.mark.unit +def test_run_bad_steps(): + with pytest.raises(KeyError): + simple.run_steps("howdy", "pardner") + + with pytest.raises(KeyError): + simple.run_step("notrun-1") + + +@pytest.mark.unit +def test_runner_context(): + simple.run_steps() + assert simple["greeting"] + + +@pytest.mark.unit +def test_add_bad_step(): + with pytest.raises(KeyError): + + @simple.step("bad") + def do_bad(ctx): + return + + with pytest.raises(KeyError): + + @simple.substep("bad", "sub") + def do_bad2(ctx): + return + + # undefined step cannot have a substep + + with pytest.raises(ValueError): + + @simple.substep("notrun-1", "sub") + def do_bad3(ctx): + return + + +# 64, 76, 143, 146, 179, 223, 225 diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 203bbf7b5b..7b22f2e1f7 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -80,8 +80,9 @@ def sleepy(context): @pytest.mark.unit -def test_unit_dof_action(): +def test_unit_dof_action_base(): rn = flash_flowsheet.FS + rn.reset() def check_step(name, data): # print(f"@@ check_step {name} data: {data}") @@ -104,3 +105,30 @@ def check_run(step_dof, model_dof): rn.run_steps("build", "solve_initial") pprint.pprint(rn.get_action("check_dof").as_dataframe()) + + +@pytest.mark.unit +def test_unit_dof_action_getters(): + rn = flash_flowsheet.FS + rn.reset() + + aname = "check_dof" + rn.add_action( + aname, + UnitDofChecker, + "fs", + ["build", "solve_initial"], + ) + rn.run_steps() + + act = rn.get_action(aname) + + steps = act.steps() + dofs = [] + for s in steps: + step_dof = act.get_unit_dof(s) + assert step_dof + dofs.append(step_dof) + assert dofs[0] != dofs[1] + + assert act.steps() == act.steps(only_with_data=True) From f20752c0a596da3dc97a6688376c1ddc39f1eb47 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 08:00:43 -0800 Subject: [PATCH 10/73] looser timings and print them --- idaes/core/util/structfs/tests/test_runner_actions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 7b22f2e1f7..d8b8c1fcdb 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -44,8 +44,9 @@ def test_class_timer(): # {'run': 0.8004975318908691, 'steps': [('step0', 0.10008621215820312), # ('step1', 0.20008587837219238), # ('step2', 0.3000912666320801)], 'inclusive': 0.6002633571624756, 'exclusive': 0.20023417472839355}] - eps = 5e-3 + eps = 5e-2 for r in s: + print(f"Timings: {r}") assert r["run"] == approx(0.8, abs=eps) assert r["inclusive"] + r["exclusive"] == approx(r["run"]) for i, (name, t) in enumerate(r["steps"]): @@ -70,8 +71,9 @@ def sleepy(context): s = rn.get_action("timer").summary() - eps = 0.01 + eps = 5e-2 for r in s: + print(f"Timings: {r}") assert r["run"] == approx(0.3, abs=eps) assert r["inclusive"] + r["exclusive"] == approx(r["run"]) for i, (name, t) in enumerate(r["steps"]): From f902adbc52d02112b962ab070cf6c860a0596d27 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 08:17:26 -0800 Subject: [PATCH 11/73] less lint --- idaes/core/util/structfs/fsrunner.py | 2 +- idaes/core/util/structfs/runner_actions.py | 4 ++-- idaes/core/util/structfs/tests/flash_flowsheet.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 8833a9ba22..89921bf842 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -11,7 +11,7 @@ # for full copyright and license information. ############################################################################### # stdlib -from typing import Optional +# none # third-party from pyomo.environ import ConcreteModel diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 525dc1443e..fcb490876d 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -73,7 +73,7 @@ def before_run(self): def after_run(self): t1 = time.time() if self._run_begin is None: - self.log.warning(f"Timer: run end without begin") + self.log.warning("Timer: run end without begin") else: dt = t1 - self._run_begin self.run_times.append(dt) @@ -223,7 +223,7 @@ def get_unit_dof(self, step_name: str) -> UnitDofType: raise ValueError("No degrees of freedom have been calculated") if step_name not in self._steps: raise KeyError( - f"Unknown step. name={step_name} " f"known={','.join(self._steps)}" + f"Unknown step. name={step_name} known={','.join(self._steps)}" ) return self._steps_dof[step_name] diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index b2da3eb2e9..dd8cd1641d 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -13,7 +13,7 @@ ############################################################################### -from pyomo.environ import ConcreteModel, SolverFactory, Constraint, value +from pyomo.environ import ConcreteModel, SolverFactory from idaes.core import FlowsheetBlock # Import idaes logger to set output levels @@ -71,4 +71,4 @@ def set_solver(ctx): @FS.step("solve_initial") def solve(ctx): - status = ctx.solver.solve(ctx.model, tee=ctx["tee"]) + ctx["status"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) From 10e6b5ecf510b111a0c9e27a32772d5feae3ec3b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 12:23:22 -0800 Subject: [PATCH 12/73] larger tolerance --- idaes/core/util/structfs/tests/test_runner_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index d8b8c1fcdb..4ed5588134 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -44,7 +44,7 @@ def test_class_timer(): # {'run': 0.8004975318908691, 'steps': [('step0', 0.10008621215820312), # ('step1', 0.20008587837219238), # ('step2', 0.3000912666320801)], 'inclusive': 0.6002633571624756, 'exclusive': 0.20023417472839355}] - eps = 5e-2 + eps = 0.1 # big variance needed for Windows for r in s: print(f"Timings: {r}") assert r["run"] == approx(0.8, abs=eps) @@ -71,7 +71,7 @@ def sleepy(context): s = rn.get_action("timer").summary() - eps = 5e-2 + eps = 0.1 # big variance needed for Windows for r in s: print(f"Timings: {r}") assert r["run"] == approx(0.3, abs=eps) From 763c17173ba0aa81f41a0d14f55d885613df89fa Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 12:23:30 -0800 Subject: [PATCH 13/73] improved comments --- idaes/core/util/structfs/runner.py | 38 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index a06e994629..0811575183 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -219,40 +219,40 @@ def wrapper(*args, **kwargs): class Action: - """Do something before and/or after each step / run. - - ``` - class GreetingAction(Action): - def before_step(self, name, runner): - print(f"Hello, step {name}") - def after_step(self, name, runner): - print(f"Goodbye, step {name}") - - class DivaAction(Action): - def after_step(self, name, runner): - if name == "act": - print(f"I am now an ac-TORRRR!") - - myrunner = Runner(steps=["plan", "act", "inspect", "revise"]) - myrunner.add_action(GreetingAction) - myrunner.add_action(DivaAction) - ``` - """ + """Do something before and/or after each step and/or run performed by a `Runner`.""" def __init__(self, runner: Runner, log: Optional[logging.Logger] = None): + """Constructor + + Args: + runner: Reference to the runner that will trigger this action. + log: Logger to use when logging informational or error messages + """ self._runner = runner if log is None: log = _log self.log = log def before_step(self, step_name: str): + """Perform this action before the named step. + + Args: + step_name: Name of the step + """ return def after_step(self, step_name: str): + """Perform this action after the named step. + + Args: + step_name: Name of the step + """ return def before_run(self): + """Perform this action before a run starts.""" return def after_run(self): + """Perform this action after a run ends.""" return From b11f686332d785d4e14a21ce1f245737e28b550f Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 13:15:27 -0800 Subject: [PATCH 14/73] cleanup docs --- idaes/core/util/structfs/fsrunner.py | 30 ++++++++++++++- idaes/core/util/structfs/logutil.py | 2 +- idaes/core/util/structfs/runner.py | 43 ++++++++++++++++++++++ idaes/core/util/structfs/runner_actions.py | 16 ++++++-- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 89921bf842..86302bdfa5 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -10,6 +10,10 @@ # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. ############################################################################### +""" +Specialize the generic `Runner` class to running a flowsheet, +in `FlowsheetRunner`. +""" # stdlib # none @@ -22,24 +26,40 @@ class Context(dict): + """Syntactic sugar for the dictionary for the 'context' passed into each + step of the `FlowsheetRunner` class. + """ + @property def model(self): + """The model being run.""" return self["model"] @model.setter def model(self, value): + """The model being run.""" self["model"] = value @property def solver(self): + """The solver used to solve the model.""" return self["solver"] @solver.setter def solver(self, value): + """The solver used to solve the model.""" self["solver"] = value class FlowsheetRunner(Runner): + """Specialize the base `Runner` to handle IDAES flowsheets. + + This class pre-determine the name and order of steps to run + + Attributes: + STEPS: List of defined step names. + """ + STEPS = ( "build", "set_operating_conditions", @@ -60,6 +80,13 @@ def __init__(self, solver=None, tee=False): super().__init__(self.STEPS) # needs to be last def run_steps(self, from_name: str = "", to_name: str = ""): + """Run the steps. + + Before it calls the superclass to run the steps, checks + if the step name matches the `build_step` attribute and, + if so, creates an empty Pyomo ConcreteModel to use as + the base model for the flowsheet. + """ from_step_name = self._norm_name(from_name) if ( from_step_name == "" @@ -71,7 +98,6 @@ def run_steps(self, from_name: str = "", to_name: str = ""): def reset(self): self._context = Context(solver=self._solver, tee=self._tee, model=None) - self._component_dof = {} def _create_model(self): m = ConcreteModel() @@ -80,8 +106,10 @@ def _create_model(self): @property def model(self): + """Syntactic sugar to return the model.""" return self._context.model @property def results(self): + """Syntactic sugar to return the `results` in the context.""" return self._context["results"] diff --git a/idaes/core/util/structfs/logutil.py b/idaes/core/util/structfs/logutil.py index 9565506139..037f759fc3 100644 --- a/idaes/core/util/structfs/logutil.py +++ b/idaes/core/util/structfs/logutil.py @@ -34,7 +34,7 @@ def quiet(roots=("idaes", "pyomo"), level=logging.CRITICAL): for root in roots: if lg.name.startswith(root + "."): g_quiet[lg.name] = lg.level - lg.setLevel(logging.CRITICAL) + lg.setLevel(level) def unquiet(): diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 0811575183..b9dcdf044d 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -24,12 +24,27 @@ class Step: + """Step to run by the `Runner`.""" + def __init__(self, name: str, func: Callable): + """Constructor + + Args: + name: Name of the step + func: Function to call to execute the step + """ self.name: str = name self.func: Callable = func self.substeps: list[Tuple[str, Callable]] = [] def add_substep(self, name: str, func: Callable): + """Add a sub-step to this step. + Substeps are run in the order given. + + Args: + name: Name of substep + func: Function to call to execute this substep + """ self.substeps.append((name, func)) @@ -128,16 +143,44 @@ def run_steps(self, from_name: str = "", to_name: str = ""): action.after_run() def reset(self): + """Reset runner internal state, especially the context.""" self._context = {} def add_action(self, name: str, action_class: type, *args, **kwargs): + """Add a named action. + + Args: + name: Arbitrary name for the action, used to get/remove it + action_class: Subclass of Action to use + args: Positional arguments passed to `action_class` constructor + kwargs: Keyword arguments passed to `action_class` constructor + """ obj = action_class(self, *args, **kwargs) self._actions[name] = obj def get_action(self, name: str) -> ActionType: + """Get an action object. + + Args: + name: Name of action (as provided to `add_action`) + + Returns: + ActionType: Action object + + Raises: + KeyError: If action name does not match any known action + """ return self._actions[name] def remove_action(self, name: str): + """Remove an action object. + + Args: + name: Name of action (as provided to `add_action`) + + Raises: + KeyError: If action name does not match any known action + """ del self._actions[name] def _first_step(self): diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index fcb490876d..5deb4be9ac 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -20,12 +20,12 @@ from typing import Union, Optional # third-party -from idaes.core.util.model_statistics import degrees_of_freedom -from idaes.core.base.unit_model import ProcessBlockData import pandas as pd from pyomo.network.port import ScalarPort # package +from idaes.core.util.model_statistics import degrees_of_freedom +from idaes.core.base.unit_model import ProcessBlockData from .runner import Action from .fsrunner import FlowsheetRunner @@ -79,7 +79,16 @@ def after_run(self): self.run_times.append(dt) self._run_begin = None - def summary(self): + def summary(self) -> list[dict]: + """Summarize timings + + Returns: + dict: Summary of timings (in seconds) for each run in `run_times`: + - 'run': Time for the run + - 'steps': list of (step_name, time) for each step (in order) + - 'inclusive': total time spent in the steps + - 'exclusive': difference between run time and inclusive time + """ data = [] for i, run_time in enumerate(self.run_times): step_times = [(k, self.step_times[k][i]) for k in self._step_order] @@ -242,7 +251,6 @@ def steps(self, only_with_data: bool = False) -> list[str]: @staticmethod def _get_dof(block, fix_inlets: bool = True): - name = block.name if fix_inlets: inlets = [ c From 244d741877fe7d37f8a3b9750071170539b448cb Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 17 Nov 2025 13:25:04 -0800 Subject: [PATCH 15/73] more docstrings --- idaes/core/util/structfs/fsrunner.py | 2 +- idaes/core/util/structfs/runner.py | 44 ++++++++++++++++--- idaes/core/util/structfs/runner_actions.py | 2 +- .../util/structfs/tests/flash_flowsheet.py | 9 +++- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 86302bdfa5..5cd382fb23 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -87,7 +87,7 @@ def run_steps(self, from_name: str = "", to_name: str = ""): if so, creates an empty Pyomo ConcreteModel to use as the base model for the flowsheet. """ - from_step_name = self._norm_name(from_name) + from_step_name = self.normalize_name(from_name) if ( from_step_name == "" or from_step_name == self.build_step diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index b9dcdf044d..6588572105 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -72,15 +72,42 @@ def __getitem__(self, key): return self._context[key] def add_step(self, name: str, func: Callable): - step_name = self._norm_name(name) + """Add a step. + + Steps are executed by calling `func(context)`, + where `context` is a dict (or dict-like) object + that is used to pass state between steps. + + Args: + name: Add a step to be executed + func: Function to execute for the step. + + Raises: + KeyError: _description_ + """ + step_name = self.normalize_name(name) if step_name not in self._step_names: raise KeyError(f"Unknown step: {step_name}") self._steps[step_name] = Step(step_name, func) def add_substep(self, base_name, name, func): - substep_name = self._norm_name(name) - base_step_name = self._norm_name(base_name) + """Add a substep for a given step. + + Substeps are all executed, in the order added, + immediately after their base step is executed. + + Args: + base_name: Step name + name: Substep name_ + func: Function to execute + + Raises: + KeyError: Base step or substep is not found + ValueError: Base step does not have any substeps + """ + substep_name = self.normalize_name(name) + base_step_name = self.normalize_name(base_name) if base_step_name not in self._step_names: raise KeyError( f"Unknown base step {base_step_name} for substep {substep_name}" @@ -111,7 +138,7 @@ def run_steps(self, from_name: str = "", to_name: str = ""): if not self._steps: return # nothing to do, no steps defined - names = (self._norm_name(from_name), self._norm_name(to_name)) + names = (self.normalize_name(from_name), self.normalize_name(to_name)) step_range = [-1, -1] for i, step_name in enumerate(names): @@ -197,7 +224,14 @@ def _last_step(self): return -1 @staticmethod - def _norm_name(s: Optional[str]) -> str: + def normalize_name(s: Optional[str]) -> str: + """Normalize a step name. + Args: + s: Step name + + Returns: + normalized name + """ return "" if s is None else s.lower() def _step_begin(self, name: str): diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 5deb4be9ac..37cf5b025b 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -160,7 +160,7 @@ def __init__( self._fs = flowsheet def after_step(self, step_name: str): - step_name = self._runner._norm_name(step_name) + step_name = self._runner.normalize_name(step_name) if step_name not in self._steps: return diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index dd8cd1641d..61a470b408 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -11,7 +11,9 @@ # for full copyright and license information. # ############################################################################### - +""" +Simple Flash flowsheet for use in testing. +""" from pyomo.environ import ConcreteModel, SolverFactory from idaes.core import FlowsheetBlock @@ -36,6 +38,7 @@ @FS.step("build") def build_model(ctx): + """Build the model.""" m = ConcreteModel() m.fs = FlowsheetBlock(dynamic=False) m.fs.properties = BTXParameterBlock( @@ -48,6 +51,7 @@ def build_model(ctx): @FS.step("set_operating_conditions") def set_operating_conditions(ctx): + """Set operating conditions.""" m = ctx.model m.fs.flash.inlet.flow_mol.fix(1) m.fs.flash.inlet.temperature.fix(368) @@ -60,15 +64,18 @@ def set_operating_conditions(ctx): @FS.step("initialize") def init_model(ctx): + """ "Initialize the model.""" m = ctx.model m.fs.flash.initialize(outlvl=idaeslog.INFO) @FS.step("set_solver") def set_solver(ctx): + """Set the solver.""" ctx.solver = SolverFactory("ipopt") @FS.step("solve_initial") def solve(ctx): + """Perform the initial model solve.""" ctx["status"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) From 5aa7b0e750c03325c235573555b263d4ff6cd8d6 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 20 Nov 2025 13:12:52 -0800 Subject: [PATCH 16/73] minor tweak --- idaes/core/util/structfs/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 6588572105..6edcc47dba 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -42,7 +42,7 @@ def add_substep(self, name: str, func: Callable): Substeps are run in the order given. Args: - name: Name of substep + name: The name of substep func: Function to call to execute this substep """ self.substeps.append((name, func)) @@ -99,7 +99,7 @@ def add_substep(self, base_name, name, func): Args: base_name: Step name - name: Substep name_ + name: Substep name func: Function to execute Raises: From af371576e05dca7d8fb49bcaa1175fe611fdef62 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 21 Nov 2025 07:14:26 -0800 Subject: [PATCH 17/73] docs and stuff --- docs/conf.py | 4 +- docs/examples/index.rst | 7 + docs/examples/structfs/flash_flowsheet.py | 86 ++++ .../structfs/flash_flowsheet_nb.ipynb | 370 ++++++++++++++++++ docs/index.rst | 1 + idaes/core/util/structfs/fsrunner.py | 6 +- idaes/core/util/structfs/runner.py | 20 +- idaes/core/util/structfs/runner_actions.py | 89 +++-- idaes/core/util/structfs/tests/test_runner.py | 4 +- requirements-dev.txt | 1 + 10 files changed, 550 insertions(+), 38 deletions(-) create mode 100644 docs/examples/index.rst create mode 100644 docs/examples/structfs/flash_flowsheet.py create mode 100644 docs/examples/structfs/flash_flowsheet_nb.ipynb diff --git a/docs/conf.py b/docs/conf.py index e4623e69f6..6c79535230 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -35,6 +35,7 @@ "sphinxarg.ext", "sphinx.ext.doctest", "sphinx_copybutton", + "nbsphinx", ] # Put type hints in the description, not signature @@ -50,8 +51,7 @@ # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - +source_suffix = [".rst"] # The encoding of source files. # # source_encoding = 'utf-8-sig' diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 0000000000..2dda3b6f29 --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,7 @@ +Examples +======== + +.. toctree:: + :maxdepth: 1 + + structfs/flash_flowsheet_nb \ No newline at end of file diff --git a/docs/examples/structfs/flash_flowsheet.py b/docs/examples/structfs/flash_flowsheet.py new file mode 100644 index 0000000000..0246bcb3f4 --- /dev/null +++ b/docs/examples/structfs/flash_flowsheet.py @@ -0,0 +1,86 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2025 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +# +############################################################################### +""" +Simple Flash flowsheet for use in testing. +""" + +from pyomo.environ import ConcreteModel, SolverFactory +from idaes.core import FlowsheetBlock + +# Import idaes logger to set output levels +import idaes.logger as idaeslog +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, +) +from idaes.models.unit_models import Flash +from idaes.core.util.structfs.fsrunner import FlowsheetRunner + + +FS = FlowsheetRunner() + +# # Flash Unit Model +# +# Author: Jaffer Ghouse +# Maintainer: Andrew Lee +# Updated: 2023-06-01 + + +@FS.step("build") +def build_model(ctx): + """Build the model.""" + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + # assert degrees_of_freedom(m) == 7 + ctx.model = m + + +@FS.step("set_operating_conditions") +def set_operating_conditions(ctx): + """Set operating conditions.""" + m = ctx.model + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + + +@FS.step("initialize") +def init_model(ctx): + """ "Initialize the model.""" + m = ctx.model + m.fs.flash.initialize(outlvl=idaeslog.INFO) + + +@FS.step("set_solver") +def set_solver(ctx): + """Set the solver.""" + ctx.solver = SolverFactory("ipopt") + + +@FS.step("solve_initial") +def solve(ctx): + """Perform the initial model solve.""" + ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) + + +@FS.step("solve_optimization") +def solve_o(ctx): + ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) diff --git a/docs/examples/structfs/flash_flowsheet_nb.ipynb b/docs/examples/structfs/flash_flowsheet_nb.ipynb new file mode 100644 index 0000000000..81f729a5b5 --- /dev/null +++ b/docs/examples/structfs/flash_flowsheet_nb.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "22ff8940", + "metadata": {}, + "source": [ + "# Structured Flowsheet Example\n", + "The Structured Flowsheet subpackage, in idaes.core.util.structfs, provides\n", + "a way to consistently structure flowsheets and then functions to help\n", + "manipulate them interactively or from an API.\n", + "\n", + "Author: Dan Gunter, LBNL" + ] + }, + { + "cell_type": "markdown", + "id": "a5d038e8", + "metadata": {}, + "source": [ + "## Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "854989ae", + "metadata": {}, + "outputs": [], + "source": [ + "# general-purpose imports\n", + "from pprint import pprint\n", + "\n", + "# Import the 'FS' structured flowsheet wrapper\n", + "from flash_flowsheet import FS\n", + "\n", + "# Imports for the actions\n", + "from idaes.core.util.structfs.runner_actions import Timer, UnitDofChecker\n", + "\n", + "# log-related\n", + "from idaes.core.util.structfs.logutil import quiet, unquiet" + ] + }, + { + "cell_type": "markdown", + "id": "c59f2eb6", + "metadata": {}, + "source": [ + "## Flowsheet setup" + ] + }, + { + "cell_type": "markdown", + "id": "1bcd0169", + "metadata": {}, + "source": [ + "### Add actions\n", + "Add 'actions', which are the framework that automatically does things\n", + "as the flowsheet runs. In this case, add actions for collecting timings\n", + "and calculating degrees of freedom." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb2a1741", + "metadata": {}, + "outputs": [], + "source": [ + "# Add some actions\n", + "FS.add_action(\"timer\", Timer)\n", + "FS.add_action(\n", + " \"dof\", UnitDofChecker, \"fs\", [\"build\", \"solve_initial\", \"solve_optimization\"]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "b195f28a", + "metadata": {}, + "source": [ + "## Solve the square problem" + ] + }, + { + "cell_type": "markdown", + "id": "568d5a77", + "metadata": {}, + "source": [ + "### Run the solver" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "4f58d1b7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "build\n", + "set_operating_conditions\n", + "initialize\n", + "set_solver\n", + "solve_initial\n", + "solve_optimization\n", + "========================================\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", + "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", + "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", + "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" + ] + } + ], + "source": [ + "# solve the square problem (up to 'solve_initial')\n", + "print(\"\\n\".join(FS.list_steps()))\n", + "print(\"=\" * 40)\n", + "quiet()\n", + "FS.run_steps(last=\"solve_initial\")\n", + "unquiet()" + ] + }, + { + "cell_type": "markdown", + "id": "b254fa86", + "metadata": {}, + "source": [ + "### Show results" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "02d7f383", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Problem: \n", + "- Lower bound: -inf\n", + " Upper bound: inf\n", + " Number of objectives: 1\n", + " Number of constraints: 41\n", + " Number of variables: 41\n", + " Sense: unknown\n", + "Solver: \n", + "- Status: ok\n", + " Message: Ipopt 3.13.2\\x3a Optimal Solution Found\n", + " Termination condition: optimal\n", + " Id: 0\n", + " Error rc: 0\n", + " Time: 0.005449056625366211\n", + "Solution: \n", + "- number of solutions: 0\n", + " number of solutions displayed: 0\n", + "\n" + ] + } + ], + "source": [ + "print(FS.results)" + ] + }, + { + "cell_type": "markdown", + "id": "1c669172", + "metadata": {}, + "source": [ + "### Show timings" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "997466d7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Time per step:\n", + "\n", + "build : 0.029 15.2%\n", + "set_operating_conditions : 0.000 0.2%\n", + "initialize : 0.146 76.0%\n", + "set_solver : 0.000 0.0%\n", + "solve_initial : 0.016 8.6%\n", + "\n", + "Total time: 0.209 s\n", + "\n" + ] + } + ], + "source": [ + "FS.get_action(\"timer\")" + ] + }, + { + "cell_type": "markdown", + "id": "dda62457", + "metadata": {}, + "source": [ + "### Show degrees of freedom" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9dd1ecee", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Degrees of freedom: 0\n", + "\n", + "Degrees of freedom after steps:\n", + " build:\n", + " fs : 7\n", + " fs.flash : 2\n", + " fs.flash.control_volume : 7\n", + " fs.flash.split : 0\n", + " fs.properties : 0\n", + " fs.properties.Liq : 0\n", + " fs.properties.Vap : 0\n", + " fs.properties.benzene : 0\n", + " fs.properties.toluene : 0\n", + " solve_initial:\n", + " fs : 0\n", + " fs.flash : 0\n", + " fs.flash.control_volume : 0\n", + " fs.flash.split : 0\n", + " fs.properties : 0\n", + " fs.properties.Liq : 0\n", + " fs.properties.Vap : 0\n", + " fs.properties.benzene : 0\n", + " fs.properties.toluene : 0\n" + ] + } + ], + "source": [ + "dof = FS.get_action(\"dof\")\n", + "dof.summary()" + ] + }, + { + "cell_type": "markdown", + "id": "546345dc", + "metadata": {}, + "source": [ + "## Solve optimization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fcac1f17", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Before: 368\n", + "After : 368.85306111169916\n" + ] + } + ], + "source": [ + "# unfix the inlet temperature\n", + "temp = FS.model.fs.flash.inlet.temperature[0]\n", + "print(f\"Before: {temp.value}\")\n", + "temp.free()\n", + "# solve again\n", + "FS.run_steps(first=\"solve_optimization\")\n", + "# look at new value\n", + "print(f\"After : {temp.value}\")" + ] + }, + { + "cell_type": "markdown", + "id": "585afc38", + "metadata": {}, + "source": [ + "### Show new degrees of freedom" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "68d8794f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Degrees of freedom: 0\n", + "\n", + "fs : 1\n", + "fs.flash : 0\n", + "fs.flash.control_volume : 5\n", + "fs.flash.split : 0\n", + "fs.properties : 0\n", + "fs.properties.Liq : 0\n", + "fs.properties.Vap : 0\n", + "fs.properties.benzene : 0\n", + "fs.properties.toluene : 0\n" + ] + } + ], + "source": [ + "dof.summary(step=\"solve_optimization\")" + ] + }, + { + "cell_type": "markdown", + "id": "8e77e50b", + "metadata": {}, + "source": [ + "## Done!" + ] + }, + { + "cell_type": "markdown", + "id": "ec75111f", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "idaes-py3.12", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/index.rst b/docs/index.rst index 8f8f4f290b..0cf483ac4d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -75,6 +75,7 @@ Contents tutorials/index how_to_guides/index explanations/index + examples/index reference_guides/index archived_features/index explanations/faq diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 5cd382fb23..bbd87549a4 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -79,7 +79,7 @@ def __init__(self, solver=None, tee=False): self._solver, self._tee = solver, tee super().__init__(self.STEPS) # needs to be last - def run_steps(self, from_name: str = "", to_name: str = ""): + def run_steps(self, first: str = "", last: str = ""): """Run the steps. Before it calls the superclass to run the steps, checks @@ -87,14 +87,14 @@ def run_steps(self, from_name: str = "", to_name: str = ""): if so, creates an empty Pyomo ConcreteModel to use as the base model for the flowsheet. """ - from_step_name = self.normalize_name(from_name) + from_step_name = self.normalize_name(first) if ( from_step_name == "" or from_step_name == self.build_step or self._context.model is None ): self._context.model = self._create_model() - super().run_steps(from_name, to_name) + super().run_steps(first, last) def reset(self): self._context = Context(solver=self._solver, tee=self._tee, model=None) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 6edcc47dba..52c3727a04 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -122,14 +122,14 @@ def add_substep(self, base_name, name, func): def run_step(self, name): """Syntactic sugar for calling `run_steps` for a single step.""" - self.run_steps(from_name=name, to_name=name) + self.run_steps(first=name, last=name) - def run_steps(self, from_name: str = "", to_name: str = ""): - """Run steps from `from_name` to step `to_name`. + def run_steps(self, first: str = "", last: str = ""): + """Run steps from `first` to step `last`. Args: - from_name: First step to run - to_name: Last step to run + first: First step to run + last: Last step to run Raises: KeyError: Unknown or undefined step given @@ -138,7 +138,7 @@ def run_steps(self, from_name: str = "", to_name: str = ""): if not self._steps: return # nothing to do, no steps defined - names = (self.normalize_name(from_name), self.normalize_name(to_name)) + names = (self.normalize_name(first), self.normalize_name(last)) step_range = [-1, -1] for i, step_name in enumerate(names): @@ -173,6 +173,14 @@ def reset(self): """Reset runner internal state, especially the context.""" self._context = {} + def list_steps(self, all_steps=False) -> list[str]: + """Get list of [runnable] steps.""" + result = [] + for n in self._step_names: + if all_steps or (n in self._steps): + result.append(n) + return result + def add_action(self, name: str, action_class: type, *args, **kwargs): """Add a named action. diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 37cf5b025b..70eecac060 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -16,6 +16,8 @@ # stdlib from collections import defaultdict from collections.abc import Callable +from io import StringIO +import sys import time from typing import Union, Optional @@ -51,6 +53,7 @@ def __init__(self, runner, **kwargs): self._step_begin = {} self._run_begin = None self._step_order = [] + self._run_steps = set() def before_step(self, step_name): self._step_begin[step_name] = time.time() @@ -66,9 +69,11 @@ def after_step(self, step_name): dt = t1 - t0 self.step_times[step_name].append(dt) self._step_begin[step_name] = None + self._run_steps.add(step_name) def before_run(self): self._run_begin = time.time() + self._run_steps = set() def after_run(self): t1 = time.time() @@ -78,12 +83,13 @@ def after_run(self): dt = t1 - self._run_begin self.run_times.append(dt) self._run_begin = None + for step in self._runner.list_steps() - def summary(self) -> list[dict]: + def get_summary(self) -> list[dict]: """Summarize timings Returns: - dict: Summary of timings (in seconds) for each run in `run_times`: + Summary of timings (in seconds) for each run in `run_times`: - 'run': Time for the run - 'steps': list of (step_name, time) for each step (in order) - 'inclusive': total time spent in the steps @@ -103,6 +109,30 @@ def summary(self) -> list[dict]: ) return data + def summary(self, stream=None) -> str: + if stream is None: + stream = StringIO() + + d = self.get_summary()[-1] + + stream.write("Time per step:\n\n") + slen, ttot = -1, 0 + for s, t in d["steps"]: + slen = max(slen, len(s)) + ttot += t + sfmt = "{{s:{slen}s}} : {{t:8.3f}} {{p:4.1f}}%\n" + for s, t in d["steps"]: + fmt = sfmt.format(slen=slen) + stream.write(fmt.format(s=s, t=t, p=t / ttot * 100)) + + stream.write(f"\nTotal time: {d['run']:.3f} s\n") + + if isinstance(stream, StringIO): + return stream.getvalue() + + def _ipython_display_(self): + print(self.summary()) + # Hold degrees of freedom for one FlowsheetRunner 'step' # {key=component: value=dof} @@ -162,11 +192,13 @@ def __init__( def after_step(self, step_name: str): step_name = self._runner.normalize_name(step_name) if step_name not in self._steps: + self.log.debug(f"Do not check DoF for step: {step_name}") return fs = self._get_flowsheet() - units_dof = {} + model_dof = degrees_of_freedom(self._get_flowsheet()) + units_dof = {self._fs: model_dof} for unit in fs.component_objects(descend_into=True): if self._is_unit_model(unit): units_dof[unit.name] = self._get_dof(unit) @@ -191,29 +223,36 @@ def _get_flowsheet(self): def _is_unit_model(block): return isinstance(block, ProcessBlockData) - def as_dataframe(self) -> pd.DataFrame: - """Format per-step DoF as a Pandas `DataFrame`. + def summary(self, stream=sys.stdout, step=None): + if stream is None: + stream = StringIO() + + def write_step(sdof, indent=4): + sdof = self._steps_dof[step] + istr = " " * indent + unit_names = list(sdof.keys()) + ulen = max((len(u) for u in unit_names)) + dfmt = f"{istr}{{u:{ulen}s}} : {{d}}\n" + unit_names.sort() + for unit in unit_names: + dof = sdof[unit] + stream.write(dfmt.format(u=unit, d=dof)) + + stream.write(f"Degrees of freedom: {self._model_dof}\n\n") + if step is None: + stream.write("Degrees of freedom after steps:\n") + for step in self._runner._steps: + if step in self._steps_dof: + stream.write(f" {step}:\n") + write_step(self._steps_dof[step]) + else: + write_step(self._steps_dof[step], indent=0) - Returns: - DataFrame: Step (str), Unit (str), DoF (int) - """ - step_names, unit_names, dofs = [], [], [] - - # add DoF for each step - for sn, data in self._steps_dof.items(): - for un, dof in data.items(): - step_names.append(sn) - unit_names.append(un) - dofs.append(dof) - - # add model DoF - step_names.append("RUN") - unit_names.append(self._fs) - dofs.append(self._model_dof) - - return pd.DataFrame( - {"after_step": step_names, "unit_name": unit_names, "dof": dofs} - ) + if isinstance(stream, StringIO): + return stream.getvalue() + + def _ipython_display_(self): + self.summary() def get_unit_dof(self, step_name: str) -> UnitDofType: """Get DoF for each unit, as measured after the given step. diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index 895b0605c5..ba24f9f312 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -72,8 +72,8 @@ def test_run_steps_order(): @pytest.mark.unit def test_run_steps_args(): - simple.run_steps(from_name="hello") - simple.run_steps(to_name="world") + simple.run_steps(first="hello") + simple.run_steps(last="world") @pytest.mark.unit diff --git a/requirements-dev.txt b/requirements-dev.txt index 182773d080..7156c0822f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -9,6 +9,7 @@ sphinxcontrib-napoleon>=0.5.0 sphinx-argparse==0.4.0 sphinx-book-theme<=1.1.2,>=1.0.0 sphinx-copybutton==0.5.2 +nbsphinx # for Jupyter Notebooks in the docs ### testing and linting # TODO/NOTE pytest is specified as a dependency in setup.py, but we might want to pin a specific version here From f8c56ec15d637c6c4b3985a9ecb7cb5a547847e4 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 21 Nov 2025 12:54:51 -0800 Subject: [PATCH 18/73] save work --- .../structfs/flash_flowsheet_nb.ipynb | 85 ++++++++++++------- idaes/core/util/structfs/fsrunner.py | 19 ++++- idaes/core/util/structfs/runner_actions.py | 26 +++--- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/docs/examples/structfs/flash_flowsheet_nb.ipynb b/docs/examples/structfs/flash_flowsheet_nb.ipynb index 81f729a5b5..dbf0f9ac5d 100644 --- a/docs/examples/structfs/flash_flowsheet_nb.ipynb +++ b/docs/examples/structfs/flash_flowsheet_nb.ipynb @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 1, "id": "854989ae", "metadata": {}, "outputs": [], @@ -62,7 +62,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "bb2a1741", "metadata": {}, "outputs": [], @@ -92,7 +92,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 3, "id": "4f58d1b7", "metadata": {}, "outputs": [ @@ -107,20 +107,20 @@ "solve_initial\n", "solve_optimization\n", "========================================\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:31 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", - "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", - "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", - "2025-11-21 04:59:32 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", + "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" ] } ], @@ -143,7 +143,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 4, "id": "02d7f383", "metadata": {}, "outputs": [ @@ -165,7 +165,7 @@ " Termination condition: optimal\n", " Id: 0\n", " Error rc: 0\n", - " Time: 0.005449056625366211\n", + " Time: 0.005582094192504883\n", "Solution: \n", "- number of solutions: 0\n", " number of solutions displayed: 0\n", @@ -187,7 +187,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 5, "id": "997466d7", "metadata": {}, "outputs": [ @@ -197,13 +197,13 @@ "text": [ "Time per step:\n", "\n", - "build : 0.029 15.2%\n", - "set_operating_conditions : 0.000 0.2%\n", - "initialize : 0.146 76.0%\n", - "set_solver : 0.000 0.0%\n", - "solve_initial : 0.016 8.6%\n", + " build : 0.015 9.7%\n", + " set_operating_conditions : 0.000 0.1%\n", + " initialize : 0.130 82.5%\n", + " set_solver : 0.000 0.0%\n", + " solve_initial : 0.012 7.6%\n", "\n", - "Total time: 0.209 s\n", + "Total time: 0.168 s\n", "\n" ] } @@ -222,7 +222,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 6, "id": "9dd1ecee", "metadata": {}, "outputs": [ @@ -271,7 +271,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "fcac1f17", "metadata": {}, "outputs": [ @@ -305,7 +305,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 8, "id": "68d8794f", "metadata": {}, "outputs": [ @@ -313,7 +313,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Degrees of freedom: 0\n", + "Degrees of freedom: 5\n", "\n", "fs : 1\n", "fs.flash : 0\n", @@ -331,12 +331,35 @@ "dof.summary(step=\"solve_optimization\")" ] }, + { + "cell_type": "code", + "execution_count": 9, + "id": "4eec370d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Time per step:\n", + "\n", + " solve_optimization : 0.109 100.0%\n", + "\n", + "Total time: 0.119 s\n", + "\n" + ] + } + ], + "source": [ + "FS.get_action(\"timer\")" + ] + }, { "cell_type": "markdown", "id": "8e77e50b", "metadata": {}, "source": [ - "## Done!" + "## Done" ] }, { diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index bbd87549a4..77ddeaafba 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -20,6 +20,8 @@ # third-party from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock +from idaes_connectivity.base import Connectivity +from idaes_connectivity.jupyter import display_connectivity # package from .runner import Runner @@ -51,7 +53,7 @@ def solver(self, value): self["solver"] = value -class FlowsheetRunner(Runner): +class BaseFlowsheetRunner(Runner): """Specialize the base `Runner` to handle IDAES flowsheets. This class pre-determine the name and order of steps to run @@ -113,3 +115,18 @@ def model(self): def results(self): """Syntactic sugar to return the `results` in the context.""" return self._context["results"] + + +class FlowsheetRunner(BaseFlowsheetRunner): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def build(self): + """Run just the build step""" + self.run_step("build") + + def solve_initial(self): + self.run_steps(last="solve_initial") + + def show_diagram(self): + return display_connectivity(input_model=self.model) diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 70eecac060..951dcbc1f7 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -52,13 +52,11 @@ def __init__(self, runner, **kwargs): self.run_times = [] self._step_begin = {} self._run_begin = None - self._step_order = [] + self._step_order = runner.list_steps() self._run_steps = set() def before_step(self, step_name): self._step_begin[step_name] = time.time() - if len(self.run_times) == 0: - self._step_order.append(step_name) def after_step(self, step_name): t1 = time.time() @@ -83,7 +81,9 @@ def after_run(self): dt = t1 - self._run_begin self.run_times.append(dt) self._run_begin = None - for step in self._runner.list_steps() + for step in self._runner.list_steps(): + if step not in self._run_steps: + self.step_times[step].append(-1) # no timing collected def get_summary(self) -> list[dict]: """Summarize timings @@ -98,7 +98,11 @@ def get_summary(self) -> list[dict]: data = [] for i, run_time in enumerate(self.run_times): step_times = [(k, self.step_times[k][i]) for k in self._step_order] - step_total = sum((item[1] for item in step_times)) + step_total = 0 + for item in step_times: + seconds = item[1] + if seconds >= 0: + step_total += seconds data.append( { "run": run_time, @@ -118,12 +122,14 @@ def summary(self, stream=None) -> str: stream.write("Time per step:\n\n") slen, ttot = -1, 0 for s, t in d["steps"]: - slen = max(slen, len(s)) - ttot += t - sfmt = "{{s:{slen}s}} : {{t:8.3f}} {{p:4.1f}}%\n" + if t >= 0: + slen = max(slen, len(s)) + ttot += t + sfmt = " {{s:{slen}s}} : {{t:8.3f}} {{p:4.1f}}%\n" for s, t in d["steps"]: - fmt = sfmt.format(slen=slen) - stream.write(fmt.format(s=s, t=t, p=t / ttot * 100)) + if t >= 0: + fmt = sfmt.format(slen=slen) + stream.write(fmt.format(s=s, t=t, p=(t / ttot * 100))) stream.write(f"\nTotal time: {d['run']:.3f} s\n") From 31f9cbc84ef8d13fe12ef6b10b877ee95afff0ac Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 23 Nov 2025 09:21:24 -0800 Subject: [PATCH 19/73] better interactive usage --- .../structfs/flash_flowsheet_nb.ipynb | 139 ++++++------------ idaes/core/util/structfs/fsrunner.py | 56 +++++++ idaes/core/util/structfs/runner.py | 3 +- idaes/core/util/structfs/runner_actions.py | 99 ++++++------- .../structfs/tests/test_runner_actions.py | 49 +++--- 5 files changed, 180 insertions(+), 166 deletions(-) diff --git a/docs/examples/structfs/flash_flowsheet_nb.ipynb b/docs/examples/structfs/flash_flowsheet_nb.ipynb index dbf0f9ac5d..07ad309dbb 100644 --- a/docs/examples/structfs/flash_flowsheet_nb.ipynb +++ b/docs/examples/structfs/flash_flowsheet_nb.ipynb @@ -41,39 +41,6 @@ "from idaes.core.util.structfs.logutil import quiet, unquiet" ] }, - { - "cell_type": "markdown", - "id": "c59f2eb6", - "metadata": {}, - "source": [ - "## Flowsheet setup" - ] - }, - { - "cell_type": "markdown", - "id": "1bcd0169", - "metadata": {}, - "source": [ - "### Add actions\n", - "Add 'actions', which are the framework that automatically does things\n", - "as the flowsheet runs. In this case, add actions for collecting timings\n", - "and calculating degrees of freedom." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "bb2a1741", - "metadata": {}, - "outputs": [], - "source": [ - "# Add some actions\n", - "FS.add_action(\"timer\", Timer)\n", - "FS.add_action(\n", - " \"dof\", UnitDofChecker, \"fs\", [\"build\", \"solve_initial\", \"solve_optimization\"]\n", - ")" - ] - }, { "cell_type": "markdown", "id": "b195f28a", @@ -92,7 +59,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "4f58d1b7", "metadata": {}, "outputs": [ @@ -106,21 +73,27 @@ "set_solver\n", "solve_initial\n", "solve_optimization\n", - "========================================\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", - "2025-11-21 07:30:38 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" + "========================================\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", + "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" ] } ], @@ -143,7 +116,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "02d7f383", "metadata": {}, "outputs": [ @@ -153,8 +126,8 @@ "text": [ "\n", "Problem: \n", - "- Lower bound: -inf\n", - " Upper bound: inf\n", + "- Lower bound: -.inf\n", + " Upper bound: .inf\n", " Number of objectives: 1\n", " Number of constraints: 41\n", " Number of variables: 41\n", @@ -165,7 +138,7 @@ " Termination condition: optimal\n", " Id: 0\n", " Error rc: 0\n", - " Time: 0.005582094192504883\n", + " Time: 0.005190134048461914\n", "Solution: \n", "- number of solutions: 0\n", " number of solutions displayed: 0\n", @@ -187,7 +160,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "997466d7", "metadata": {}, "outputs": [ @@ -197,19 +170,19 @@ "text": [ "Time per step:\n", "\n", - " build : 0.015 9.7%\n", - " set_operating_conditions : 0.000 0.1%\n", - " initialize : 0.130 82.5%\n", + " build : 0.027 15.9%\n", + " set_operating_conditions : 0.000 0.2%\n", + " initialize : 0.126 74.3%\n", " set_solver : 0.000 0.0%\n", - " solve_initial : 0.012 7.6%\n", + " solve_initial : 0.016 9.5%\n", "\n", - "Total time: 0.168 s\n", + "Total time: 0.172 s\n", "\n" ] } ], "source": [ - "FS.get_action(\"timer\")" + "FS.timings" ] }, { @@ -222,7 +195,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "9dd1ecee", "metadata": {}, "outputs": [ @@ -257,8 +230,7 @@ } ], "source": [ - "dof = FS.get_action(\"dof\")\n", - "dof.summary()" + "FS.dof" ] }, { @@ -271,7 +243,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "id": "fcac1f17", "metadata": {}, "outputs": [ @@ -279,7 +251,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "Before: 368\n", + "Before: 368\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "After : 368.85306111169916\n" ] } @@ -300,12 +278,14 @@ "id": "585afc38", "metadata": {}, "source": [ - "### Show new degrees of freedom" + "### Show new degrees of freedom\n", + "Putting the name of the step as an additional attribute will print a summary of the DoF for \n", + "that step only." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "id": "68d8794f", "metadata": {}, "outputs": [ @@ -328,30 +308,7 @@ } ], "source": [ - "dof.summary(step=\"solve_optimization\")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "4eec370d", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Time per step:\n", - "\n", - " solve_optimization : 0.109 100.0%\n", - "\n", - "Total time: 0.119 s\n", - "\n" - ] - } - ], - "source": [ - "FS.get_action(\"timer\")" + "FS.dof.solve_optimization" ] }, { diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 77ddeaafba..53ec5f2c81 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -20,6 +20,7 @@ # third-party from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock + from idaes_connectivity.base import Connectivity from idaes_connectivity.jupyter import display_connectivity @@ -118,8 +119,63 @@ def results(self): class FlowsheetRunner(BaseFlowsheetRunner): + + class DegreesOfFreedom: + def __init__(self, runner): + from .runner_actions import UnitDofChecker + + self._a = runner.add_action( + "dof", + UnitDofChecker, + "fs", + ["build", "solve_initial", "solve_optimization"], + ) + self._rnr = runner + + def model(self): + return self._a.get_dof_model() + + def __getattr__(self, name): + """Naming the step prints a summary of that step.""" + if name not in set(self._rnr.list_steps()): + raise AttributeError(f"No step named '{name}'") + self._a.summary(step=name) + + def __str__(self): + return self._a.summary(stream=None) + + def _ipython_display_(self): + self._a.summary() + + class Timings: + def __init__(self, runner): + from .runner_actions import Timer + + self._a: Timer = runner.add_action("t", Timer) + + @property + def values(self) -> list[dict]: + return self._a.get_history() + + @property + def history(self) -> str: + h = [] + for i in range(len(self._a)): + h.append(f"== Run {i + 1} ==") + h.append("") + h.append(self._a.summary(run_idx=i)) + return "\n".join(h) + + def __str__(self): + return self._a.summary() + + def _ipython_display_(self): + self._a._ipython_display_() + def __init__(self, **kwargs): super().__init__(**kwargs) + self.dof = self.DegreesOfFreedom(self) + self.timings = self.Timings(self) def build(self): """Run just the build step""" diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 52c3727a04..d62958b68b 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -181,7 +181,7 @@ def list_steps(self, all_steps=False) -> list[str]: result.append(n) return result - def add_action(self, name: str, action_class: type, *args, **kwargs): + def add_action(self, name: str, action_class: type, *args, **kwargs) -> object: """Add a named action. Args: @@ -192,6 +192,7 @@ def add_action(self, name: str, action_class: type, *args, **kwargs): """ obj = action_class(self, *args, **kwargs) self._actions[name] = obj + return obj def get_action(self, name: str) -> ActionType: """Get an action object. diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 951dcbc1f7..96b686a3ab 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -48,12 +48,10 @@ def __init__(self, runner, **kwargs): run_times: List of timings for a run (sequence of steps) """ super().__init__(runner, **kwargs) - self.step_times = defaultdict(list) - self.run_times = [] - self._step_begin = {} - self._run_begin = None + self.step_times: list[dict[str, float]] = [] + self.run_times: list[float] = [] + self._run_begin, self._step_begin = None, {} self._step_order = runner.list_steps() - self._run_steps = set() def before_step(self, step_name): self._step_begin[step_name] = time.time() @@ -64,69 +62,68 @@ def after_step(self, step_name): if t0 is None: self.log.warning(f"Timer: step {step_name} end without begin") else: - dt = t1 - t0 - self.step_times[step_name].append(dt) + self._cur_step_times[step_name] = t1 - t0 self._step_begin[step_name] = None - self._run_steps.add(step_name) def before_run(self): self._run_begin = time.time() - self._run_steps = set() + self._cur_step_times = {} + self._step_begin = {} def after_run(self): t1 = time.time() if self._run_begin is None: self.log.warning("Timer: run end without begin") else: - dt = t1 - self._run_begin - self.run_times.append(dt) + self.run_times.append(t1 - self._run_begin) self._run_begin = None + filled_times = {} for step in self._runner.list_steps(): - if step not in self._run_steps: - self.step_times[step].append(-1) # no timing collected + filled_times[step] = self._cur_step_times.get(step, -1) + self.step_times.append(filled_times) + + def __len__(self): + return len(self.run_times) - def get_summary(self) -> list[dict]: + def get_history(self) -> list[dict]: """Summarize timings Returns: Summary of timings (in seconds) for each run in `run_times`: - 'run': Time for the run - - 'steps': list of (step_name, time) for each step (in order) + - 'steps': dict of `{: }` - 'inclusive': total time spent in the steps - 'exclusive': difference between run time and inclusive time """ - data = [] - for i, run_time in enumerate(self.run_times): - step_times = [(k, self.step_times[k][i]) for k in self._step_order] - step_total = 0 - for item in step_times: - seconds = item[1] - if seconds >= 0: - step_total += seconds - data.append( - { - "run": run_time, - "steps": step_times, - "inclusive": step_total, - "exclusive": run_time - step_total, - } - ) - return data - - def summary(self, stream=None) -> str: + return [self._get_summary(i) for i in range(0, len(self.run_times))] + + def _get_summary(self, i): + rt, st = self.run_times[i], self.step_times[i] + step_total = sum((max(t, 0) for t in st.values())) + return { + "run": rt, + "steps": st, + "inclusive": step_total, + "exclusive": rt - step_total, + } + + def summary(self, stream=None, run_idx=-1) -> str: if stream is None: stream = StringIO() - d = self.get_summary()[-1] + if len(self.run_times) == 0: + return "" # nothing to summarize + + d = self._get_summary(run_idx) stream.write("Time per step:\n\n") slen, ttot = -1, 0 - for s, t in d["steps"]: + for s, t in d["steps"].items(): if t >= 0: slen = max(slen, len(s)) ttot += t sfmt = " {{s:{slen}s}} : {{t:8.3f}} {{p:4.1f}}%\n" - for s, t in d["steps"]: + for s, t in d["steps"].items(): if t >= 0: fmt = sfmt.format(slen=slen) stream.write(fmt.format(s=s, t=t, p=(t / ttot * 100))) @@ -260,26 +257,22 @@ def write_step(sdof, indent=4): def _ipython_display_(self): self.summary() - def get_unit_dof(self, step_name: str) -> UnitDofType: - """Get DoF for each unit, as measured after the given step. - - Args: - step_name: Step for which to get the per-unit degrees of freedom. + def get_dof(self) -> dict[str, UnitDofType]: + """Get degrees of freedom Returns: - UnitDofType + dict[str, UnitDofType]: Mapping of step name to per-unit DoF when + the step completed. + """ + return self._steps_dof.copy() - Raises: - KeyError: If `step_name` is unknown, or has no data - ValueError: There is no degrees_of_freedom data at all + def get_dof_model(self) -> int: + """Get degrees of freedom for the model. + + Returns: + int: Last calculated DoF for the model. """ - if not self._steps_dof: - raise ValueError("No degrees of freedom have been calculated") - if step_name not in self._steps: - raise KeyError( - f"Unknown step. name={step_name} known={','.join(self._steps)}" - ) - return self._steps_dof[step_name] + return self._model_dof def steps(self, only_with_data: bool = False) -> list[str]: """Get list of steps for which unit degrees of freedom are calculated. diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 4ed5588134..23b6905129 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -35,22 +35,24 @@ def test_class_timer(): time.sleep(0.1) timer.after_run() - s = timer.summary() - # tests/test_runner_actions.py [ - # {'run': 0.8005404472351074, - # 'steps': [('step0', 0.10010385513305664), ('step1', 0.20009303092956543), - # ('step2', 0.3000965118408203)], 'inclusive': 0.6002933979034424, 'exclusive': 0.20024704933166504}, - # - # {'run': 0.8004975318908691, 'steps': [('step0', 0.10008621215820312), - # ('step1', 0.20008587837219238), - # ('step2', 0.3000912666320801)], 'inclusive': 0.6002633571624756, 'exclusive': 0.20023417472839355}] + s = timer.get_history() + # [ {'run': 0.8005404472351074, + # 'steps': { + # 'step0': 0.10010385513305664, + # 'step2': 0.3000965118408203, + # 'step1': 0.20009303092956543 + # }, + # 'inclusive': 0.6002933979034424, + # 'exclusive': 0.20024704933166504 + # }, + # ... + # ] eps = 0.1 # big variance needed for Windows for r in s: print(f"Timings: {r}") assert r["run"] == approx(0.8, abs=eps) assert r["inclusive"] + r["exclusive"] == approx(r["run"]) - for i, (name, t) in enumerate(r["steps"]): - assert name == f"step{i}" + for name, t in r["steps"].items(): assert t == approx(0.1 + 0.1 * i, abs=eps) @@ -58,26 +60,31 @@ def test_class_timer(): def test_timer_runner(): rn = runner.Runner(["step1", "step2", "step3"]) - def sleepy(context): + @rn.step("step1") + def sleepy1(context): time.sleep(0.1) - rn.add_step("step1", sleepy) - rn.add_step("step2", sleepy) - rn.add_step("step3", sleepy) + @rn.step("step2") + def sleepy2(context): + time.sleep(0.1) + + @rn.step("step3") + def sleepy3(context): + time.sleep(0.1) rn.add_action("timer", Timer) rn.run_steps() - s = rn.get_action("timer").summary() + s = rn.get_action("timer").get_history() eps = 0.1 # big variance needed for Windows for r in s: print(f"Timings: {r}") assert r["run"] == approx(0.3, abs=eps) assert r["inclusive"] + r["exclusive"] == approx(r["run"]) - for i, (name, t) in enumerate(r["steps"]): - assert name == f"step{i + 1}" + for name, t in r["steps"].items(): + # assert name == f"step{i + 1}" assert t == approx(0.1, abs=eps) @@ -87,7 +94,7 @@ def test_unit_dof_action_base(): rn.reset() def check_step(name, data): - # print(f"@@ check_step {name} data: {data}") + print(f"check_step {name} data: {data}") assert "fs.flash" in data if name == "solve_initial": assert data["fs.flash"] == 0 @@ -106,7 +113,7 @@ def check_run(step_dof, model_dof): rn.run_steps("build", "solve_initial") - pprint.pprint(rn.get_action("check_dof").as_dataframe()) + pprint.pprint(rn.get_action("check_dof").get_dof()) @pytest.mark.unit @@ -128,7 +135,7 @@ def test_unit_dof_action_getters(): steps = act.steps() dofs = [] for s in steps: - step_dof = act.get_unit_dof(s) + step_dof = act.get_dof()[s] assert step_dof dofs.append(step_dof) assert dofs[0] != dofs[1] From d2959562be03cc64b4e772739901836b1df4557b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Wed, 26 Nov 2025 10:10:19 -0800 Subject: [PATCH 20/73] save work --- docs/examples/structfs/hda_flowsheet.py | 378 ++++++++++++++ docs/examples/structfs/hda_flowsheet_nb.ipynb | 460 ++++++++++++++++++ idaes/core/util/structfs/fsrunner.py | 11 + idaes/core/util/structfs/runner.py | 32 +- 4 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 docs/examples/structfs/hda_flowsheet.py create mode 100644 docs/examples/structfs/hda_flowsheet_nb.ipynb diff --git a/docs/examples/structfs/hda_flowsheet.py b/docs/examples/structfs/hda_flowsheet.py new file mode 100644 index 0000000000..6858425154 --- /dev/null +++ b/docs/examples/structfs/hda_flowsheet.py @@ -0,0 +1,378 @@ +############################################################################### +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2023 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +############################################################################### + + +# +# # HDA Flowsheet Simulation and Optimization +# +# Author: Jaffer Ghouse +# Maintainer: Brandon Paul +# Updated: 2023-06-01 +# +# ## Learning outcomes +# +# +# - Construct a steady-state flowsheet using the IDAES unit model library +# - Connecting unit models in a flowsheet using Arcs +# - Using the SequentialDecomposition tool to initialize a flowsheet with recycle +# - Formulate and solve an optimization problem +# - Defining an objective function +# - Setting variable bounds +# - Adding additional constraints +# +# +# ## Problem Statement +# +# Hydrodealkylation is a chemical reaction that often involves reacting +# an aromatic hydrocarbon in the presence of hydrogen gas to form a +# simpler aromatic hydrocarbon devoid of functional groups. In this +# example, toluene will be reacted with hydrogen gas at high temperatures +# to form benzene via the following reaction: +# +# **C6H5CH3 + H2 → C6H6 + CH4** +# +# +# This reaction is often accompanied by an equilibrium side reaction +# which forms diphenyl, which we will neglect for this example. +# +# This example is based on the 1967 AIChE Student Contest problem as +# present by Douglas, J.M., Chemical Design of Chemical Processes, 1988, +# McGraw-Hill. +# +# The flowsheet that we will be using for this module is shown below with the stream conditions. We will be processing toluene and hydrogen to produce at least 370 TPY of benzene. As shown in the flowsheet, there are two flash tanks, F101 to separate out the non-condensibles and F102 to further separate the benzene-toluene mixture to improve the benzene purity. Note that typically a distillation column is required to obtain high purity benzene but that is beyond the scope of this workshop. The non-condensibles separated out in F101 will be partially recycled back to M101 and the rest will be either purged or combusted for power generation.We will assume ideal gas for this flowsheet. The properties required for this module are available in the same directory: +# +# - hda_ideal_VLE.py +# - hda_reaction.py +# +# The state variables chosen for the property package are **flows of component by phase, temperature and pressure**. The components considered are: **toluene, hydrogen, benzene and methane**. Therefore, every stream has 8 flow variables, 1 temperature and 1 pressure variable. +# +# ![](HDA_flowsheet.png) +# +# + +# ## Importing required pyomo and idaes components +# +# +# To construct a flowsheet, we will need several components from the pyomo and idaes package. Let us first import the following components from Pyomo: +# - Constraint (to write constraints) +# - Var (to declare variables) +# - ConcreteModel (to create the concrete model object) +# - Expression (to evaluate values as a function of variables defined in the model) +# - Objective (to define an objective function for optimization) +# - SolverFactory (to solve the problem) +# - TransformationFactory (to apply certain transformations) +# - Arc (to connect two unit models) +# - SequentialDecomposition (to initialize the flowsheet in a sequential mode) +# +# For further details on these components, please refer to the pyomo documentation: https://pyomo.readthedocs.io/en/stable/ +# + + +from pyomo.environ import ( + Constraint, + Var, + ConcreteModel, + Expression, + Objective, + SolverFactory, + TerminationCondition, + TransformationFactory, + value, +) +from pyomo.network import Arc, SequentialDecomposition + +from idaes.core import FlowsheetBlock + +from idaes.models.unit_models import ( + PressureChanger, + Mixer, + Separator as Splitter, + Heater, + StoichiometricReactor, +) + +from idaes.models.unit_models import Flash +from idaes.models.unit_models.pressure_changer import ThermodynamicAssumption +from idaes.core.util.model_statistics import degrees_of_freedom + +import idaes.logger as idaeslog +from idaes.core.solvers import get_solver +from idaes.core.util.exceptions import InitializationError + +from idaes.core.util.structfs.fsrunner import FlowsheetRunner, Context + +import hda_ideal_VLE as thermo_props +import hda_reaction as reaction_props + + +FS = FlowsheetRunner() + + +@FS.step("build") +def build_model(ctx: Context): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + add_property_packages(m) + add_units(m) + connect_units(m) + add_expr(m) + ctx.model = m + + +# We now need to add the property packages to the flowsheet. Unlike Module 1, where we only had a thermo property package, for this flowsheet we will also need to add a reaction property package. + + +@FS.substep("build", "add_props") +def add_property_packages(m): + m.fs.thermo_params = thermo_props.HDAParameterBlock() + m.fs.reaction_params = reaction_props.HDAReactionParameterBlock( + property_package=m.fs.thermo_params + ) + + +@FS.substep("build", "add_units") +def add_units(m): + """Add the unit models we have imported to the flowsheet. + + Here, we are adding the Mixer (assigned a name M101) and a Heater (assigned a name H101). + Note that, all unit models need to be given a property package argument. + """ + m.fs.M101 = Mixer( + property_package=m.fs.thermo_params, + inlet_list=["toluene_feed", "hydrogen_feed", "vapor_recycle"], + ) + + m.fs.H101 = Heater( + property_package=m.fs.thermo_params, + has_pressure_change=False, + has_phase_equilibrium=True, + ) + + # Todo: Add reactor with the specifications above + m.fs.R101 = StoichiometricReactor( + property_package=m.fs.thermo_params, + reaction_package=m.fs.reaction_params, + has_heat_of_reaction=True, + has_heat_transfer=True, + has_pressure_change=False, + ) + + m.fs.F101 = Flash( + property_package=m.fs.thermo_params, + has_heat_transfer=True, + has_pressure_change=True, + ) + + m.fs.S101 = Splitter( + property_package=m.fs.thermo_params, + ideal_separation=False, + outlet_list=["purge", "recycle"], + ) + + m.fs.C101 = PressureChanger( + property_package=m.fs.thermo_params, + compressor=True, + thermodynamic_assumption=ThermodynamicAssumption.isothermal, + ) + + m.fs.F102 = Flash( + property_package=m.fs.thermo_params, + has_heat_transfer=True, + has_pressure_change=True, + ) + + +@FS.substep("build", "create_arcs") +def connect_units(m): + """Connect Unit Models using Arcs""" + m.fs.s03 = Arc(source=m.fs.M101.outlet, destination=m.fs.H101.inlet) + m.fs.s04 = Arc(source=m.fs.H101.outlet, destination=m.fs.R101.inlet) + m.fs.s05 = Arc(source=m.fs.R101.outlet, destination=m.fs.F101.inlet) + m.fs.s06 = Arc(source=m.fs.F101.vap_outlet, destination=m.fs.S101.inlet) + m.fs.s08 = Arc(source=m.fs.S101.recycle, destination=m.fs.C101.inlet) + m.fs.s09 = Arc(source=m.fs.C101.outlet, destination=m.fs.M101.vapor_recycle) + m.fs.s10 = Arc(source=m.fs.F101.liq_outlet, destination=m.fs.F102.inlet) + + TransformationFactory("network.expand_arcs").apply_to(m) + + +@FS.substep("build", "add_expressions") +def add_expr(m): + """Add expressions to compute purity and operating costs""" + + m.fs.purity = Expression( + expr=m.fs.F102.vap_outlet.flow_mol_phase_comp[0, "Vap", "benzene"] + / ( + m.fs.F102.vap_outlet.flow_mol_phase_comp[0, "Vap", "benzene"] + + m.fs.F102.vap_outlet.flow_mol_phase_comp[0, "Vap", "toluene"] + ) + ) + m.fs.cooling_cost = Expression( + expr=0.212e-7 * (-m.fs.F101.heat_duty[0]) + 0.212e-7 * (-m.fs.R101.heat_duty[0]) + ) + m.fs.heating_cost = Expression( + expr=2.2e-7 * m.fs.H101.heat_duty[0] + 1.9e-7 * m.fs.F102.heat_duty[0] + ) + m.fs.operating_cost = Expression( + expr=(3600 * 24 * 365 * (m.fs.heating_cost + m.fs.cooling_cost)) + ) + + +@FS.step("set_operating_conditions") +def set_op_cond(ctx): + m = ctx.model + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Vap", "benzene"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Vap", "toluene"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Vap", "hydrogen"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Vap", "methane"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Liq", "benzene"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Liq", "toluene"].fix(0.30) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Liq", "hydrogen"].fix(1e-5) + m.fs.M101.toluene_feed.flow_mol_phase_comp[0, "Liq", "methane"].fix(1e-5) + m.fs.M101.toluene_feed.temperature.fix(303.2) + m.fs.M101.toluene_feed.pressure.fix(350000) + + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Vap", "benzene"].fix(1e-5) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Vap", "toluene"].fix(1e-5) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Vap", "hydrogen"].fix(0.30) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Vap", "methane"].fix(0.02) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Liq", "benzene"].fix(1e-5) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Liq", "toluene"].fix(1e-5) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Liq", "hydrogen"].fix(1e-5) + m.fs.M101.hydrogen_feed.flow_mol_phase_comp[0, "Liq", "methane"].fix(1e-5) + m.fs.M101.hydrogen_feed.temperature.fix(303.2) + m.fs.M101.hydrogen_feed.pressure.fix(350000) + + m.fs.H101.outlet.temperature.fix(600) + + m.fs.R101.conversion = Var(initialize=0.75, bounds=(0, 1)) + + m.fs.R101.conv_constraint = Constraint( + expr=m.fs.R101.conversion + * m.fs.R101.inlet.flow_mol_phase_comp[0, "Vap", "toluene"] + == ( + m.fs.R101.inlet.flow_mol_phase_comp[0, "Vap", "toluene"] + - m.fs.R101.outlet.flow_mol_phase_comp[0, "Vap", "toluene"] + ) + ) + + m.fs.R101.conversion.fix(0.75) + m.fs.R101.heat_duty.fix(0) + + # Flash 1 + m.fs.F101.vap_outlet.temperature.fix(325.0) + m.fs.F101.deltaP.fix(0) + + # Flash 2 + m.fs.F102.vap_outlet.temperature.fix(375) + m.fs.F102.deltaP.fix(-200000) + + # Purge split fraction + m.fs.S101.split_fraction[0, "purge"].fix(0.2) + m.fs.C101.outlet.pressure.fix(350000) + + +@FS.step("initialize") +def initialization(ctx): + m = ctx.model + seq = SequentialDecomposition() + seq.options.select_tear_method = "heuristic" + seq.options.tear_method = "Wegstein" + seq.options.iterLim = 3 + + # Using the SD tool + G = seq.create_graph(m) + heuristic_tear_set = seq.tear_set_arcs(G, method="heuristic") + order = seq.calculation_order(G) + + tear_guesses = { + "flow_mol_phase_comp": { + (0, "Vap", "benzene"): 1e-5, + (0, "Vap", "toluene"): 1e-5, + (0, "Vap", "hydrogen"): 0.30, + (0, "Vap", "methane"): 0.02, + (0, "Liq", "benzene"): 1e-5, + (0, "Liq", "toluene"): 0.30, + (0, "Liq", "hydrogen"): 1e-5, + (0, "Liq", "methane"): 1e-5, + }, + "temperature": {0: 303}, + "pressure": {0: 350000}, + } + + # Pass the tear_guess to the SD tool + seq.set_guesses_for(m.fs.H101.inlet, tear_guesses) + + def init_function(unit): + try: + initializer = unit.default_initializer() + initializer.initialize(unit, output_level=idaeslog.INFO) + except InitializationError: + solver = get_solver() + solver.solve(unit) + + seq.run(m, init_function) + + +@FS.step("set_solver") +def set_solver(ctx): + ctx.solver = SolverFactory("ipopt") + + +@FS.step("solve_initial") +def solve(ctx): + """Perform the initial model solve.""" + ctx["status"] = results = ctx.solver.solve(ctx.model, tee=ctx["tee"]) + assert results.solver.termination_condition == TerminationCondition.optimal + + +@FS.step("solve_optimization") +def solve_opt(ctx): + m = ctx.model + m.fs.objective = Objective(expr=m.fs.operating_cost) + + m.fs.H101.outlet.temperature.unfix() + m.fs.R101.heat_duty.unfix() + m.fs.F101.vap_outlet.temperature.unfix() + m.fs.F102.vap_outlet.temperature.unfix() + + m.fs.F102.deltaP.unfix() + + assert degrees_of_freedom(m) == 5 + + m.fs.H101.outlet.temperature[0].setlb(500) + m.fs.H101.outlet.temperature[0].setub(600) + + m.fs.R101.outlet.temperature[0].setlb(600) + m.fs.R101.outlet.temperature[0].setub(800) + + m.fs.F101.vap_outlet.temperature[0].setlb(298.0) + m.fs.F101.vap_outlet.temperature[0].setub(450.0) + m.fs.F102.vap_outlet.temperature[0].setlb(298.0) + m.fs.F102.vap_outlet.temperature[0].setub(450.0) + m.fs.F102.vap_outlet.pressure[0].setlb(105000) + m.fs.F102.vap_outlet.pressure[0].setub(110000) + + m.fs.overhead_loss = Constraint( + expr=m.fs.F101.vap_outlet.flow_mol_phase_comp[0, "Vap", "benzene"] + <= 0.20 * m.fs.R101.outlet.flow_mol_phase_comp[0, "Vap", "benzene"] + ) + + m.fs.product_flow = Constraint( + expr=m.fs.F102.vap_outlet.flow_mol_phase_comp[0, "Vap", "benzene"] >= 0.15 + ) + + m.fs.product_purity = Constraint(expr=m.fs.purity >= 0.80) + + results = ctx.solver.solve(ctx.model, tee=ctx["tee"]) + ctx["results"] = results diff --git a/docs/examples/structfs/hda_flowsheet_nb.ipynb b/docs/examples/structfs/hda_flowsheet_nb.ipynb new file mode 100644 index 0000000000..796e61d060 --- /dev/null +++ b/docs/examples/structfs/hda_flowsheet_nb.ipynb @@ -0,0 +1,460 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "cd678c21", + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import TerminationCondition, value\n", + "from hda_flowsheet import FS" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0bfdd163", + "metadata": {}, + "outputs": [ + { + "ename": "TypeError", + "evalue": "BaseFlowsheetRunner.run_steps() got an unexpected keyword argument 'before'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[3], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mFS\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mrun_steps\u001b[49m\u001b[43m(\u001b[49m\u001b[43mbefore\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43msolve_optimization\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "\u001b[0;31mTypeError\u001b[0m: BaseFlowsheetRunner.run_steps() got an unexpected keyword argument 'before'" + ] + } + ], + "source": [ + "FS.run_steps(before=\"solve_optimization\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0a96da4a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "operating cost = $ 419122.3387677973\n", + "\n", + "====================================================================================\n", + "Unit : fs.F102 Time: 0.0\n", + "------------------------------------------------------------------------------------\n", + " Unit Performance\n", + "\n", + " Variables: \n", + "\n", + " Key : Value : Units : Fixed : Bounds\n", + " Heat Duty : 7352.5 : watt : False : (None, None)\n", + " Pressure Change : -2.0000e+05 : pascal : True : (None, None)\n", + "\n", + "------------------------------------------------------------------------------------\n", + " Stream Table\n", + " Units Inlet Vapor Outlet Liquid Outlet\n", + " flow_mol_phase_comp ('Liq', 'benzene') mole / second 0.20460 1.0000e-08 0.062620 \n", + " flow_mol_phase_comp ('Liq', 'toluene') mole / second 0.062520 1.0000e-08 0.032257 \n", + " flow_mol_phase_comp ('Liq', 'methane') mole / second 2.6712e-07 1.0000e-08 9.4877e-08 \n", + " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 2.6712e-07 1.0000e-08 9.4877e-08 \n", + " flow_mol_phase_comp ('Vap', 'benzene') mole / second 1.0000e-08 0.14198 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'toluene') mole / second 1.0000e-08 0.030264 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.0000e-08 1.8224e-07 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 1.0000e-08 1.8224e-07 1.0000e-08 \n", + " temperature kelvin 325.00 375.00 375.00 \n", + " pressure pascal 3.5000e+05 1.5000e+05 1.5000e+05 \n", + "====================================================================================\n", + "\n", + "benzene purity = 0.8242962943918926\n", + " Units Reactor Light Gases\n", + "flow_mol_phase_comp ('Liq', 'benzene') mole / second 1.2993e-07 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'toluene') mole / second 8.4147e-07 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 \n", + "flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.35374 0.14915 \n", + "flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.078129 0.015610 \n", + "flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2721 1.2721 \n", + "flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.32821 0.32821 \n", + "temperature kelvin 771.85 325.00 \n", + "pressure pascal 3.5000e+05 3.5000e+05 \n" + ] + } + ], + "source": [ + "m = FS.model\n", + "\n", + "# What is the total operating cost?\n", + "print(\"operating cost = $\", value(m.fs.operating_cost))\n", + "\n", + "# For this operating cost, what is the amount of benzene we are able to produce and what purity we are able to achieve?\n", + "m.fs.F102.report()\n", + "print()\n", + "print(\"benzene purity = \", value(m.fs.purity))\n", + "\n", + "\n", + "# How much benzene are we losing in the F101 vapor outlet stream?\n", + "from idaes.core.util.tables import (\n", + " create_stream_table_dataframe,\n", + " stream_table_dataframe_to_string,\n", + ")\n", + "st = create_stream_table_dataframe({\"Reactor\": m.fs.s05, \"Light Gases\": m.fs.s06})\n", + "print(stream_table_dataframe_to_string(st))" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8b4dd126", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "WARNING: Error converting Ipopt log entry to float: could not convert string\n", + "to float: '0.00e+00S'\n", + " 59r 0.0000000e+00 2.06e+04 2.95e+09 -1.5 1.69e-01 6.9 5.41e-04\n", + " 0.00e+00S 9\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "WARNING: Wegstein failed to converge in 3 iterations\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", + "2025-11-23 11:56:08 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n" + ] + } + ], + "source": [ + "FS.run_steps(last=\"solve_optimization\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a1dc1860", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "operating cost = $ 312786.3383406732\n", + "\n", + "Product flow rate and purity in F102\n", + "\n", + "====================================================================================\n", + "Unit : fs.F102 Time: 0.0\n", + "------------------------------------------------------------------------------------\n", + " Unit Performance\n", + "\n", + " Variables: \n", + "\n", + " Key : Value : Units : Fixed : Bounds\n", + " Heat Duty : 8377.0 : watt : False : (None, None)\n", + " Pressure Change : -2.4500e+05 : pascal : False : (None, None)\n", + "\n", + "------------------------------------------------------------------------------------\n", + " Stream Table\n", + " Units Inlet Vapor Outlet Liquid Outlet\n", + " flow_mol_phase_comp ('Liq', 'benzene') mole / second 0.21743 1.0000e-08 0.067425 \n", + " flow_mol_phase_comp ('Liq', 'toluene') mole / second 0.070695 1.0000e-08 0.037507 \n", + " flow_mol_phase_comp ('Liq', 'methane') mole / second 2.8812e-07 1.0000e-08 1.0493e-07 \n", + " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 2.8812e-07 1.0000e-08 1.0493e-07 \n", + " flow_mol_phase_comp ('Vap', 'benzene') mole / second 1.0000e-08 0.15000 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'toluene') mole / second 1.0000e-08 0.033189 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.0000e-08 1.9319e-07 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 1.0000e-08 1.9319e-07 1.0000e-08 \n", + " temperature kelvin 301.88 362.93 362.93 \n", + " pressure pascal 3.5000e+05 1.0500e+05 1.0500e+05 \n", + "====================================================================================\n", + "\n", + "benzene purity = 0.818827657811582\n", + "\n", + "Overhead loss in F101\n", + "\n", + "====================================================================================\n", + "Unit : fs.F101 Time: 0.0\n", + "------------------------------------------------------------------------------------\n", + " Unit Performance\n", + "\n", + " Variables: \n", + "\n", + " Key : Value : Units : Fixed : Bounds\n", + " Heat Duty : -56353. : watt : False : (None, None)\n", + " Pressure Change : 0.0000 : pascal : True : (None, None)\n", + "\n", + "------------------------------------------------------------------------------------\n", + " Stream Table\n", + " Units Inlet Vapor Outlet Liquid Outlet\n", + " flow_mol_phase_comp ('Liq', 'benzene') mole / second 4.3534e-08 1.0000e-08 0.21743 \n", + " flow_mol_phase_comp ('Liq', 'toluene') mole / second 7.5866e-07 1.0000e-08 0.070695 \n", + " flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 2.8812e-07 \n", + " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 2.8812e-07 \n", + " flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.27178 0.054356 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.076085 0.0053908 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2414 1.2414 1.0000e-08 \n", + " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.35887 0.35887 1.0000e-08 \n", + " temperature kelvin 696.11 301.88 301.88 \n", + " pressure pascal 3.5000e+05 3.5000e+05 3.5000e+05 \n", + "====================================================================================\n", + "Optimal Values\n", + "\n", + "H101 outlet temperature = 500.0 K\n", + "\n", + "R101 outlet temperature = 696.1117584980856 K\n", + "\n", + "F101 outlet temperature = 301.8784760572362 K\n", + "\n", + "F102 outlet temperature = 362.93476830509684 K\n", + "F102 outlet pressure = 105000.0 Pa\n" + ] + } + ], + "source": [ + "m = FS.model\n", + "results = FS.results\n", + "\n", + "assert results.solver.termination_condition == TerminationCondition.optimal\n", + "\n", + "print(\"operating cost = $\", value(m.fs.operating_cost))\n", + "\n", + "print()\n", + "print(\"Product flow rate and purity in F102\")\n", + "\n", + "m.fs.F102.report()\n", + "\n", + "print()\n", + "print(\"benzene purity = \", value(m.fs.purity))\n", + "\n", + "print()\n", + "print(\"Overhead loss in F101\")\n", + "m.fs.F101.report()\n", + "\n", + "\n", + "# assert value(m.fs.operating_cost) == pytest.approx(312786.338, abs=1e-3)\n", + "# assert value(m.fs.purity) == pytest.approx(0.818827, abs=1e-3)\n", + "\n", + "print(\"Optimal Values\")\n", + "print()\n", + "\n", + "print(\"H101 outlet temperature = \", value(m.fs.H101.outlet.temperature[0]), \"K\")\n", + "\n", + "print()\n", + "print(\"R101 outlet temperature = \", value(m.fs.R101.outlet.temperature[0]), \"K\")\n", + "\n", + "print()\n", + "print(\"F101 outlet temperature = \", value(m.fs.F101.vap_outlet.temperature[0]), \"K\")\n", + "\n", + "print()\n", + "print(\"F102 outlet temperature = \", value(m.fs.F102.vap_outlet.temperature[0]), \"K\")\n", + "print(\"F102 outlet pressure = \", value(m.fs.F102.vap_outlet.pressure[0]), \"Pa\")\n", + "\n", + "# assert value(m.fs.H101.outlet.temperature[0]) == pytest.approx(500, abs=1e-3)\n", + "# assert value(m.fs.R101.outlet.temperature[0]) == pytest.approx(696.112, abs=1e-3)\n", + "# assert value(m.fs.F101.vap_outlet.temperature[0]) == pytest.approx(301.878, abs=1e-3)\n", + "# assert value(m.fs.F102.vap_outlet.temperature[0]) == pytest.approx(362.935, abs=1e-3)\n", + "# assert value(m.fs.F102.vap_outlet.pressure[0]) == pytest.approx(105000, abs=1e-2)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e3ec5e4b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/markdown": [ + "```mermaid\n", + "flowchart LR\n", + " Unit_B[\"M101\"]\n", + " Unit_C[\"H101\"]\n", + " Unit_D[\"R101\"]\n", + " Unit_E[\"F101\"]\n", + " Unit_F[\"S101\"]\n", + " Unit_G[\"C101\"]\n", + " Unit_H[\"F102\"]\n", + " Unit_B --> Unit_C\n", + " Unit_C --> Unit_D\n", + " Unit_D --> Unit_E\n", + " Unit_E --> Unit_F\n", + " Unit_F --> Unit_G\n", + " Unit_G --> Unit_B\n", + " Unit_E --> Unit_H\n", + "\n", + "```" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "FS.show_diagram()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0262031", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Degrees of freedom: 5\n", + "\n", + "fs : 5\n", + "fs.C101 : 0\n", + "fs.C101.control_volume : 11\n", + "fs.F101 : 1\n", + "fs.F101.control_volume : 11\n", + "fs.F101.split : 0\n", + "fs.F102 : 2\n", + "fs.F102.control_volume : 12\n", + "fs.F102.split : 0\n", + "fs.H101 : 1\n", + "fs.H101.control_volume : 11\n", + "fs.M101 : 0\n", + "fs.R101 : 1\n", + "fs.R101.control_volume : 12\n", + "fs.S101 : -10\n", + "fs.reaction_params : 0\n", + "fs.thermo_params : 0\n", + "fs.thermo_params.Liq : 0\n", + "fs.thermo_params.Vap : 0\n", + "fs.thermo_params.benzene : 0\n", + "fs.thermo_params.hydrogen : 0\n", + "fs.thermo_params.methane : 0\n", + "fs.thermo_params.toluene : 0\n" + ] + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83297a7a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 53ec5f2c81..e671f02b07 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -117,6 +117,17 @@ def results(self): """Syntactic sugar to return the `results` in the context.""" return self._context["results"] + def mark(self, obj, title=None, desc=None, units=None, rounding=0, **kwargs): + """Annotate a variable""" + # XXX: fill in defaults for None/0 + self._marks[obj] = { + "title": title, + "description": desc, + "units": units, + "rounding": rounding, + } + self._marks[obj].update(kwargs) + class FlowsheetRunner(BaseFlowsheetRunner): diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index d62958b68b..f90cb270cb 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -26,6 +26,8 @@ class Step: """Step to run by the `Runner`.""" + SEP = "::" # when printing out step::substep + def __init__(self, name: str, func: Callable): """Constructor @@ -124,12 +126,18 @@ def run_step(self, name): """Syntactic sugar for calling `run_steps` for a single step.""" self.run_steps(first=name, last=name) - def run_steps(self, first: str = "", last: str = ""): + def run_steps( + self, + first: str = "", + last: str = "", + endpoints: tuple[bool, bool] = (True, True), + ): """Run steps from `first` to step `last`. Args: first: First step to run last: Last step to run + endpoints: Whether to include (first, last) in steps (default=True for both) Raises: KeyError: Unknown or undefined step given @@ -162,6 +170,10 @@ def run_steps(self, first: str = "", last: str = ""): action.before_run() for i in range(step_range[0], step_range[1] + 1): + if (i == step_range[0] and not endpoints[0]) or ( + i == step_range[1] and not endpoints[1] + ): + continue step = self._steps.get(self._step_names[i], None) if step: step.func(self._context) @@ -247,10 +259,18 @@ def _step_begin(self, name: str): for action in self._actions.values(): action.before_step(name) + def _substep_begin(self, base: str, name: str): + for action in self._actions.values(): + action.before_substep(base, name) + def _step_end(self, name: str): for action in self._actions.values(): action.after_step(name) + def _substep_end(self, base: str, name: str): + for action in self._actions.values(): + action.after_substep(base, name) + def step(self, name: str): """Decorator function for creating a new step. @@ -292,9 +312,9 @@ def substep(self, base: str, name: str): def step_decorator(func): def wrapper(*args, **kwargs): - self._step_begin(name) + self._substep_begin(base, name) result = func(*args, **kwargs) - self._step_end(name) + self._substep_end(base, name) return result self.add_substep(base, name, wrapper) @@ -327,6 +347,9 @@ def before_step(self, step_name: str): """ return + def before_substep(self, step_name: str, substep_name: str): + return + def after_step(self, step_name: str): """Perform this action after the named step. @@ -335,6 +358,9 @@ def after_step(self, step_name: str): """ return + def after_substep(self, step_name: str, substep_name: str): + return + def before_run(self): """Perform this action before a run starts.""" return From 4ace189fd34ea95ec99b322e0c0d3bd7e2122966 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 1 Dec 2025 17:23:22 -0800 Subject: [PATCH 21/73] save work --- idaes/core/util/structfs/fsrunner.py | 57 +++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index e671f02b07..72ab48e7c8 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -117,17 +117,64 @@ def results(self): """Syntactic sugar to return the `results` in the context.""" return self._context["results"] - def mark(self, obj, title=None, desc=None, units=None, rounding=0, **kwargs): + def mark( + self, + obj, + title=None, + desc=None, + units=None, + rounding=3, + is_input=True, + is_output=True, + input_category="main", + output_category="main", + ): """Annotate a variable""" - # XXX: fill in defaults for None/0 self._marks[obj] = { - "title": title, - "description": desc, - "units": units, + "fullname": obj.getname(), + "title": title or obj.getname(), + "description": desc or obj.getname(fully_qualified=True), + "units": units or str(obj.getunits()), "rounding": rounding, + "is_input": is_input, + "is_output": is_output, + "input_category": input_category, + "output_category": output_category, } self._marks[obj].update(kwargs) + def marks_to_table(self) -> list[list[str]]: + """Translate 'marks' to a table suitable for loading up the UI (Flowsheet Processor). + + Returns: + list of rows, each row is a list of strings. First row is the header: + `name,obj,description,ui_units,display_units,rounding,is_input,input_category,is_output,output_category` + """ + hdr_items = [ + "name", + "obj", + "description", + "ui_units", + "display_units", + "rounding", + "is_input", + "input_category", + "is_output", + "output_category", + ] + # add header row + tbl = [hdr_items] + # add one row for each marked variable + for m in self._marks: + if "display_units" not in m: + m["display_units"] = m["units"] + row = [m["title"], m["fullname"], m["description"], m["units"]] + for key in hdr_items[4:]: + row.append(m[key]) + tbl.append(row) + # return table + return tbl + class FlowsheetRunner(BaseFlowsheetRunner): From 3ad8a850a409072b73b7c35443dc9473583df556 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 6 Dec 2025 05:45:30 -0800 Subject: [PATCH 22/73] annotation tests/fixes --- idaes/core/util/structfs/fsrunner.py | 100 +++++++++--------- .../util/structfs/tests/flash_flowsheet.py | 1 + .../core/util/structfs/tests/test_fsrunner.py | 42 ++++++++ 3 files changed, 94 insertions(+), 49 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 72ab48e7c8..f8daa74751 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -15,10 +15,11 @@ in `FlowsheetRunner`. """ # stdlib -# none +from typing import Union # third-party from pyomo.environ import ConcreteModel +from pyomo.environ import units as pyunits from idaes.core import FlowsheetBlock from idaes_connectivity.base import Connectivity @@ -80,6 +81,7 @@ class BaseFlowsheetRunner(Runner): def __init__(self, solver=None, tee=False): self.build_step = self.STEPS[0] self._solver, self._tee = solver, tee + self._ann = {} super().__init__(self.STEPS) # needs to be last def run_steps(self, first: str = "", last: str = ""): @@ -117,63 +119,63 @@ def results(self): """Syntactic sugar to return the `results` in the context.""" return self._context["results"] - def mark( + def annotate( self, - obj, - title=None, - desc=None, - units=None, - rounding=3, - is_input=True, - is_output=True, - input_category="main", - output_category="main", - ): - """Annotate a variable""" - self._marks[obj] = { - "fullname": obj.getname(), - "title": title or obj.getname(), - "description": desc or obj.getname(fully_qualified=True), - "units": units or str(obj.getunits()), + block: object, + key: str = None, + title: str = None, + desc: str = None, + units: str = None, + rounding: int = 3, + is_input: bool = True, + is_output: bool = True, + input_category: str = "main", + output_category: str = "main", + ) -> object: + """Annotate a variable + + Args: + block: Pyomo block being annotated + key: Key for this block in dict. Defaults to object name. + title: Name / title of the block. Defaults to object name. + desc: Description of the block. Defaults to object name. + units: Units. Defaults to string value of native units. + rounding: Significant digits + is_input: Is this variable an input + is_output: Is this variable an output + input_category: Name of input grouping to display under + output_category: Name of output grouping to display under + + Returns: + Input block (for chaining) + + Raises: + ValueError: if `is_input` and `is_output` are both False + """ + if not is_input and not is_output: + raise ValueError("One of 'is_input', 'is_output' must be True") + + qual_name = block.name + key = key or block.name + + self._ann[key] = { + "block": block, + "fullname": qual_name, + "title": title or qual_name, + "description": desc or qual_name, + "units": units or str(pyunits.get_units(block)), "rounding": rounding, "is_input": is_input, "is_output": is_output, "input_category": input_category, "output_category": output_category, } - self._marks[obj].update(kwargs) - def marks_to_table(self) -> list[list[str]]: - """Translate 'marks' to a table suitable for loading up the UI (Flowsheet Processor). + return block - Returns: - list of rows, each row is a list of strings. First row is the header: - `name,obj,description,ui_units,display_units,rounding,is_input,input_category,is_output,output_category` - """ - hdr_items = [ - "name", - "obj", - "description", - "ui_units", - "display_units", - "rounding", - "is_input", - "input_category", - "is_output", - "output_category", - ] - # add header row - tbl = [hdr_items] - # add one row for each marked variable - for m in self._marks: - if "display_units" not in m: - m["display_units"] = m["units"] - row = [m["title"], m["fullname"], m["description"], m["units"]] - for key in hdr_items[4:]: - row.append(m[key]) - tbl.append(row) - # return table - return tbl + @property + def annotations(self): + return self._ann.copy() class FlowsheetRunner(BaseFlowsheetRunner): diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index 61a470b408..f55d5d3a0a 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -47,6 +47,7 @@ def build_model(ctx): m.fs.flash = Flash(property_package=m.fs.properties) # assert degrees_of_freedom(m) == 7 ctx.model = m + print("@@ built flash") @FS.step("set_operating_conditions") diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index afff82cddc..5c25d20df2 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -11,7 +11,9 @@ # for full copyright and license information. ############################################################################### import pytest +from pyomo.environ import value from ..fsrunner import FlowsheetRunner +from .flash_flowsheet import FS as flash_fs # -- setup -- @@ -72,3 +74,43 @@ def test_rerun(): # running from build also creates new model fsr.run_steps("build", "add_costing") assert fsr.model != second_model + + +@pytest.mark.unit +def test_annotation(): + runner = flash_fs + runner.run_steps(last="build") + print(runner.timings.history) + + a_ = runner.annotate # alias + flash = runner.model.fs.flash # alias + category = "flash" + kw = {"input_category": category, "output_category": category} + + a_( + flash.inlet.flow_mol, + key="fs.flash.inlet.flow_mol", + title="Inlet molar flow", + desc="Flash inlet molar flow rate", + **kw, + ).fix(1) + a_(flash.inlet.temperature, units="Centipedes", **kw).fix(368) + a_(flash.inlet.pressure, **kw).fix(101325) + a_(flash.inlet.mole_frac_comp[0, "benzene"], **kw).fix(0.5) + a_(flash.inlet.mole_frac_comp[0, "toluene"], **kw).fix(0.5) + a_(flash.heat_duty, **kw).fix(0) + a_(flash.deltaP, is_input=False, **kw).fix(0) + + ann = runner.annotations + print("-" * 40) + print(ann) + print("-" * 40) + assert ann["fs.flash.inlet.flow_mol"]["title"] == "Inlet molar flow" + assert ( + ann["fs.flash.inlet.flow_mol"]["description"] == "Flash inlet molar flow rate" + ) + assert ann["fs.flash.inlet.flow_mol"]["input_category"] == category + assert ann["fs.flash.inlet.flow_mol"]["output_category"] == category + assert runner.model.fs.flash.inlet.flow_mol[0].value == 1 + assert ann["fs.flash._temperature_inlet_ref"]["units"] == "Centipedes" + assert ann["fs.flash.deltaP"]["is_input"] == False From b7fc99ce1df564a56b27450db902af9714e281c2 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 18 Dec 2025 13:42:30 -0800 Subject: [PATCH 23/73] save work yay --- docs/examples/index.rst | 2 +- .../structfs/flash_flowsheet_nb.ipynb | 83 +- docs/examples/structfs/hda_flowsheet_nb.ipynb | 564 +++---- docs/examples/structfs/hda_ideal_VLE.py | 1341 +++++++++++++++++ docs/examples/structfs/hda_reaction.py | 182 +++ docs/examples/structfs/index.rst | 8 + docs/how_to_guides/index.rst | 3 +- docs/index.rst | 1 + 8 files changed, 1845 insertions(+), 339 deletions(-) create mode 100644 docs/examples/structfs/hda_ideal_VLE.py create mode 100644 docs/examples/structfs/hda_reaction.py create mode 100644 docs/examples/structfs/index.rst diff --git a/docs/examples/index.rst b/docs/examples/index.rst index 2dda3b6f29..1a650f0eeb 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -4,4 +4,4 @@ Examples .. toctree:: :maxdepth: 1 - structfs/flash_flowsheet_nb \ No newline at end of file + structfs/index diff --git a/docs/examples/structfs/flash_flowsheet_nb.ipynb b/docs/examples/structfs/flash_flowsheet_nb.ipynb index 07ad309dbb..3162dbcc0c 100644 --- a/docs/examples/structfs/flash_flowsheet_nb.ipynb +++ b/docs/examples/structfs/flash_flowsheet_nb.ipynb @@ -23,22 +23,13 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "854989ae", "metadata": {}, "outputs": [], "source": [ - "# general-purpose imports\n", - "from pprint import pprint\n", - "\n", "# Import the 'FS' structured flowsheet wrapper\n", - "from flash_flowsheet import FS\n", - "\n", - "# Imports for the actions\n", - "from idaes.core.util.structfs.runner_actions import Timer, UnitDofChecker\n", - "\n", - "# log-related\n", - "from idaes.core.util.structfs.logutil import quiet, unquiet" + "from flash_flowsheet import FS" ] }, { @@ -59,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "4f58d1b7", "metadata": {}, "outputs": [ @@ -67,43 +58,37 @@ "name": "stdout", "output_type": "stream", "text": [ + "Run steps:\n", "build\n", "set_operating_conditions\n", "initialize\n", "set_solver\n", "solve_initial\n", "solve_optimization\n", - "========================================\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", - "2025-11-23 08:09:26 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" + "========================================\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 1 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 2 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 3 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 4 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: Initialization Step 5 optimal - Optimal Solution Found.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_out: State Released.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume: Initialization Complete\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash.control_volume.properties_in: State Released.\n", + "2025-12-06 05:31:08 [INFO] idaes.init.fs.flash: Initialization Complete: optimal - Optimal Solution Found\n" ] } ], "source": [ "# solve the square problem (up to 'solve_initial')\n", + "print(\"Run steps:\")\n", "print(\"\\n\".join(FS.list_steps()))\n", "print(\"=\" * 40)\n", - "quiet()\n", - "FS.run_steps(last=\"solve_initial\")\n", - "unquiet()" + "FS.run_steps(last=\"solve_initial\")" ] }, { @@ -138,7 +123,7 @@ " Termination condition: optimal\n", " Id: 0\n", " Error rc: 0\n", - " Time: 0.005190134048461914\n", + " Time: 0.005049467086791992\n", "Solution: \n", "- number of solutions: 0\n", " number of solutions displayed: 0\n", @@ -170,13 +155,13 @@ "text": [ "Time per step:\n", "\n", - " build : 0.027 15.9%\n", - " set_operating_conditions : 0.000 0.2%\n", - " initialize : 0.126 74.3%\n", + " build : 0.022 14.5%\n", + " set_operating_conditions : 0.000 0.1%\n", + " initialize : 0.111 74.7%\n", " set_solver : 0.000 0.0%\n", - " solve_initial : 0.016 9.5%\n", + " solve_initial : 0.016 10.7%\n", "\n", - "Total time: 0.172 s\n", + "Total time: 0.151 s\n", "\n" ] } @@ -251,13 +236,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Before: 368\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Before: 368\n", "After : 368.85306111169916\n" ] } @@ -318,12 +297,6 @@ "source": [ "## Done" ] - }, - { - "cell_type": "markdown", - "id": "ec75111f", - "metadata": {}, - "source": [] } ], "metadata": { diff --git a/docs/examples/structfs/hda_flowsheet_nb.ipynb b/docs/examples/structfs/hda_flowsheet_nb.ipynb index 796e61d060..65c822a4b8 100644 --- a/docs/examples/structfs/hda_flowsheet_nb.ipynb +++ b/docs/examples/structfs/hda_flowsheet_nb.ipynb @@ -1,5 +1,13 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "e26e86fc", + "metadata": {}, + "source": [ + "# Running the HDA flowsheet" + ] + }, { "cell_type": "code", "execution_count": 1, @@ -13,215 +21,293 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "0bfdd163", "metadata": {}, "outputs": [ { - "ename": "TypeError", - "evalue": "BaseFlowsheetRunner.run_steps() got an unexpected keyword argument 'before'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[3], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[43mFS\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mrun_steps\u001b[49m\u001b[43m(\u001b[49m\u001b[43mbefore\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43msolve_optimization\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", - "\u001b[0;31mTypeError\u001b[0m: BaseFlowsheetRunner.run_steps() got an unexpected keyword argument 'before'" + "name": "stdout", + "output_type": "stream", + "text": [ + "2025-12-18 09:01:51 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:51 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:51 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "WARNING: Error converting Ipopt log entry to float: could not convert string\n", + "to float: '0.00e+00S'\n", + " 59r 0.0000000e+00 2.06e+04 2.95e+09 -1.5 1.69e-01 6.9 5.41e-04\n", + " 0.00e+00S 9\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", + "WARNING: Wegstein failed to converge in 3 iterations\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", + "2025-12-18 09:01:53 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n" ] } ], "source": [ + "# run up to, but not including, optimization\n", "FS.run_steps(before=\"solve_optimization\")" ] }, { "cell_type": "code", "execution_count": 3, - "id": "0a96da4a", + "id": "85da5b80", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "operating cost = $ 419122.3387677973\n", - "\n", - "====================================================================================\n", - "Unit : fs.F102 Time: 0.0\n", - "------------------------------------------------------------------------------------\n", - " Unit Performance\n", - "\n", - " Variables: \n", + "Degrees of freedom: 2\n", "\n", - " Key : Value : Units : Fixed : Bounds\n", - " Heat Duty : 7352.5 : watt : False : (None, None)\n", - " Pressure Change : -2.0000e+05 : pascal : True : (None, None)\n", - "\n", - "------------------------------------------------------------------------------------\n", - " Stream Table\n", - " Units Inlet Vapor Outlet Liquid Outlet\n", - " flow_mol_phase_comp ('Liq', 'benzene') mole / second 0.20460 1.0000e-08 0.062620 \n", - " flow_mol_phase_comp ('Liq', 'toluene') mole / second 0.062520 1.0000e-08 0.032257 \n", - " flow_mol_phase_comp ('Liq', 'methane') mole / second 2.6712e-07 1.0000e-08 9.4877e-08 \n", - " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 2.6712e-07 1.0000e-08 9.4877e-08 \n", - " flow_mol_phase_comp ('Vap', 'benzene') mole / second 1.0000e-08 0.14198 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'toluene') mole / second 1.0000e-08 0.030264 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.0000e-08 1.8224e-07 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 1.0000e-08 1.8224e-07 1.0000e-08 \n", - " temperature kelvin 325.00 375.00 375.00 \n", - " pressure pascal 3.5000e+05 1.5000e+05 1.5000e+05 \n", - "====================================================================================\n", - "\n", - "benzene purity = 0.8242962943918926\n", - " Units Reactor Light Gases\n", - "flow_mol_phase_comp ('Liq', 'benzene') mole / second 1.2993e-07 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'toluene') mole / second 8.4147e-07 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 \n", - "flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.35374 0.14915 \n", - "flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.078129 0.015610 \n", - "flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2721 1.2721 \n", - "flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.32821 0.32821 \n", - "temperature kelvin 771.85 325.00 \n", - "pressure pascal 3.5000e+05 3.5000e+05 \n" + "Degrees of freedom after steps:\n", + " build:\n", + " fs : 29\n", + " fs.C101 : 1\n", + " fs.C101.control_volume : 12\n", + " fs.F101 : 2\n", + " fs.F101.control_volume : 12\n", + " fs.F101.split : 0\n", + " fs.F102 : 2\n", + " fs.F102.control_volume : 12\n", + " fs.F102.split : 0\n", + " fs.H101 : 1\n", + " fs.H101.control_volume : 11\n", + " fs.M101 : 20\n", + " fs.R101 : 2\n", + " fs.R101.control_volume : 12\n", + " fs.S101 : -9\n", + " fs.reaction_params : 0\n", + " fs.thermo_params : 0\n", + " fs.thermo_params.Liq : 0\n", + " fs.thermo_params.Vap : 0\n", + " fs.thermo_params.benzene : 0\n", + " fs.thermo_params.hydrogen : 0\n", + " fs.thermo_params.methane : 0\n", + " fs.thermo_params.toluene : 0\n", + " solve_initial:\n", + " fs : 0\n", + " fs.C101 : 0\n", + " fs.C101.control_volume : 11\n", + " fs.F101 : 0\n", + " fs.F101.control_volume : 10\n", + " fs.F101.split : 0\n", + " fs.F102 : 0\n", + " fs.F102.control_volume : 10\n", + " fs.F102.split : 0\n", + " fs.H101 : 0\n", + " fs.H101.control_volume : 10\n", + " fs.M101 : 0\n", + " fs.R101 : 0\n", + " fs.R101.control_volume : 11\n", + " fs.S101 : -10\n", + " fs.reaction_params : 0\n", + " fs.thermo_params : 0\n", + " fs.thermo_params.Liq : 0\n", + " fs.thermo_params.Vap : 0\n", + " fs.thermo_params.benzene : 0\n", + " fs.thermo_params.hydrogen : 0\n", + " fs.thermo_params.methane : 0\n", + " fs.thermo_params.toluene : 0\n" ] } ], "source": [ - "m = FS.model\n", - "\n", - "# What is the total operating cost?\n", - "print(\"operating cost = $\", value(m.fs.operating_cost))\n", - "\n", - "# For this operating cost, what is the amount of benzene we are able to produce and what purity we are able to achieve?\n", - "m.fs.F102.report()\n", - "print()\n", - "print(\"benzene purity = \", value(m.fs.purity))\n", - "\n", - "\n", - "# How much benzene are we losing in the F101 vapor outlet stream?\n", - "from idaes.core.util.tables import (\n", - " create_stream_table_dataframe,\n", - " stream_table_dataframe_to_string,\n", - ")\n", - "st = create_stream_table_dataframe({\"Reactor\": m.fs.s05, \"Light Gases\": m.fs.s06})\n", - "print(stream_table_dataframe_to_string(st))" + "# print degrees of freedom\n", + "FS.dof" ] }, { "cell_type": "code", "execution_count": 4, - "id": "8b4dd126", + "id": "b7082ff1", + "metadata": {}, + "outputs": [], + "source": [ + "# run rest of steps\n", + "FS.run_steps(first=\"solve_optimization\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "008f9ccc", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:06 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "WARNING: Error converting Ipopt log entry to float: could not convert string\n", - "to float: '0.00e+00S'\n", - " 59r 0.0000000e+00 2.06e+04 2.95e+09 -1.5 1.69e-01 6.9 5.41e-04\n", - " 0.00e+00S 9\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:07 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "WARNING: Wegstein failed to converge in 3 iterations\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", - "2025-11-23 11:56:08 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n" + "Degrees of freedom: 5\n", + "\n", + "Degrees of freedom after steps:\n", + " build:\n", + " fs : 29\n", + " fs.C101 : 1\n", + " fs.C101.control_volume : 12\n", + " fs.F101 : 2\n", + " fs.F101.control_volume : 12\n", + " fs.F101.split : 0\n", + " fs.F102 : 2\n", + " fs.F102.control_volume : 12\n", + " fs.F102.split : 0\n", + " fs.H101 : 1\n", + " fs.H101.control_volume : 11\n", + " fs.M101 : 20\n", + " fs.R101 : 2\n", + " fs.R101.control_volume : 12\n", + " fs.S101 : -9\n", + " fs.reaction_params : 0\n", + " fs.thermo_params : 0\n", + " fs.thermo_params.Liq : 0\n", + " fs.thermo_params.Vap : 0\n", + " fs.thermo_params.benzene : 0\n", + " fs.thermo_params.hydrogen : 0\n", + " fs.thermo_params.methane : 0\n", + " fs.thermo_params.toluene : 0\n", + " solve_initial:\n", + " fs : 0\n", + " fs.C101 : 0\n", + " fs.C101.control_volume : 11\n", + " fs.F101 : 0\n", + " fs.F101.control_volume : 10\n", + " fs.F101.split : 0\n", + " fs.F102 : 0\n", + " fs.F102.control_volume : 10\n", + " fs.F102.split : 0\n", + " fs.H101 : 0\n", + " fs.H101.control_volume : 10\n", + " fs.M101 : 0\n", + " fs.R101 : 0\n", + " fs.R101.control_volume : 11\n", + " fs.S101 : -10\n", + " fs.reaction_params : 0\n", + " fs.thermo_params : 0\n", + " fs.thermo_params.Liq : 0\n", + " fs.thermo_params.Vap : 0\n", + " fs.thermo_params.benzene : 0\n", + " fs.thermo_params.hydrogen : 0\n", + " fs.thermo_params.methane : 0\n", + " fs.thermo_params.toluene : 0\n", + " solve_optimization:\n", + " fs : 5\n", + " fs.C101 : 0\n", + " fs.C101.control_volume : 11\n", + " fs.F101 : 1\n", + " fs.F101.control_volume : 11\n", + " fs.F101.split : 0\n", + " fs.F102 : 2\n", + " fs.F102.control_volume : 12\n", + " fs.F102.split : 0\n", + " fs.H101 : 1\n", + " fs.H101.control_volume : 11\n", + " fs.M101 : 0\n", + " fs.R101 : 1\n", + " fs.R101.control_volume : 12\n", + " fs.S101 : -10\n", + " fs.reaction_params : 0\n", + " fs.thermo_params : 0\n", + " fs.thermo_params.Liq : 0\n", + " fs.thermo_params.Vap : 0\n", + " fs.thermo_params.benzene : 0\n", + " fs.thermo_params.hydrogen : 0\n", + " fs.thermo_params.methane : 0\n", + " fs.thermo_params.toluene : 0\n" ] } ], "source": [ - "FS.run_steps(last=\"solve_optimization\")" + "# print degrees of freedom again\n", + "FS.dof" ] }, { "cell_type": "code", - "execution_count": 6, - "id": "a1dc1860", + "execution_count": 8, + "id": "0a96da4a", "metadata": {}, "outputs": [ { @@ -230,8 +316,6 @@ "text": [ "operating cost = $ 312786.3383406732\n", "\n", - "Product flow rate and purity in F102\n", - "\n", "====================================================================================\n", "Unit : fs.F102 Time: 0.0\n", "------------------------------------------------------------------------------------\n", @@ -259,96 +343,52 @@ "====================================================================================\n", "\n", "benzene purity = 0.818827657811582\n", - "\n", - "Overhead loss in F101\n", - "\n", - "====================================================================================\n", - "Unit : fs.F101 Time: 0.0\n", - "------------------------------------------------------------------------------------\n", - " Unit Performance\n", - "\n", - " Variables: \n", - "\n", - " Key : Value : Units : Fixed : Bounds\n", - " Heat Duty : -56353. : watt : False : (None, None)\n", - " Pressure Change : 0.0000 : pascal : True : (None, None)\n", - "\n", - "------------------------------------------------------------------------------------\n", - " Stream Table\n", - " Units Inlet Vapor Outlet Liquid Outlet\n", - " flow_mol_phase_comp ('Liq', 'benzene') mole / second 4.3534e-08 1.0000e-08 0.21743 \n", - " flow_mol_phase_comp ('Liq', 'toluene') mole / second 7.5866e-07 1.0000e-08 0.070695 \n", - " flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 2.8812e-07 \n", - " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 2.8812e-07 \n", - " flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.27178 0.054356 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.076085 0.0053908 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2414 1.2414 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.35887 0.35887 1.0000e-08 \n", - " temperature kelvin 696.11 301.88 301.88 \n", - " pressure pascal 3.5000e+05 3.5000e+05 3.5000e+05 \n", - "====================================================================================\n", - "Optimal Values\n", - "\n", - "H101 outlet temperature = 500.0 K\n", - "\n", - "R101 outlet temperature = 696.1117584980856 K\n", - "\n", - "F101 outlet temperature = 301.8784760572362 K\n", - "\n", - "F102 outlet temperature = 362.93476830509684 K\n", - "F102 outlet pressure = 105000.0 Pa\n" + " Units Reactor Light Gases\n", + "flow_mol_phase_comp ('Liq', 'benzene') mole / second 4.3534e-08 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'toluene') mole / second 7.5866e-07 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 \n", + "flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 \n", + "flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.27178 0.054356 \n", + "flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.076085 0.0053908 \n", + "flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2414 1.2414 \n", + "flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.35887 0.35887 \n", + "temperature kelvin 696.11 301.88 \n", + "pressure pascal 3.5000e+05 3.5000e+05 \n" ] } ], "source": [ - "m = FS.model\n", - "results = FS.results\n", + "# examine results\n", "\n", - "assert results.solver.termination_condition == TerminationCondition.optimal\n", + "m = FS.model\n", "\n", + "# What is the total operating cost?\n", "print(\"operating cost = $\", value(m.fs.operating_cost))\n", "\n", - "print()\n", - "print(\"Product flow rate and purity in F102\")\n", - "\n", + "# For this operating cost, what is the amount of benzene we are able to produce and what purity we are able to achieve?\n", "m.fs.F102.report()\n", - "\n", "print()\n", "print(\"benzene purity = \", value(m.fs.purity))\n", "\n", - "print()\n", - "print(\"Overhead loss in F101\")\n", - "m.fs.F101.report()\n", - "\n", - "\n", - "# assert value(m.fs.operating_cost) == pytest.approx(312786.338, abs=1e-3)\n", - "# assert value(m.fs.purity) == pytest.approx(0.818827, abs=1e-3)\n", - "\n", - "print(\"Optimal Values\")\n", - "print()\n", - "\n", - "print(\"H101 outlet temperature = \", value(m.fs.H101.outlet.temperature[0]), \"K\")\n", "\n", - "print()\n", - "print(\"R101 outlet temperature = \", value(m.fs.R101.outlet.temperature[0]), \"K\")\n", - "\n", - "print()\n", - "print(\"F101 outlet temperature = \", value(m.fs.F101.vap_outlet.temperature[0]), \"K\")\n", - "\n", - "print()\n", - "print(\"F102 outlet temperature = \", value(m.fs.F102.vap_outlet.temperature[0]), \"K\")\n", - "print(\"F102 outlet pressure = \", value(m.fs.F102.vap_outlet.pressure[0]), \"Pa\")\n", - "\n", - "# assert value(m.fs.H101.outlet.temperature[0]) == pytest.approx(500, abs=1e-3)\n", - "# assert value(m.fs.R101.outlet.temperature[0]) == pytest.approx(696.112, abs=1e-3)\n", - "# assert value(m.fs.F101.vap_outlet.temperature[0]) == pytest.approx(301.878, abs=1e-3)\n", - "# assert value(m.fs.F102.vap_outlet.temperature[0]) == pytest.approx(362.935, abs=1e-3)\n", - "# assert value(m.fs.F102.vap_outlet.pressure[0]) == pytest.approx(105000, abs=1e-2)" + "# How much benzene are we losing in the F101 vapor outlet stream?\n", + "from idaes.core.util.tables import (\n", + " create_stream_table_dataframe,\n", + " stream_table_dataframe_to_string,\n", + ")\n", + "st = create_stream_table_dataframe({\"Reactor\": m.fs.s05, \"Light Gases\": m.fs.s06})\n", + "print(stream_table_dataframe_to_string(st))" ] }, + { + "cell_type": "markdown", + "id": "ca0cf4d4", + "metadata": {}, + "source": [] + }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 9, "id": "e3ec5e4b", "metadata": {}, "outputs": [ @@ -378,7 +418,7 @@ "" ] }, - "execution_count": 7, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -387,46 +427,6 @@ "FS.show_diagram()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "e0262031", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Degrees of freedom: 5\n", - "\n", - "fs : 5\n", - "fs.C101 : 0\n", - "fs.C101.control_volume : 11\n", - "fs.F101 : 1\n", - "fs.F101.control_volume : 11\n", - "fs.F101.split : 0\n", - "fs.F102 : 2\n", - "fs.F102.control_volume : 12\n", - "fs.F102.split : 0\n", - "fs.H101 : 1\n", - "fs.H101.control_volume : 11\n", - "fs.M101 : 0\n", - "fs.R101 : 1\n", - "fs.R101.control_volume : 12\n", - "fs.S101 : -10\n", - "fs.reaction_params : 0\n", - "fs.thermo_params : 0\n", - "fs.thermo_params.Liq : 0\n", - "fs.thermo_params.Vap : 0\n", - "fs.thermo_params.benzene : 0\n", - "fs.thermo_params.hydrogen : 0\n", - "fs.thermo_params.methane : 0\n", - "fs.thermo_params.toluene : 0\n" - ] - } - ], - "source": [] - }, { "cell_type": "code", "execution_count": null, @@ -438,7 +438,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "idaes-py3.12", "language": "python", "name": "python3" }, diff --git a/docs/examples/structfs/hda_ideal_VLE.py b/docs/examples/structfs/hda_ideal_VLE.py new file mode 100644 index 0000000000..c6295a1c94 --- /dev/null +++ b/docs/examples/structfs/hda_ideal_VLE.py @@ -0,0 +1,1341 @@ +############################################################################## +# Institute for the Design of Advanced Energy Systems Process Systems +# Engineering Framework (IDAES PSE Framework) Copyright (c) 2018-2019, by the +# software owners: The Regents of the University of California, through +# Lawrence Berkeley National Laboratory, National Technology & Engineering +# Solutions of Sandia, LLC, Carnegie Mellon University, West Virginia +# University Research Corporation, et al. All rights reserved. +# +# Please see the files COPYRIGHT.txt and LICENSE.txt for full copyright and +# license information, respectively. Both files are also available online +# at the URL "https://github.com/IDAES/idaes-pse". +############################################################################## +""" +Example ideal parameter block for the VLE calculations for a +Benzene-Toluene-o-Xylene system. +""" + + +# Import Pyomo libraries +from pyomo.environ import ( + Constraint, + Expression, + log, + NonNegativeReals, + Var, + Set, + Param, + sqrt, + log10, + units as pyunits, +) +from pyomo.util.calc_var_value import calculate_variable_from_constraint +from pyomo.common.config import ConfigValue + +# Import IDAES cores +from idaes.core import ( + declare_process_block_class, + MaterialFlowBasis, + PhysicalParameterBlock, + StateBlockData, + StateBlock, + MaterialBalanceType, + EnergyBalanceType, + Component, + LiquidPhase, + VaporPhase, +) +from idaes.core.util.constants import Constants as const +from idaes.core.util.initialization import fix_state_vars, solve_indexed_blocks +from idaes.core.initialization import InitializerBase +from idaes.core.util.misc import add_object_reference +from idaes.core.util.model_statistics import number_unfixed_variables +from idaes.core.util.misc import extract_data +from idaes.core.solvers import get_solver +import idaes.core.util.scaling as iscale +import idaes.logger as idaeslog + +# Set up logger +_log = idaeslog.getLogger(__name__) + + +class HDAInitializer(InitializerBase): + """ + Initializer for HDA Property package. + + """ + + CONFIG = InitializerBase.CONFIG() + CONFIG.declare( + "solver", + ConfigValue(default=None, domain=str, description="Initialization solver"), + ) + CONFIG.declare( + "solver_options", + ConfigValue(default=None, description="Initialization solver options"), + ) + + def initialization_routine(self, blk): + init_log = idaeslog.getInitLogger( + blk.name, self.config.output_level, tag="properties" + ) + solve_log = idaeslog.getSolveLogger( + blk.name, self.config.output_level, tag="properties" + ) + + # Set solver + solver = get_solver(self.config.solver, self.config.solver_options) + + # --------------------------------------------------------------------- + # If present, initialize bubble and dew point calculations + for k in blk.keys(): + if hasattr(blk[k], "eq_temperature_dew"): + calculate_variable_from_constraint( + blk[k].temperature_dew, blk[k].eq_temperature_dew + ) + + if hasattr(blk[k], "eq_pressure_dew"): + calculate_variable_from_constraint( + blk[k].pressure_dew, blk[k].eq_pressure_dew + ) + + init_log.info_high( + "Initialization Step 1 - Dew and bubble points " "calculation completed." + ) + + # --------------------------------------------------------------------- + # If flash, initialize T1 and Teq + for k in blk.keys(): + if blk[k].config.has_phase_equilibrium and not blk[k].config.defined_state: + blk[k]._t1.value = max( + blk[k].temperature.value, blk[k].temperature_bubble.value + ) + blk[k]._teq.value = min(blk[k]._t1.value, blk[k].temperature_dew.value) + + init_log.info_high( + "Initialization Step 2 - Equilibrium temperature " " calculation completed." + ) + + # --------------------------------------------------------------------- + # Initialize flow rates and compositions + free_vars = 0 + for k in blk.keys(): + free_vars += number_unfixed_variables(blk[k]) + if free_vars > 0: + try: + with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: + res = solve_indexed_blocks(solver, [blk], tee=slc.tee) + except: + res = None + else: + res = None + + init_log.info("Initialization Complete") + + return res + + +@declare_process_block_class("HDAParameterBlock") +class HDAParameterData(PhysicalParameterBlock): + CONFIG = PhysicalParameterBlock.CONFIG() + + def build(self): + """ + Callable method for Block construction. + """ + super(HDAParameterData, self).build() + + self._state_block_class = IdealStateBlock + + self.benzene = Component() + self.toluene = Component() + self.methane = Component() + self.hydrogen = Component() + + self.Liq = LiquidPhase() + self.Vap = VaporPhase() + + # List of components in each phase (optional) + self.phase_comp = {"Liq": self.component_list, "Vap": self.component_list} + + # List of phase equilibrium index + self.phase_equilibrium_idx = Set(initialize=[1, 2, 3, 4]) + + self.phase_equilibrium_list = { + 1: ["benzene", ("Vap", "Liq")], + 2: ["toluene", ("Vap", "Liq")], + 3: ["hydrogen", ("Vap", "Liq")], + 4: ["methane", ("Vap", "Liq")], + } + + # Thermodynamic reference state + self.pressure_ref = Param( + mutable=True, default=101325, units=pyunits.Pa, doc="Reference pressure" + ) + self.temperature_ref = Param( + mutable=True, default=298.15, units=pyunits.K, doc="Reference temperature" + ) + + # Source: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + pressure_crit_data = { + "benzene": 48.9e5, + "toluene": 41e5, + "hydrogen": 12.9e5, + "methane": 46e5, + } + + self.pressure_crit = Param( + self.component_list, + within=NonNegativeReals, + mutable=True, + units=pyunits.Pa, + initialize=extract_data(pressure_crit_data), + doc="Critical pressure", + ) + + # Source: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + temperature_crit_data = { + "benzene": 562.2, + "toluene": 591.8, + "hydrogen": 33.0, + "methane": 190.4, + } + + self.temperature_crit = Param( + self.component_list, + within=NonNegativeReals, + mutable=True, + units=pyunits.K, + initialize=extract_data(temperature_crit_data), + doc="Critical temperature", + ) + + # Source: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + mw_comp_data = { + "benzene": 78.1136e-3, + "toluene": 92.1405e-3, + "hydrogen": 2.016e-3, + "methane": 16.043e-3, + } + + self.mw_comp = Param( + self.component_list, + mutable=True, + units=pyunits.kg / pyunits.mol, + initialize=extract_data(mw_comp_data), + doc="molecular weight", + ) + + # Constants for liquid densities + # Source: Perry's Chemical Engineers Handbook + # - Robert H. Perry (Cp_liq) + dens_liq_data = { + ("benzene", "1"): 1.0162, + ("benzene", "2"): 0.2655, + ("benzene", "3"): 562.16, + ("benzene", "4"): 0.28212, + ("toluene", "1"): 0.8488, + ("toluene", "2"): 0.26655, + ("toluene", "3"): 591.8, + ("toluene", "4"): 0.2878, + ("hydrogen", "1"): 5.414, + ("hydrogen", "2"): 0.34893, + ("hydrogen", "3"): 33.19, + ("hydrogen", "4"): 0.2706, + ("methane", "1"): 2.9214, + ("methane", "2"): 0.28976, + ("methane", "3"): 190.56, + ("methane", "4"): 0.28881, + } + + self.dens_liq_param_1 = Param( + self.component_list, + mutable=True, + initialize={c: v for (c, j), v in dens_liq_data.items() if j == "1"}, + doc="Parameter 1 to compute liquid densities", + units=pyunits.kmol * pyunits.m**-3, + ) + + self.dens_liq_param_2 = Param( + self.component_list, + mutable=True, + initialize={c: v for (c, j), v in dens_liq_data.items() if j == "2"}, + doc="Parameter 2 to compute liquid densities", + units=pyunits.dimensionless, + ) + + self.dens_liq_param_3 = Param( + self.component_list, + mutable=True, + initialize={c: v for (c, j), v in dens_liq_data.items() if j == "3"}, + doc="Parameter 3 to compute liquid densities", + units=pyunits.K, + ) + + self.dens_liq_param_4 = Param( + self.component_list, + mutable=True, + initialize={c: v for (c, j), v in dens_liq_data.items() if j == "4"}, + doc="Parameter 4 to compute liquid densities", + units=pyunits.dimensionless, + ) + + # Boiling point at standard pressure + # Source: Perry's Chemical Engineers Handbook + # - Robert H. Perry (Cp_liq) + bp_data = { + ("benzene"): 353.25, + ("toluene"): 383.95, + ("hydrogen"): 20.45, + ("methane"): 111.75, + } + + self.temperature_boil = Param( + self.component_list, + mutable=True, + units=pyunits.K, + initialize=extract_data(bp_data), + doc="Pure component boiling points at standard pressure", + ) + + # Constants for specific heat capacity, enthalpy + # Sources: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + # Perry's Chemical Engineers Handbook + # - Robert H. Perry (Cp_liq) + cp_ig_data = { + ("Liq", "benzene", "1"): 1.29e5, + ("Liq", "benzene", "2"): -1.7e2, + ("Liq", "benzene", "3"): 6.48e-1, + ("Liq", "benzene", "4"): 0, + ("Liq", "benzene", "5"): 0, + ("Vap", "benzene", "1"): -3.392e1, + ("Vap", "benzene", "2"): 4.739e-1, + ("Vap", "benzene", "3"): -3.017e-4, + ("Vap", "benzene", "4"): 7.130e-8, + ("Vap", "benzene", "5"): 0, + ("Liq", "toluene", "1"): 1.40e5, + ("Liq", "toluene", "2"): -1.52e2, + ("Liq", "toluene", "3"): 6.95e-1, + ("Liq", "toluene", "4"): 0, + ("Liq", "toluene", "5"): 0, + ("Vap", "toluene", "1"): -2.435e1, + ("Vap", "toluene", "2"): 5.125e-1, + ("Vap", "toluene", "3"): -2.765e-4, + ("Vap", "toluene", "4"): 4.911e-8, + ("Vap", "toluene", "5"): 0, + ("Liq", "hydrogen", "1"): 0, # 6.6653e1, + ("Liq", "hydrogen", "2"): 0, # 6.7659e3, + ("Liq", "hydrogen", "3"): 0, # -1.2363e2, + ("Liq", "hydrogen", "4"): 0, # 4.7827e2, # Eqn 2 + ("Liq", "hydrogen", "5"): 0, + ("Vap", "hydrogen", "1"): 2.714e1, + ("Vap", "hydrogen", "2"): 9.274e-3, + ("Vap", "hydrogen", "3"): -1.381e-5, + ("Vap", "hydrogen", "4"): 7.645e-9, + ("Vap", "hydrogen", "5"): 0, + ("Liq", "methane", "1"): 0, # 6.5708e1, + ("Liq", "methane", "2"): 0, # 3.8883e4, + ("Liq", "methane", "3"): 0, # -2.5795e2, + ("Liq", "methane", "4"): 0, # 6.1407e2, # Eqn 2 + ("Liq", "methane", "5"): 0, + ("Vap", "methane", "1"): 1.925e1, + ("Vap", "methane", "2"): 5.213e-2, + ("Vap", "methane", "3"): 1.197e-5, + ("Vap", "methane", "4"): -1.132e-8, + ("Vap", "methane", "5"): 0, + } + + self.cp_ig_1 = Param( + self.phase_list, + self.component_list, + mutable=True, + initialize={(p, c): v for (p, c, j), v in cp_ig_data.items() if j == "1"}, + doc="Parameter 1 to compute Cp_comp", + units=pyunits.J / pyunits.mol / pyunits.K, + ) + + self.cp_ig_2 = Param( + self.phase_list, + self.component_list, + mutable=True, + initialize={(p, c): v for (p, c, j), v in cp_ig_data.items() if j == "2"}, + doc="Parameter 2 to compute Cp_comp", + units=pyunits.J / pyunits.mol / pyunits.K**2, + ) + + self.cp_ig_3 = Param( + self.phase_list, + self.component_list, + mutable=True, + initialize={(p, c): v for (p, c, j), v in cp_ig_data.items() if j == "3"}, + doc="Parameter 3 to compute Cp_comp", + units=pyunits.J / pyunits.mol / pyunits.K**3, + ) + + self.cp_ig_4 = Param( + self.phase_list, + self.component_list, + mutable=True, + initialize={(p, c): v for (p, c, j), v in cp_ig_data.items() if j == "4"}, + doc="Parameter 4 to compute Cp_comp", + units=pyunits.J / pyunits.mol / pyunits.K**4, + ) + + self.cp_ig_5 = Param( + self.phase_list, + self.component_list, + mutable=True, + initialize={(p, c): v for (p, c, j), v in cp_ig_data.items() if j == "5"}, + doc="Parameter 5 to compute Cp_comp", + units=pyunits.J / pyunits.mol / pyunits.K**5, + ) + + # Source: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + # fitted to Antoine form + # H2, Methane from NIST webbook + pressure_sat_coeff_data = { + ("benzene", "A"): 4.202, + ("benzene", "B"): 1322, + ("benzene", "C"): -38.56, + ("toluene", "A"): 4.216, + ("toluene", "B"): 1435, + ("toluene", "C"): -43.33, + ("hydrogen", "A"): 3.543, + ("hydrogen", "B"): 99.40, + ("hydrogen", "C"): 7.726, + ("methane", "A"): 3.990, + ("methane", "B"): 443.0, + ("methane", "C"): -0.49, + } + + self.pressure_sat_coeff_A = Param( + self.component_list, + mutable=True, + initialize={ + c: v for (c, j), v in pressure_sat_coeff_data.items() if j == "A" + }, + doc="Parameter A to compute saturated pressure", + units=pyunits.dimensionless, + ) + + self.pressure_sat_coeff_B = Param( + self.component_list, + mutable=True, + initialize={ + c: v for (c, j), v in pressure_sat_coeff_data.items() if j == "B" + }, + doc="Parameter B to compute saturated pressure", + units=pyunits.K, + ) + + self.pressure_sat_coeff_C = Param( + self.component_list, + mutable=True, + initialize={ + c: v for (c, j), v in pressure_sat_coeff_data.items() if j == "C" + }, + doc="Parameter C to compute saturated pressure", + units=pyunits.K, + ) + + # Source: The Properties of Gases and Liquids (1987) + # 4th edition, Chemical Engineering Series - Robert C. Reid + dh_vap = {"benzene": 3.387e4, "toluene": 3.8262e4, "hydrogen": 0, "methane": 0} + + self.dh_vap = Param( + self.component_list, + mutable=True, + units=pyunits.J / pyunits.mol, + initialize=extract_data(dh_vap), + doc="heat of vaporization", + ) + + # Set default scaling factors + self.set_default_scaling("flow_mol", 1e3) + self.set_default_scaling("flow_mol_phase_comp", 1e3) + self.set_default_scaling("flow_mol_phase", 1e3) + self.set_default_scaling("material_flow_terms", 1e3) + self.set_default_scaling("enthalpy_flow_terms", 1e-2) + self.set_default_scaling("mole_frac_comp", 1e1) + self.set_default_scaling("temperature", 1e-2) + self.set_default_scaling("temperature_dew", 1e-2) + self.set_default_scaling("temperature_bubble", 1e-2) + self.set_default_scaling("pressure", 1e-5) + self.set_default_scaling("pressure_sat", 1e-5) + self.set_default_scaling("pressure_dew", 1e-5) + self.set_default_scaling("pressure_bubble", 1e-5) + self.set_default_scaling("mole_frac_phase_comp", 1e1) + self.set_default_scaling("enth_mol_phase", 1e-3, index="Liq") + self.set_default_scaling("enth_mol_phase", 1e-4, index="Vap") + self.set_default_scaling("enth_mol", 1e-3) + self.set_default_scaling("entr_mol_phase", 1e-2) + self.set_default_scaling("entr_mol", 1e-2) + + @classmethod + def define_metadata(cls, obj): + """Define properties supported and units.""" + obj.add_properties( + { + "flow_mol": {"method": None}, + "flow_mol_phase_comp": {"method": None}, + "mole_frac_comp": {"method": None}, + "temperature": {"method": None}, + "pressure": {"method": None}, + "flow_mol_phase": {"method": None}, + "dens_mol_phase": {"method": "_dens_mol_phase"}, + "pressure_sat": {"method": "_pressure_sat"}, + "mole_frac_phase_comp": {"method": "_mole_frac_phase"}, + "energy_internal_mol_phase_comp": { + "method": "_energy_internal_mol_phase_comp" + }, + "energy_internal_mol_phase": {"method": "_energy_internal_mol_phase"}, + "enth_mol_phase_comp": {"method": "_enth_mol_phase_comp"}, + "enth_mol_phase": {"method": "_enth_mol_phase"}, + "entr_mol_phase_comp": {"method": "_entr_mol_phase_comp"}, + "entr_mol_phase": {"method": "_entr_mol_phase"}, + "temperature_bubble": {"method": "_temperature_bubble"}, + "temperature_dew": {"method": "_temperature_dew"}, + "pressure_bubble": {"method": "_pressure_bubble"}, + "pressure_dew": {"method": "_pressure_dew"}, + "fug_phase_comp": {"method": "_fug_phase_comp"}, + } + ) + + obj.define_custom_properties( + { + # Enthalpy of vaporization + "dh_vap": {"method": "_dh_vap", "units": obj.derived_units.ENERGY_MOLE}, + # Entropy of vaporization + "ds_vap": { + "method": "_ds_vap", + "units": obj.derived_units.ENTROPY_MOLE, + }, + } + ) + + obj.add_default_units( + { + "time": pyunits.s, + "length": pyunits.m, + "mass": pyunits.kg, + "amount": pyunits.mol, + "temperature": pyunits.K, + } + ) + + +class _IdealStateBlock(StateBlock): + """ + This Class contains methods which should be applied to Property Blocks as a + whole, rather than individual elements of indexed Property Blocks. + """ + + default_initializer = HDAInitializer + + def fix_initialization_states(blk): + """ + Fixes state variables for state blocks. + + Returns: + None + """ + + # Fix state variables + fix_state_vars(blk) + + # Also need to deactivate sum of mole fraction constraint + for k in blk.values(): + if not k.config.defined_state and k.config.has_phase_equilibrium: + k.equilibrium_constraint.deactivate() + + +@declare_process_block_class("IdealStateBlock", block_class=_IdealStateBlock) +class IdealStateBlockData(StateBlockData): + """An example property package for ideal VLE.""" + + def build(self): + """Callable method for Block construction.""" + super().build() + + # Add state variables + self.flow_mol_phase_comp = Var( + self._params.phase_list, + self._params.component_list, + initialize=0.5, + units=pyunits.mol / pyunits.s, + bounds=(1e-12, 100), + doc="Phase-component molar flow rates", + ) + + self.pressure = Var( + initialize=101325, + bounds=(100000, 1000000), + units=pyunits.Pa, + domain=NonNegativeReals, + doc="State pressure", + ) + self.temperature = Var( + initialize=298.15, + units=pyunits.K, + bounds=(298, 1000), + domain=NonNegativeReals, + doc="State temperature", + ) + + # Add supporting variables + def flow_mol_phase(b, p): + return sum(b.flow_mol_phase_comp[p, j] for j in b._params.component_list) + + self.flow_mol_phase = Expression( + self._params.phase_list, rule=flow_mol_phase, doc="Phase molar flow rates" + ) + + def flow_mol(b): + return sum( + b.flow_mol_phase_comp[p, j] + for j in b._params.component_list + for p in b._params.phase_list + ) + + self.flow_mol = Expression(rule=flow_mol, doc="Total molar flowrate") + + def mole_frac_phase_comp(b, p, j): + return b.flow_mol_phase_comp[p, j] / b.flow_mol_phase[p] + + self.mole_frac_phase_comp = Expression( + self._params.phase_list, + self._params.component_list, + rule=mole_frac_phase_comp, + doc="Phase mole fractions", + ) + + def mole_frac_comp(b, j): + return ( + sum(b.flow_mol_phase_comp[p, j] for p in b._params.phase_list) + / b.flow_mol + ) + + self.mole_frac_comp = Expression( + self._params.component_list, + rule=mole_frac_comp, + doc="Mixture mole fractions", + ) + + # Reaction Stoichiometry + add_object_reference( + self, "phase_equilibrium_list_ref", self._params.phase_equilibrium_list + ) + + if self.config.has_phase_equilibrium and self.config.defined_state is False: + # Definition of equilibrium temperature for smooth VLE + self._teq = Var( + initialize=self.temperature.value, + units=pyunits.K, + doc="Temperature for calculating phase equilibrium", + ) + self._t1 = Var( + initialize=self.temperature.value, + units=pyunits.K, + doc="Intermediate temperature for calculating Teq", + ) + + self.eps_1 = Param( + default=0.01, + units=pyunits.K, + mutable=True, + doc="Smoothing parameter for Teq", + ) + self.eps_2 = Param( + default=0.0005, + units=pyunits.K, + mutable=True, + doc="Smoothing parameter for Teq", + ) + + # PSE paper Eqn 13 + def rule_t1(b): + return b._t1 == 0.5 * ( + b.temperature + + b.temperature_bubble + + sqrt((b.temperature - b.temperature_bubble) ** 2 + b.eps_1**2) + ) + + self._t1_constraint = Constraint(rule=rule_t1) + + # PSE paper Eqn 14 + # TODO : Add option for supercritical extension + def rule_teq(b): + return b._teq == 0.5 * ( + b._t1 + + b.temperature_dew + - sqrt((b._t1 - b.temperature_dew) ** 2 + b.eps_2**2) + ) + + self._teq_constraint = Constraint(rule=rule_teq) + + def rule_tr_eq(b, i): + return b._teq / b._params.temperature_crit[i] + + self._tr_eq = Expression( + self._params.component_list, + rule=rule_tr_eq, + doc="Component reduced temperatures", + ) + + def rule_equilibrium(b, i): + return b.fug_phase_comp["Liq", i] == b.fug_phase_comp["Vap", i] + + self.equilibrium_constraint = Constraint( + self._params.component_list, rule=rule_equilibrium + ) + + # ----------------------------------------------------------------------------- + # Property Methods + def _dens_mol_phase(self): + self.dens_mol_phase = Var( + self._params.phase_list, + initialize=1.0, + units=pyunits.mol * pyunits.m**-3, + doc="Molar density", + ) + + def rule_dens_mol_phase(b, p): + if p == "Vap": + return b._dens_mol_vap() + else: + return b._dens_mol_liq() + + self.eq_dens_mol_phase = Constraint( + self._params.phase_list, rule=rule_dens_mol_phase + ) + + def _energy_internal_mol_phase_comp(self): + self.energy_internal_mol_phase_comp = Var( + self._params.phase_list, + self._params.component_list, + units=pyunits.J / pyunits.mol, + doc="Phase-component molar specific internal energies", + ) + + def rule_energy_internal_mol_phase_comp(b, p, j): + if p == "Vap": + return b.energy_internal_mol_phase_comp[p, j] == b.enth_mol_phase_comp[ + p, j + ] - const.gas_constant * (b.temperature - b._params.temperature_ref) + else: + return ( + b.energy_internal_mol_phase_comp[p, j] + == b.enth_mol_phase_comp[p, j] + ) + + self.eq_energy_internal_mol_phase_comp = Constraint( + self._params.phase_list, + self._params.component_list, + rule=rule_energy_internal_mol_phase_comp, + ) + + def _energy_internal_mol_phase(self): + self.energy_internal_mol_phase = Var( + self._params.phase_list, + units=pyunits.J / pyunits.mol, + doc="Phase molar specific internal energies", + ) + + def rule_energy_internal_mol_phase(b, p): + return b.energy_internal_mol_phase[p] == sum( + b.energy_internal_mol_phase_comp[p, i] * b.mole_frac_phase_comp[p, i] + for i in b._params.component_list + ) + + self.eq_energy_internal_mol_phase = Constraint( + self._params.phase_list, rule=rule_energy_internal_mol_phase + ) + + def _enth_mol_phase_comp(self): + self.enth_mol_phase_comp = Var( + self._params.phase_list, + self._params.component_list, + initialize=7e5, + units=pyunits.J / pyunits.mol, + doc="Phase-component molar specific enthalpies", + ) + + def rule_enth_mol_phase_comp(b, p, j): + if p == "Vap": + return b._enth_mol_comp_vap(j) + else: + return b._enth_mol_comp_liq(j) + + self.eq_enth_mol_phase_comp = Constraint( + self._params.phase_list, + self._params.component_list, + rule=rule_enth_mol_phase_comp, + ) + + def _enth_mol_phase(self): + self.enth_mol_phase = Var( + self._params.phase_list, + initialize=7e5, + units=pyunits.J / pyunits.mol, + doc="Phase molar specific enthalpies", + ) + + def rule_enth_mol_phase(b, p): + return b.enth_mol_phase[p] == sum( + b.enth_mol_phase_comp[p, i] * b.mole_frac_phase_comp[p, i] + for i in b._params.component_list + ) + + self.eq_enth_mol_phase = Constraint( + self._params.phase_list, rule=rule_enth_mol_phase + ) + + def _entr_mol_phase_comp(self): + self.entr_mol_phase_comp = Var( + self._params.phase_list, + self._params.component_list, + units=pyunits.J / pyunits.mol / pyunits.K, + doc="Phase-component molar specific entropies", + ) + + def rule_entr_mol_phase_comp(b, p, j): + if p == "Vap": + return b._entr_mol_comp_vap(j) + else: + return b._entr_mol_comp_liq(j) + + self.eq_entr_mol_phase_comp = Constraint( + self._params.phase_list, + self._params.component_list, + rule=rule_entr_mol_phase_comp, + ) + + def _entr_mol_phase(self): + self.entr_mol_phase = Var( + self._params.phase_list, + units=pyunits.J / pyunits.mol / pyunits.K, + doc="Phase molar specific enthropies", + ) + + def rule_entr_mol_phase(b, p): + return b.entr_mol_phase[p] == sum( + b.entr_mol_phase_comp[p, i] * b.mole_frac_phase_comp[p, i] + for i in b._params.component_list + ) + + self.eq_entr_mol_phase = Constraint( + self._params.phase_list, rule=rule_entr_mol_phase + ) + + # ----------------------------------------------------------------------------- + # General Methods + def get_material_flow_terms(self, p, j): + """Create material flow terms for control volume.""" + if not self.is_property_constructed("material_flow_terms"): + try: + + def rule_material_flow_terms(blk, p, j): + return blk.flow_mol_phase_comp[p, j] + + self.material_flow_terms = Expression( + self.params.phase_list, + self.params.component_list, + rule=rule_material_flow_terms, + ) + except AttributeError: + self.del_component(self.material_flow_terms) + + if j in self.params.component_list: + return self.material_flow_terms[p, j] + else: + return 0 + + def get_enthalpy_flow_terms(self, p): + """Create enthalpy flow terms.""" + if not self.is_property_constructed("enthalpy_flow_terms"): + try: + + def rule_enthalpy_flow_terms(blk, p): + return blk.flow_mol_phase[p] * blk.enth_mol_phase[p] + + self.enthalpy_flow_terms = Expression( + self.params.phase_list, rule=rule_enthalpy_flow_terms + ) + except AttributeError: + self.del_component(self.enthalpy_flow_terms) + return self.enthalpy_flow_terms[p] + + def get_material_density_terms(self, p, j): + """Create material density terms.""" + if not self.is_property_constructed("material_density_terms"): + try: + + def rule_material_density_terms(b, p, j): + return self.dens_mol_phase[p] * self.mole_frac_phase_comp[p, j] + + self.material_density_terms = Expression( + self.params.phase_list, + self.params.component_list, + rule=rule_material_density_terms, + ) + except AttributeError: + self.del_component(self.material_density_terms) + + if j in self.params.component_list: + return self.material_density_terms[p, j] + else: + return 0 + + def get_enthalpy_density_terms(self, p): + """Create energy density terms.""" + if not self.is_property_constructed("enthalpy_density_terms"): + try: + + def rule_energy_density_terms(b, p): + return self.dens_mol_phase[p] * self.energy_internal_mol_phase[p] + + self.energy_density_terms = Expression( + self.params.phase_list, rule=rule_energy_density_terms + ) + except AttributeError: + self.del_component(self.energy_density_terms) + return self.enthalpy_density_terms[p] + + def default_material_balance_type(self): + return MaterialBalanceType.componentPhase + + def default_energy_balance_type(self): + return EnergyBalanceType.enthalpyTotal + + def get_material_flow_basis(b): + return MaterialFlowBasis.molar + + def define_state_vars(self): + """Define state vars.""" + return { + "flow_mol_phase_comp": self.flow_mol_phase_comp, + "temperature": self.temperature, + "pressure": self.pressure, + } + + # Property package utility functions + def calculate_bubble_point_temperature(self, clear_components=True): + """ "To compute the bubble point temperature of the mixture.""" + + if hasattr(self, "eq_temperature_bubble"): + # Do not delete components if the block already has the components + clear_components = False + + calculate_variable_from_constraint( + self.temperature_bubble, self.eq_temperature_bubble + ) + + return self.temperature_bubble.value + + if clear_components is True: + self.del_component(self.eq_temperature_bubble) + self.del_component(self._p_sat_bubbleT) + self.del_component(self.temperature_bubble) + + def calculate_dew_point_temperature(self, clear_components=True): + """ "To compute the dew point temperature of the mixture.""" + + if hasattr(self, "eq_temperature_dew"): + # Do not delete components if the block already has the components + clear_components = False + + calculate_variable_from_constraint( + self.temperature_dew, self.eq_temperature_dew + ) + + return self.temperature_dew.value + + # Delete the var/constraint created in this method that are part of the + # IdealStateBlock if the user desires + if clear_components is True: + self.del_component(self.eq_temperature_dew) + self.del_component(self._p_sat_dewT) + self.del_component(self.temperature_dew) + + def calculate_bubble_point_pressure(self, clear_components=True): + """ "To compute the bubble point pressure of the mixture.""" + + if hasattr(self, "eq_pressure_bubble"): + # Do not delete components if the block already has the components + clear_components = False + + calculate_variable_from_constraint( + self.pressure_bubble, self.eq_pressure_bubble + ) + + return self.pressure_bubble.value + + # Delete the var/constraint created in this method that are part of the + # IdealStateBlock if the user desires + if clear_components is True: + self.del_component(self.eq_pressure_bubble) + self.del_component(self._p_sat_bubbleP) + self.del_component(self.pressure_bubble) + + def calculate_dew_point_pressure(self, clear_components=True): + """ "To compute the dew point pressure of the mixture.""" + + if hasattr(self, "eq_pressure_dew"): + # Do not delete components if the block already has the components + clear_components = False + + calculate_variable_from_constraint(self.pressure_dew, self.eq_pressure_dew) + + return self.pressure_dew.value + + # Delete the var/constraint created in this method that are part of the + # IdealStateBlock if the user desires + if clear_components is True: + self.del_component(self.eq_pressure_dew) + self.del_component(self._p_sat_dewP) + self.del_component(self.pressure_dew) + + # ----------------------------------------------------------------------------- + # Bubble and Dew Points + # Ideal-Ideal properties allow for the simplifications below + # Other methods require more complex equations with shadow compositions + + # For future work, propose the following: + # Core class writes a set of constraints Phi_L_i == Phi_V_i + # Phi_L_i and Phi_V_i make calls to submethods which add shadow compositions + # as needed + def _temperature_bubble(self): + self.temperature_bubble = Param( + initialize=33.0, units=pyunits.K, doc="Bubble point temperature" + ) + + def _temperature_dew(self): + + self.temperature_dew = Var( + initialize=298.15, units=pyunits.K, doc="Dew point temperature" + ) + + def rule_psat_dew(b, j): + return ( + 1e5 + * pyunits.Pa + * 10 + ** ( + b._params.pressure_sat_coeff_A[j] + - b._params.pressure_sat_coeff_B[j] + / (b.temperature_dew + b._params.pressure_sat_coeff_C[j]) + ) + ) + + try: + # Try to build expression + self._p_sat_dewT = Expression( + self._params.component_list, rule=rule_psat_dew + ) + + def rule_temp_dew(b): + return ( + b.pressure + * sum( + b.mole_frac_comp[i] / b._p_sat_dewT[i] + for i in ["toluene", "benzene"] + ) + - 1 + == 0 + ) + + self.eq_temperature_dew = Constraint(rule=rule_temp_dew) + except AttributeError: + # If expression fails, clean up so that DAE can try again later + # Deleting only var/expression as expression construction will fail + # first; if it passes then constraint construction will not fail. + self.del_component(self.temperature_dew) + self.del_component(self._p_sat_dewT) + + def _pressure_bubble(self): + self.pressure_bubble = Param( + initialize=1e8, units=pyunits.Pa, doc="Bubble point pressure" + ) + + def _pressure_dew(self): + self.pressure_dew = Var( + initialize=298.15, units=pyunits.Pa, doc="Dew point pressure" + ) + + def rule_psat_dew(b, j): + return ( + 1e5 + * pyunits.Pa + * 10 + ** ( + b._params.pressure_sat_coeff_A[j] + - b._params.pressure_sat_coeff_B[j] + / (b.temperature + b._params.pressure_sat_coeff_C[j]) + ) + ) + + try: + # Try to build expression + self._p_sat_dewP = Expression( + self._params.component_list, rule=rule_psat_dew + ) + + def rule_pressure_dew(b): + return ( + b.pressure_dew + * sum( + b.mole_frac_comp[i] / b._p_sat_dewP[i] + for i in ["toluene", "benzene"] + ) + - 1 + == 0 + ) + + self.eq_pressure_dew = Constraint(rule=rule_pressure_dew) + except AttributeError: + # If expression fails, clean up so that DAE can try again later + # Deleting only var/expression as expression construction will fail + # first; if it passes then constraint construction will not fail. + self.del_component(self.pressure_dew) + self.del_component(self._p_sat_dewP) + + # ----------------------------------------------------------------------------- + # Liquid phase properties + def _dens_mol_liq(b): + return b.dens_mol_phase["Liq"] == 1e3 * sum( + b.mole_frac_phase_comp["Liq", j] + * b._params.dens_liq_param_1[j] + / b._params.dens_liq_param_2[j] + ** ( + 1 + + (1 - b.temperature / b._params.dens_liq_param_3[j]) + ** b._params.dens_liq_param_4[j] + ) + for j in ["benzene", "toluene"] + ) + + def _fug_phase_comp(self): + def fug_phase_comp_rule(b, p, i): + if p == "Liq": + if i in ["hydrogen", "methane"]: + return b.mole_frac_phase_comp["Liq", i] + else: + return b.pressure_sat[i] * b.mole_frac_phase_comp["Liq", i] + else: + if i in ["hydrogen", "methane"]: + return 1e-6 + else: + return b.mole_frac_phase_comp["Vap", i] * b.pressure + + self.fug_phase_comp = Expression( + self._params.phase_list, + self._params.component_list, + rule=fug_phase_comp_rule, + ) + + def _pressure_sat(self): + self.pressure_sat = Var( + self._params.component_list, + initialize=101325, + units=pyunits.Pa, + doc="Vapor pressure", + ) + + def rule_P_sat(b, j): + return ( + ( + log10(b.pressure_sat[j] / pyunits.Pa * 1e-5) + - b._params.pressure_sat_coeff_A[j] + ) + * (b._teq + b._params.pressure_sat_coeff_C[j]) + ) == -b._params.pressure_sat_coeff_B[j] + + self.eq_pressure_sat = Constraint(self._params.component_list, rule=rule_P_sat) + + def _enth_mol_comp_liq(b, j): + return b.enth_mol_phase_comp["Liq", j] * 1e3 == ( + (b._params.cp_ig_5["Liq", j] / 5) + * (b.temperature**5 - b._params.temperature_ref**5) + + (b._params.cp_ig_4["Liq", j] / 4) + * (b.temperature**4 - b._params.temperature_ref**4) + + (b._params.cp_ig_3["Liq", j] / 3) + * (b.temperature**3 - b._params.temperature_ref**3) + + (b._params.cp_ig_2["Liq", j] / 2) + * (b.temperature**2 - b._params.temperature_ref**2) + + b._params.cp_ig_1["Liq", j] * (b.temperature - b._params.temperature_ref) + ) + + def _entr_mol_comp_liq(b, j): + return b.entr_mol_phase_comp["Liq", j] * 1e3 == ( + ( + (b._params.cp_ig_5["Liq", j] / 4) + * (b.temperature**4 - b._params.temperature_ref**4) + + (b._params.cp_ig_4["Liq", j] / 3) + * (b.temperature**3 - b._params.temperature_ref**3) + + (b._params.cp_ig_3["Liq", j] / 2) + * (b.temperature**2 - b._params.temperature_ref**2) + + b._params.cp_ig_2["Liq", j] + * (b.temperature - b._params.temperature_ref) + + b._params.cp_ig_1["Liq", j] + * log(b.temperature / b._params.temperature_ref) + ) + - const.gas_constant + * log( + b.mole_frac_phase_comp["Liq", j] * b.pressure / b._params.pressure_ref + ) + ) + + # ----------------------------------------------------------------------------- + # Vapour phase properties + def _dens_mol_vap(b): + return b.pressure == ( + b.dens_mol_phase["Vap"] * const.gas_constant * b.temperature + ) + + def _dh_vap(self): + # heat of vaporization + add_object_reference(self, "dh_vap", self._params.dh_vap) + + def _ds_vap(self): + # entropy of vaporization = dh_Vap/T_boil + # TODO : something more rigorous would be nice + self.ds_vap = Var( + self._params.component_list, + initialize=86, + units=pyunits.J / pyunits.mol / pyunits.K, + doc="Entropy of vaporization", + ) + + def rule_ds_vap(b, j): + return b.dh_vap[j] == (b.ds_vap[j] * b._params.temperature_boil[j]) + + self.eq_ds_vap = Constraint(self._params.component_list, rule=rule_ds_vap) + + def _enth_mol_comp_vap(b, j): + return b.enth_mol_phase_comp["Vap", j] == b.dh_vap[j] + ( + (b._params.cp_ig_5["Vap", j] / 5) + * (b.temperature**5 - b._params.temperature_ref**5) + + (b._params.cp_ig_4["Vap", j] / 4) + * (b.temperature**4 - b._params.temperature_ref**4) + + (b._params.cp_ig_3["Vap", j] / 3) + * (b.temperature**3 - b._params.temperature_ref**3) + + (b._params.cp_ig_2["Vap", j] / 2) + * (b.temperature**2 - b._params.temperature_ref**2) + + b._params.cp_ig_1["Vap", j] * (b.temperature - b._params.temperature_ref) + ) + + def _entr_mol_comp_vap(b, j): + return b.entr_mol_phase_comp["Vap", j] == ( + b.ds_vap[j] + + ( + (b._params.cp_ig_5["Vap", j] / 4) + * (b.temperature**4 - b._params.temperature_ref**4) + + (b._params.cp_ig_4["Vap", j] / 3) + * (b.temperature**3 - b._params.temperature_ref**3) + + (b._params.cp_ig_3["Vap", j] / 2) + * (b.temperature**2 - b._params.temperature_ref**2) + + b._params.cp_ig_2["Vap", j] + * (b.temperature - b._params.temperature_ref) + + b._params.cp_ig_1["Vap", j] + * log(b.temperature / b._params.temperature_ref) + ) + - const.gas_constant + * log( + b.mole_frac_phase_comp["Vap", j] * b.pressure / b._params.pressure_ref + ) + ) + + def calculate_scaling_factors(self): + # Get default scale factors + super().calculate_scaling_factors() + + is_two_phase = len(self._params.phase_list) == 2 + sf_flow = iscale.get_scaling_factor(self.flow_mol, default=1, warning=True) + sf_T = iscale.get_scaling_factor(self.temperature, default=1, warning=True) + sf_P = iscale.get_scaling_factor(self.pressure, default=1, warning=True) + + if self.is_property_constructed("_teq"): + iscale.set_scaling_factor(self._teq, sf_T) + if self.is_property_constructed("_teq_constraint"): + iscale.constraint_scaling_transform( + self._teq_constraint, sf_T, overwrite=False + ) + + if self.is_property_constructed("_t1"): + iscale.set_scaling_factor(self._t1, sf_T) + if self.is_property_constructed("_t1_constraint"): + iscale.constraint_scaling_transform( + self._t1_constraint, sf_T, overwrite=False + ) + + if self.is_property_constructed("_mole_frac_pdew"): + iscale.set_scaling_factor(self._mole_frac_pdew, 1e3) + iscale.constraint_scaling_transform( + self._sum_mole_frac_pdew, 1e3, overwrite=False + ) + + if self.is_property_constructed("total_flow_balance"): + iscale.constraint_scaling_transform( + self.total_flow_balance, sf_flow, overwrite=False + ) + + if self.is_property_constructed("component_flow_balances"): + for i, c in self.component_flow_balances.items(): + if is_two_phase: + s = iscale.get_scaling_factor( + self.mole_frac_comp[i], default=1, warning=True + ) + s *= sf_flow + iscale.constraint_scaling_transform(c, s, overwrite=False) + else: + s = iscale.get_scaling_factor( + self.mole_frac_comp[i], default=1, warning=True + ) + iscale.constraint_scaling_transform(c, s, overwrite=False) + + if self.is_property_constructed("dens_mol_phase"): + for c in self.eq_dens_mol_phase.values(): + iscale.constraint_scaling_transform(c, sf_P, overwrite=False) + + if self.is_property_constructed("dens_mass_phase"): + for p, c in self.eq_dens_mass_phase.items(): + sf = iscale.get_scaling_factor( + self.dens_mass_phase[p], default=1, warning=True + ) + iscale.constraint_scaling_transform(c, sf, overwrite=False) + + if self.is_property_constructed("enth_mol_phase"): + for p, c in self.eq_enth_mol_phase.items(): + sf = iscale.get_scaling_factor( + self.enth_mol_phase[p], default=1, warning=True + ) + iscale.constraint_scaling_transform(c, sf, overwrite=False) + + if self.is_property_constructed("enth_mol"): + sf = iscale.get_scaling_factor(self.enth_mol, default=1, warning=True) + sf *= sf_flow + iscale.constraint_scaling_transform(self.eq_enth_mol, sf, overwrite=False) + + if self.is_property_constructed("entr_mol_phase"): + for p, c in self.eq_entr_mol_phase.items(): + sf = iscale.get_scaling_factor( + self.entr_mol_phase[p], default=1, warning=True + ) + iscale.constraint_scaling_transform(c, sf, overwrite=False) + + if self.is_property_constructed("entr_mol"): + sf = iscale.get_scaling_factor(self.entr_mol, default=1, warning=True) + sf *= sf_flow + iscale.constraint_scaling_transform(self.eq_entr_mol, sf, overwrite=False) + + if self.is_property_constructed("gibbs_mol_phase"): + for p, c in self.eq_gibbs_mol_phase.items(): + sf = iscale.get_scaling_factor( + self.gibbs_mol_phase[p], default=1, warning=True + ) + iscale.constraint_scaling_transform(c, sf, overwrite=False) diff --git a/docs/examples/structfs/hda_reaction.py b/docs/examples/structfs/hda_reaction.py new file mode 100644 index 0000000000..bade0736f7 --- /dev/null +++ b/docs/examples/structfs/hda_reaction.py @@ -0,0 +1,182 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES), and is copyright (c) 2018-2022 +# by the software owners: The Regents of the University of California, through +# Lawrence Berkeley National Laboratory, National Technology & Engineering +# Solutions of Sandia, LLC, Carnegie Mellon University, West Virginia University +# Research Corporation, et al. All rights reserved. +# +# Please see the files COPYRIGHT.md and LICENSE.md for full copyright and +# license information. +################################################################################# +""" +Property package for the hydrodealkylation of toluene to form benzene +""" + +# Import Python libraries +import logging + +# Import Pyomo libraries +from pyomo.environ import Constraint, exp, Set, Var, Param, units as pyunits + +# Import IDAES cores +from idaes.core import ( + declare_process_block_class, + MaterialFlowBasis, + ReactionParameterBlock, + ReactionBlockDataBase, + ReactionBlockBase, +) +from idaes.core.util.constants import Constants as const +from idaes.core.util.misc import add_object_reference + +# Set up logger +_log = logging.getLogger(__name__) + + +@declare_process_block_class("HDAReactionParameterBlock") +class HDAReactionParameterData(ReactionParameterBlock): + """ + Property Parameter Block Class + Contains parameters and indexing sets associated with properties for + superheated steam. + """ + + def build(self): + """ + Callable method for Block construction. + """ + super(HDAReactionParameterData, self).build() + + self._reaction_block_class = HDAReactionBlock + + # List of valid phases in property package + self.phase_list = Set(initialize=["Vap"]) + + # Component list - a list of component identifiers + self.component_list = Set( + initialize=["benzene", "toluene", "hydrogen", "methane"] + ) + + # Reaction Index + self.rate_reaction_idx = Set(initialize=["R1"]) + + # Reaction Stoichiometry + self.rate_reaction_stoichiometry = { + ("R1", "Vap", "benzene"): 1, + ("R1", "Vap", "toluene"): -1, + ("R1", "Vap", "hydrogen"): -1, + ("R1", "Vap", "methane"): 1, + ("R1", "Liq", "benzene"): 0, + ("R1", "Liq", "toluene"): 0, + ("R1", "Liq", "hydrogen"): 0, + ("R1", "Liq", "methane"): 0, + } + + # Arrhenius Constant + self.arrhenius = Var( + initialize=6.3e10, + units=pyunits.mol * pyunits.m**-3 * pyunits.s**-1 * pyunits.Pa**-1, + doc="Arrhenius pre-exponential factor", + ) + self.arrhenius.fix() + + # Activation Energy + self.energy_activation = Var( + initialize=217.6e3, units=pyunits.J / pyunits.mol, doc="Activation energy" + ) + self.energy_activation.fix() + + # Heat of Reaction + dh_rxn_dict = {"R1": -1.08e5} + self.dh_rxn = Param( + self.rate_reaction_idx, + initialize=dh_rxn_dict, + units=pyunits.J / pyunits.mol, + doc="Heat of reaction", + ) + + @classmethod + def define_metadata(cls, obj): + obj.add_properties( + { + "k_rxn": {"method": None, "units": "m^3/mol.s"}, + "reaction_rate": {"method": None, "units": "mol/m^3.s"}, + } + ) + obj.add_default_units( + { + "time": pyunits.s, + "length": pyunits.m, + "mass": pyunits.kg, + "amount": pyunits.mol, + "temperature": pyunits.K, + } + ) + + +class ReactionBlock(ReactionBlockBase): + """ + This Class contains methods which should be applied to Reaction Blocks as a + whole, rather than individual elements of indexed Reaction Blocks. + """ + + def initialize(blk, outlvl=0, **kwargs): + """ + Initialization routine for reaction package. + Keyword Arguments: + outlvl : sets output level of initialization routine + * 0 = no output (default) + * 1 = report after each step + Returns: + None + """ + if outlvl > 0: + _log.info("{} Initialization Complete.".format(blk.name)) + + +@declare_process_block_class("HDAReactionBlock", block_class=ReactionBlock) +class HDAReactionBlockData(ReactionBlockDataBase): + """ + An example reaction package for saponification of ethyl acetate + """ + + def build(self): + """ + Callable method for Block construction + """ + super(HDAReactionBlockData, self).build() + + # Heat of reaction - no _ref as this is the actual property + add_object_reference(self, "dh_rxn", self.config.parameters.dh_rxn) + + self.k_rxn = Var( + initialize=0.2, + units=pyunits.mol * pyunits.m**-3 * pyunits.s**-1 * pyunits.Pa**-1, + ) + + self.reaction_rate = Var( + self.params.rate_reaction_idx, + initialize=1, + units=pyunits.mol / pyunits.m**3 / pyunits.s, + ) + + self.arrhenus_equation = Constraint( + expr=self.k_rxn + == self.params.arrhenius + * exp( + -self.params.energy_activation + / (const.gas_constant * self.state_ref.temperature) + ) + ) + + self.rate_expression = Constraint( + expr=self.reaction_rate["R1"] + == self.k_rxn + * self.state_ref.pressure + * self.state_ref.mole_frac_phase_comp["Vap", "toluene"] + ) + + def get_reaction_rate_basis(b): + return MaterialFlowBasis.molar diff --git a/docs/examples/structfs/index.rst b/docs/examples/structfs/index.rst new file mode 100644 index 0000000000..ed33735a47 --- /dev/null +++ b/docs/examples/structfs/index.rst @@ -0,0 +1,8 @@ +Structured Flowsheet Examples +============================= + +.. toctree:: + :maxdepth: 1 + + flash_flowsheet_nb + hda_flowsheet_nb \ No newline at end of file diff --git a/docs/how_to_guides/index.rst b/docs/how_to_guides/index.rst index e2bd73ea7b..552e6b1f94 100644 --- a/docs/how_to_guides/index.rst +++ b/docs/how_to_guides/index.rst @@ -7,4 +7,5 @@ How-To-Guides custom_models/general_model_development workflow/index opt_dependencies - versioned_idaes_install \ No newline at end of file + versioned_idaes_install + structfs/index \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 0cf483ac4d..81a2ca0d8d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -44,6 +44,7 @@ Contents | :doc:`Setting up IDAES Models ` | :doc:`Developing Custom Models ` | :doc:`Installing Specific IDAES Versions ` + | :doc:`Creating and using the FlowsheetRunner interface to a flowsheet ` * - Explanations | :doc:`Why IDAES ` | :doc:`Concepts ` From 8bbb96bdca6c7e5dd7d84d64861659f00e3d87a1 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 19 Dec 2025 17:25:44 -0800 Subject: [PATCH 24/73] save docs changes --- docs/how_to_guides/structfs/index.rst | 155 ++++++++++++++++++ idaes/core/util/structfs/fsrunner.py | 55 +++++-- .../core/util/structfs/tests/test_fsrunner.py | 20 +-- 3 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 docs/how_to_guides/structfs/index.rst diff --git a/docs/how_to_guides/structfs/index.rst b/docs/how_to_guides/structfs/index.rst new file mode 100644 index 0000000000..2e6abca6b0 --- /dev/null +++ b/docs/how_to_guides/structfs/index.rst @@ -0,0 +1,155 @@ +Flowsheet Runner +================= + +.. py:currentmodule:: idaes.core.util.structfs + +The 'flowsheet runner' is an API in the :py:mod:`idaes.core.util.structfs` subpackage, and in particular the :py:mod:`.runner` and :py:mod:`.fsrunner` modules. + +The core idea of the :py:class:`fsrunner.FlowsheetRunner` class is that the code that does this should follow a standard set of "steps", each of which can be represented with a function. +By standardizing the naming and ordering of these steps, it becomes easier to build tools that run and inspect flowsheets. + +There are two stages to using the FlowsheetRunner API: + +1. wrapping the code that builds and runs a flowsheet and +2. executing and inspecting a wrapped flowsheet. + +Create FlowsheetRunner +----------------------- +It is assumed here that you have Python code to build, configure, and run an IDAES flowsheet. +You will first arrange this code to follow the standard "steps" of a flowsheet workflow, which are +listed in the :py:attr:`fsrunner.BaseFlowsheetRunner.STEPS` class attribute. +Not all the steps need to be defined; the API will skip over steps with no definition when executing a range of steps. +To make the code more structured you can define sub-steps, as described later. + + +Before +++++++ + +For now, let's assume a simple flowsheet with four steps: "build", "set_operating_conditions", "initialize", and "solve_optimization". +Let's also assume you have four functions defined that correspond to these steps. +Below is a sample flowsheet (for a single Flash unit) that we will use as an example: + +.. code-block:: python + + from pyomo.environ import ConcreteModel, SolverFactory + from idaes.core import FlowsheetBlock + from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, + ) + from idaes.models.unit_models import Flash + + def build_model(): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + return m + + def set_operating_conditions(m): + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + + def init_model(m): + m.fs.flash.initialize(outlvl=idaeslog.INFO) + + def solve(m): + solver = SolverFactory("ipopt") + return = solver.solve(m, tee=ctx["tee"]) + +After ++++++ + +In order to make this into a :py:class:`fsrunner.FlowsheetRunner`-wrapped flowsheet, we need to do make a few changes. +The modified file is shown below, with changed lines highlighted and descriptions below. + +.. code-block:: python + :linenos: + :emphasize-lines: 10, 13, 15, 16, 25, 28, 29, 31, 31, 42, 44, 48, 49, 51, 54, 55, 56 + + from pyomo.environ import ConcreteModel, SolverFactory + from idaes.core import FlowsheetBlock + + # Import idaes logger to set output levels + import idaes.logger as idaeslog + from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, + ) + from idaes.models.unit_models import Flash + from idaes.core.util.structfs.fsrunner import FlowsheetRunner + + + FS = FlowsheetRunner() + + @FS.step("build") + def build_model(ctx): + """Build the model.""" + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + # assert degrees_of_freedom(m) == 7 + ctx.model = m + + + @FS.step("set_operating_conditions") + def set_operating_conditions(ctx): + """Set operating conditions.""" + m = ctx.model + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + + + @FS.step("initialize") + def init_model(ctx): + """ "Initialize the model.""" + m = ctx.model + m.fs.flash.initialize(outlvl=idaeslog.INFO) + + + @FS.step("set_solver") + def set_solver(ctx): + """Set the solver.""" + ctx.solver = SolverFactory("ipopt") + + + @FS.step("solve_optimization") + def solve_opt(ctx): + ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) + +Details on the changes: + +============== =========== +Lines Description +============== =========== +10 Import the FlowsheetRunner class +13 Create a global ``FlowsheetRunner`` object, here called ``FS`` +15,28,41,48,54 Add a ``@FS.step()`` decorator in front of each function with the name of the associated step +16,29,42,39,55 Make each function take a single argument which is a :py:class:`fsrunner.Context` instance used to pass state information between functions (here, that argument is named ``ctx``) +31,44 Replace the direct passing of the model object (in this case, called `m`) with a context object that has a ``.model`` attribute +48-51 Add a function for the ``set_solver`` step, to select the solver (here, IPOPT) +25 Assign the model created in the "build" step to ``ctx.model``, a standard attribute of the context object +31,44 At the top of all other steps, alias the ``ctx.model`` variable to the local variable name used in the original function (in this case, ``m``) +56 In the "solve_optimization" step, assign the solver result to ``ctx["results"]`` +============== =========== + + +Executing and inspecting FlowsheetRunner +----------------------------------------- +Once the flowsheet has been 'wrapped' in the flowsheet runner interface, it can be run and manipulated via the wrapper object. +The basic steps to do this are: import the flowsheet-runner object, build and execute the flowsheet, and inspect the flowsheet. + +Some examples of doing this are shown in the :doc:`structfs examples notebooks `. diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index f8daa74751..090e894b23 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -18,7 +18,7 @@ from typing import Union # third-party -from pyomo.environ import ConcreteModel +from pyomo.environ import ConcreteModel, is_variable_type from pyomo.environ import units as pyunits from idaes.core import FlowsheetBlock @@ -84,7 +84,7 @@ def __init__(self, solver=None, tee=False): self._ann = {} super().__init__(self.STEPS) # needs to be last - def run_steps(self, first: str = "", last: str = ""): + def run_steps(self, first: str = "", last: str = "", **kwargs): """Run the steps. Before it calls the superclass to run the steps, checks @@ -99,7 +99,7 @@ def run_steps(self, first: str = "", last: str = ""): or self._context.model is None ): self._context.model = self._create_model() - super().run_steps(first, last) + super().run_steps(first, last, **kwargs) def reset(self): self._context = Context(solver=self._solver, tee=self._tee, model=None) @@ -119,9 +119,9 @@ def results(self): """Syntactic sugar to return the `results` in the context.""" return self._context["results"] - def annotate( + def annotate_var( self, - block: object, + variable: object, key: str = None, title: str = None, desc: str = None, @@ -132,10 +132,10 @@ def annotate( input_category: str = "main", output_category: str = "main", ) -> object: - """Annotate a variable + """Annotate a Pyomo variable. Args: - block: Pyomo block being annotated + variable: Pyomo variable being annotated key: Key for this block in dict. Defaults to object name. title: Name / title of the block. Defaults to object name. desc: Description of the block. Defaults to object name. @@ -155,15 +155,15 @@ def annotate( if not is_input and not is_output: raise ValueError("One of 'is_input', 'is_output' must be True") - qual_name = block.name - key = key or block.name + qual_name = variable.name + key = key or variable.name self._ann[key] = { - "block": block, + "var": variable, "fullname": qual_name, "title": title or qual_name, "description": desc or qual_name, - "units": units or str(pyunits.get_units(block)), + "units": units or str(pyunits.get_units(variable)), "rounding": rounding, "is_input": is_input, "is_output": is_output, @@ -171,19 +171,23 @@ def annotate( "output_category": output_category, } - return block + return variable @property - def annotations(self): + def annotated_vars(self) -> dict[str,]: + """Get (a copy of) the annotated variables.""" return self._ann.copy() class FlowsheetRunner(BaseFlowsheetRunner): class DegreesOfFreedom: + """Wrapper for the UnitDofChecker action""" + def __init__(self, runner): from .runner_actions import UnitDofChecker + # check DoF after build, initial solve, and optimization solve self._a = runner.add_action( "dof", UnitDofChecker, @@ -208,6 +212,8 @@ def _ipython_display_(self): self._a.summary() class Timings: + """Wrapper for the Timer action""" + def __init__(self, runner): from .runner_actions import Timer @@ -244,5 +250,28 @@ def build(self): def solve_initial(self): self.run_steps(last="solve_initial") + def run_steps(self, after=None, before=None, **kwargs): + if after is not None: + if "first" in kwargs: + raise ValueError("Cannot specify both 'after' and 'first'") + kwargs["first"] = before + if "endpoints" in kwargs: + ep = list(kwargs["endpoints"]) + ep[0] = False + kwargs["endpoints"] = tuple(ep) + else: + kwargs["endpoints"] = (False, True) + if before is not None: + if "last" in kwargs: + raise ValueError("Cannot specify both 'before' and 'last'") + kwargs["last"] = before + if "endpoints" in kwargs: + ep = list(kwargs["endpoints"]) + ep[1] = False + kwargs["endpoints"] = tuple(ep) + else: + kwargs["endpoints"] = (True, False) + return super().run_steps(**kwargs) + def show_diagram(self): return display_connectivity(input_model=self.model) diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 5c25d20df2..6fd5bacfd6 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -82,26 +82,26 @@ def test_annotation(): runner.run_steps(last="build") print(runner.timings.history) - a_ = runner.annotate # alias + ann = runner.annotate_var # alias flash = runner.model.fs.flash # alias category = "flash" kw = {"input_category": category, "output_category": category} - a_( + ann( flash.inlet.flow_mol, key="fs.flash.inlet.flow_mol", title="Inlet molar flow", desc="Flash inlet molar flow rate", **kw, ).fix(1) - a_(flash.inlet.temperature, units="Centipedes", **kw).fix(368) - a_(flash.inlet.pressure, **kw).fix(101325) - a_(flash.inlet.mole_frac_comp[0, "benzene"], **kw).fix(0.5) - a_(flash.inlet.mole_frac_comp[0, "toluene"], **kw).fix(0.5) - a_(flash.heat_duty, **kw).fix(0) - a_(flash.deltaP, is_input=False, **kw).fix(0) - - ann = runner.annotations + ann(flash.inlet.temperature, units="Centipedes", **kw).fix(368) + ann(flash.inlet.pressure, **kw).fix(101325) + ann(flash.inlet.mole_frac_comp[0, "benzene"], **kw).fix(0.5) + ann(flash.inlet.mole_frac_comp[0, "toluene"], **kw).fix(0.5) + ann(flash.heat_duty, **kw).fix(0) + ann(flash.deltaP, is_input=False, **kw).fix(0) + + ann = runner.annotated_vars print("-" * 40) print(ann) print("-" * 40) From 6514832845966329404c92f69c48ac05a31bf766 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 27 Dec 2025 12:15:48 -0800 Subject: [PATCH 25/73] myst --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 6c79535230..783ef5dce0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,6 +36,7 @@ "sphinx.ext.doctest", "sphinx_copybutton", "nbsphinx", + "myst_parser", ] # Put type hints in the description, not signature From 260317f3e2dbc96193635fde792c0b29a402993e Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 27 Dec 2025 14:36:18 -0800 Subject: [PATCH 26/73] markdown version --- docs/how_to_guides/structfs/index.md | 178 +++++++++++++++++++++++++++ requirements-dev.txt | 1 + 2 files changed, 179 insertions(+) create mode 100644 docs/how_to_guides/structfs/index.md diff --git a/docs/how_to_guides/structfs/index.md b/docs/how_to_guides/structfs/index.md new file mode 100644 index 0000000000..ad00169a74 --- /dev/null +++ b/docs/how_to_guides/structfs/index.md @@ -0,0 +1,178 @@ +# Flowsheet Runner + +The 'flowsheet runner' is an API in the +{py:mod}`idaes.core.util.structfs` subpackage, and in +particular that package's {py:mod}`runner ` and +{py:mod}`fsrunner ` modules. + +## Overview + +The core idea of the +{py:class}`FlowsheetRunner ` class is +that flowsheets should follow a standard set of "steps". By standardizing the +naming and ordering of these steps, it becomes easier to build tools that run +and inspect flowsheets. The Python mechanics of this are to put each step in a +function and wrap that function with decorator. The decorator uses a string to +indicate which standard step the function implements. + +Once these functions are defined, the API can be used to execute and inspect a +wrapped flowsheet. + +## Step 1: Define flowsheet + +It is assumed here that you have Python code to build, configure, and run an +IDAES flowsheet. You will first arrange this code to follow the standard "steps" +of a flowsheet workflow, which are listed in the +{py:class}`BaseFlowsheetRunner ` +class' `STEPS` attribute. Not all the steps need to be defined: the API will +skip over steps with no definition when executing a range of steps. To make the +code more structured you can also define internal sub-steps, as described later. + +The set of defined steps is: + +* build - create the flowsheet +* set_operating_conditions - set initial variable values +* set_scaling - set scaling +* initialize - initialize the flowsheet +* set_solver - choose the solver +* solve_initial - perform an initial (square problem) solve +* add_costing - add costing information (if any) +* check_model_structure - check the model for structural issues +* initialize_costing - initialize costing variables +* solve_optimization - setup and solve the optimization problem +* check_model_numerics - check the model for numerical issues + +### Example: Flash flowsheet + +This is illustrated below with a before/after of an extremely simple flowsheet +with a single Flash unit model. + +#### Before + +For now, let's assume this flowsheet uses only four of the standard steps: +"build", "set_operating_conditions", "initialize", and "solve_optimization". +Let's also assume you have four functions defined that correspond to these +steps. Below is a sample flowsheet (for a single Flash unit) that we will use as +an example: + +```{code} +from pyomo.environ import ConcreteModel, SolverFactory from idaes.core +import FlowsheetBlock from +idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import +( BTXParameterBlock, ) from idaes.models.unit_models import Flash + +def build_model(): m = ConcreteModel() m.fs = FlowsheetBlock(dynamic=False) +m.fs.properties = BTXParameterBlock( valid_phase=("Liq", "Vap"), +activity_coeff_model="Ideal", state_vars="FTPz" ) m.fs.flash = +Flash(property_package=m.fs.properties) return m + +def set_operating_conditions(m): m.fs.flash.inlet.flow_mol.fix(1) +m.fs.flash.inlet.temperature.fix(368) m.fs.flash.inlet.pressure.fix(101325) +m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) +m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) +m.fs.flash.heat_duty.fix(0) m.fs.flash.deltaP.fix(0) + +def init_model(m): m.fs.flash.initialize(outlvl=idaeslog.INFO) + +def solve(m): solver = SolverFactory("ipopt") return = solver.solve(m, +tee=ctx["tee"]) +``` + +#### After + +In order to make this into a +{py:class}`FlowsheetRunner `-wrapped +flowsheet, we need to do make a few changes. The modified file is shown below, +with changed lines highlighted and descriptions below. + +```{code} +:linenos: + +from pyomo.environ import ConcreteModel, SolverFactory +from idaes.core import FlowsheetBlock + +# Import idaes logger to set output levels +import idaes.logger as idaeslog +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE \ +import ( BTXParameterBlock, ) +from idaes.models.unit_models import Flash + +from idaes.core.util.structfs.fsrunner import FlowsheetRunner + + +FS = FlowsheetRunner() + +@FS.step("build") +def build_model(ctx): + """Build the model.""" + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + # assert degrees_of_freedom(m) == 7 + ctx.model = m + + +@FS.step("set_operating_conditions") + def set_operating_conditions(ctx): + """Set operating conditions.""" + m = ctx.model + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + + +@FS.step("initialize") +def init_model(ctx): + """ "Initialize the model.""" + m = ctx.model + m.fs.flash.initialize(outlvl=idaeslog.INFO) + + +@FS.step("set_solver") +def set_solver(ctx): + """Set the solver.""" + ctx.solver = SolverFactory("ipopt") + + +@FS.step("solve_optimization") +def solve_opt(ctx): + ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) +``` + +Details on the changes: + +* **10**: Import the FlowsheetRunner class +* **13**: Create a global `FlowsheetRunner` object, here called `FS` +* **15, 28, 41, 48, 54**: Add a `@FS.step()` decorator in front of each function + with the name of the associated step +* **16, 29, 42, 39, 55**: Make each function take a single argument which is a + {py:class}`fsrunner.Context ` instance used to + pass state information between functions (here, that argument is named `ctx`) +* **25**: Assign the model created in the "build" step to `ctx.model`, a + standard attribute of the context object +* **31, 44**: Replace the direct passing of the model object (in this case, + called `m`) with a context object that has a `.model` attribute +* **48-51**: Add a function for the `set_solver` step, to select the solver + (here, IPOPT) +* **56**: In the "solve_optimization" step, assign the solver result to + `ctx["results"]` + +## Step 2: Execute and inspect + +Once the flowsheet has been 'wrapped' in the flowsheet runner interface, it can +be run and manipulated via the wrapper object. The basic steps to do this are: +import the flowsheet-runner object, build and execute the flowsheet, and inspect +the flowsheet. + +### Examples + +Some examples of doing this are shown in the +[structfs examples notebooks](/examples/structfs/index). + diff --git a/requirements-dev.txt b/requirements-dev.txt index 7156c0822f..c2d1b60394 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,7 @@ sphinx-argparse==0.4.0 sphinx-book-theme<=1.1.2,>=1.0.0 sphinx-copybutton==0.5.2 nbsphinx # for Jupyter Notebooks in the docs +myst-parser # for MystMD ### testing and linting # TODO/NOTE pytest is specified as a dependency in setup.py, but we might want to pin a specific version here From cb5935312f88cf1384cef6e51e78fd2f73939fff Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 27 Dec 2025 15:05:03 -0800 Subject: [PATCH 27/73] removed old --- docs/how_to_guides/structfs/index.rst | 155 -------------------------- 1 file changed, 155 deletions(-) delete mode 100644 docs/how_to_guides/structfs/index.rst diff --git a/docs/how_to_guides/structfs/index.rst b/docs/how_to_guides/structfs/index.rst deleted file mode 100644 index 2e6abca6b0..0000000000 --- a/docs/how_to_guides/structfs/index.rst +++ /dev/null @@ -1,155 +0,0 @@ -Flowsheet Runner -================= - -.. py:currentmodule:: idaes.core.util.structfs - -The 'flowsheet runner' is an API in the :py:mod:`idaes.core.util.structfs` subpackage, and in particular the :py:mod:`.runner` and :py:mod:`.fsrunner` modules. - -The core idea of the :py:class:`fsrunner.FlowsheetRunner` class is that the code that does this should follow a standard set of "steps", each of which can be represented with a function. -By standardizing the naming and ordering of these steps, it becomes easier to build tools that run and inspect flowsheets. - -There are two stages to using the FlowsheetRunner API: - -1. wrapping the code that builds and runs a flowsheet and -2. executing and inspecting a wrapped flowsheet. - -Create FlowsheetRunner ------------------------ -It is assumed here that you have Python code to build, configure, and run an IDAES flowsheet. -You will first arrange this code to follow the standard "steps" of a flowsheet workflow, which are -listed in the :py:attr:`fsrunner.BaseFlowsheetRunner.STEPS` class attribute. -Not all the steps need to be defined; the API will skip over steps with no definition when executing a range of steps. -To make the code more structured you can define sub-steps, as described later. - - -Before -++++++ - -For now, let's assume a simple flowsheet with four steps: "build", "set_operating_conditions", "initialize", and "solve_optimization". -Let's also assume you have four functions defined that correspond to these steps. -Below is a sample flowsheet (for a single Flash unit) that we will use as an example: - -.. code-block:: python - - from pyomo.environ import ConcreteModel, SolverFactory - from idaes.core import FlowsheetBlock - from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( - BTXParameterBlock, - ) - from idaes.models.unit_models import Flash - - def build_model(): - m = ConcreteModel() - m.fs = FlowsheetBlock(dynamic=False) - m.fs.properties = BTXParameterBlock( - valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" - ) - m.fs.flash = Flash(property_package=m.fs.properties) - return m - - def set_operating_conditions(m): - m.fs.flash.inlet.flow_mol.fix(1) - m.fs.flash.inlet.temperature.fix(368) - m.fs.flash.inlet.pressure.fix(101325) - m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) - m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) - m.fs.flash.heat_duty.fix(0) - m.fs.flash.deltaP.fix(0) - - def init_model(m): - m.fs.flash.initialize(outlvl=idaeslog.INFO) - - def solve(m): - solver = SolverFactory("ipopt") - return = solver.solve(m, tee=ctx["tee"]) - -After -+++++ - -In order to make this into a :py:class:`fsrunner.FlowsheetRunner`-wrapped flowsheet, we need to do make a few changes. -The modified file is shown below, with changed lines highlighted and descriptions below. - -.. code-block:: python - :linenos: - :emphasize-lines: 10, 13, 15, 16, 25, 28, 29, 31, 31, 42, 44, 48, 49, 51, 54, 55, 56 - - from pyomo.environ import ConcreteModel, SolverFactory - from idaes.core import FlowsheetBlock - - # Import idaes logger to set output levels - import idaes.logger as idaeslog - from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( - BTXParameterBlock, - ) - from idaes.models.unit_models import Flash - from idaes.core.util.structfs.fsrunner import FlowsheetRunner - - - FS = FlowsheetRunner() - - @FS.step("build") - def build_model(ctx): - """Build the model.""" - m = ConcreteModel() - m.fs = FlowsheetBlock(dynamic=False) - m.fs.properties = BTXParameterBlock( - valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" - ) - m.fs.flash = Flash(property_package=m.fs.properties) - # assert degrees_of_freedom(m) == 7 - ctx.model = m - - - @FS.step("set_operating_conditions") - def set_operating_conditions(ctx): - """Set operating conditions.""" - m = ctx.model - m.fs.flash.inlet.flow_mol.fix(1) - m.fs.flash.inlet.temperature.fix(368) - m.fs.flash.inlet.pressure.fix(101325) - m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) - m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) - m.fs.flash.heat_duty.fix(0) - m.fs.flash.deltaP.fix(0) - - - @FS.step("initialize") - def init_model(ctx): - """ "Initialize the model.""" - m = ctx.model - m.fs.flash.initialize(outlvl=idaeslog.INFO) - - - @FS.step("set_solver") - def set_solver(ctx): - """Set the solver.""" - ctx.solver = SolverFactory("ipopt") - - - @FS.step("solve_optimization") - def solve_opt(ctx): - ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) - -Details on the changes: - -============== =========== -Lines Description -============== =========== -10 Import the FlowsheetRunner class -13 Create a global ``FlowsheetRunner`` object, here called ``FS`` -15,28,41,48,54 Add a ``@FS.step()`` decorator in front of each function with the name of the associated step -16,29,42,39,55 Make each function take a single argument which is a :py:class:`fsrunner.Context` instance used to pass state information between functions (here, that argument is named ``ctx``) -31,44 Replace the direct passing of the model object (in this case, called `m`) with a context object that has a ``.model`` attribute -48-51 Add a function for the ``set_solver`` step, to select the solver (here, IPOPT) -25 Assign the model created in the "build" step to ``ctx.model``, a standard attribute of the context object -31,44 At the top of all other steps, alias the ``ctx.model`` variable to the local variable name used in the original function (in this case, ``m``) -56 In the "solve_optimization" step, assign the solver result to ``ctx["results"]`` -============== =========== - - -Executing and inspecting FlowsheetRunner ------------------------------------------ -Once the flowsheet has been 'wrapped' in the flowsheet runner interface, it can be run and manipulated via the wrapper object. -The basic steps to do this are: import the flowsheet-runner object, build and execute the flowsheet, and inspect the flowsheet. - -Some examples of doing this are shown in the :doc:`structfs examples notebooks `. From a1fb58c9d407106cf8ad9d19e2dda25c6dadd682 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Wed, 31 Dec 2025 10:25:31 -0800 Subject: [PATCH 28/73] save work --- docs/how_to_guides/structfs/index.md | 178 +--------------- idaes/core/util/structfs/__init__.py | 199 ++++++++++++++++++ idaes/core/util/structfs/fsrunner.py | 29 +-- idaes/core/util/structfs/runner.py | 145 +++++++++++-- idaes/core/util/structfs/runner_actions.py | 28 +++ .../core/util/structfs/tests/test_fsrunner.py | 32 ++- idaes/core/util/structfs/tests/test_runner.py | 12 +- 7 files changed, 396 insertions(+), 227 deletions(-) diff --git a/docs/how_to_guides/structfs/index.md b/docs/how_to_guides/structfs/index.md index ad00169a74..11075d8f22 100644 --- a/docs/how_to_guides/structfs/index.md +++ b/docs/how_to_guides/structfs/index.md @@ -1,178 +1,6 @@ # Flowsheet Runner -The 'flowsheet runner' is an API in the -{py:mod}`idaes.core.util.structfs` subpackage, and in -particular that package's {py:mod}`runner ` and -{py:mod}`fsrunner ` modules. - -## Overview - -The core idea of the -{py:class}`FlowsheetRunner ` class is -that flowsheets should follow a standard set of "steps". By standardizing the -naming and ordering of these steps, it becomes easier to build tools that run -and inspect flowsheets. The Python mechanics of this are to put each step in a -function and wrap that function with decorator. The decorator uses a string to -indicate which standard step the function implements. - -Once these functions are defined, the API can be used to execute and inspect a -wrapped flowsheet. - -## Step 1: Define flowsheet - -It is assumed here that you have Python code to build, configure, and run an -IDAES flowsheet. You will first arrange this code to follow the standard "steps" -of a flowsheet workflow, which are listed in the -{py:class}`BaseFlowsheetRunner ` -class' `STEPS` attribute. Not all the steps need to be defined: the API will -skip over steps with no definition when executing a range of steps. To make the -code more structured you can also define internal sub-steps, as described later. - -The set of defined steps is: - -* build - create the flowsheet -* set_operating_conditions - set initial variable values -* set_scaling - set scaling -* initialize - initialize the flowsheet -* set_solver - choose the solver -* solve_initial - perform an initial (square problem) solve -* add_costing - add costing information (if any) -* check_model_structure - check the model for structural issues -* initialize_costing - initialize costing variables -* solve_optimization - setup and solve the optimization problem -* check_model_numerics - check the model for numerical issues - -### Example: Flash flowsheet - -This is illustrated below with a before/after of an extremely simple flowsheet -with a single Flash unit model. - -#### Before - -For now, let's assume this flowsheet uses only four of the standard steps: -"build", "set_operating_conditions", "initialize", and "solve_optimization". -Let's also assume you have four functions defined that correspond to these -steps. Below is a sample flowsheet (for a single Flash unit) that we will use as -an example: - -```{code} -from pyomo.environ import ConcreteModel, SolverFactory from idaes.core -import FlowsheetBlock from -idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import -( BTXParameterBlock, ) from idaes.models.unit_models import Flash - -def build_model(): m = ConcreteModel() m.fs = FlowsheetBlock(dynamic=False) -m.fs.properties = BTXParameterBlock( valid_phase=("Liq", "Vap"), -activity_coeff_model="Ideal", state_vars="FTPz" ) m.fs.flash = -Flash(property_package=m.fs.properties) return m - -def set_operating_conditions(m): m.fs.flash.inlet.flow_mol.fix(1) -m.fs.flash.inlet.temperature.fix(368) m.fs.flash.inlet.pressure.fix(101325) -m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) -m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) -m.fs.flash.heat_duty.fix(0) m.fs.flash.deltaP.fix(0) - -def init_model(m): m.fs.flash.initialize(outlvl=idaeslog.INFO) - -def solve(m): solver = SolverFactory("ipopt") return = solver.solve(m, -tee=ctx["tee"]) +```{autodoc2-docstring} structfs +render_plugin = "myst" +no_index = true ``` - -#### After - -In order to make this into a -{py:class}`FlowsheetRunner `-wrapped -flowsheet, we need to do make a few changes. The modified file is shown below, -with changed lines highlighted and descriptions below. - -```{code} -:linenos: - -from pyomo.environ import ConcreteModel, SolverFactory -from idaes.core import FlowsheetBlock - -# Import idaes logger to set output levels -import idaes.logger as idaeslog -from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE \ -import ( BTXParameterBlock, ) -from idaes.models.unit_models import Flash - -from idaes.core.util.structfs.fsrunner import FlowsheetRunner - - -FS = FlowsheetRunner() - -@FS.step("build") -def build_model(ctx): - """Build the model.""" - m = ConcreteModel() - m.fs = FlowsheetBlock(dynamic=False) - m.fs.properties = BTXParameterBlock( - valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" - ) - m.fs.flash = Flash(property_package=m.fs.properties) - # assert degrees_of_freedom(m) == 7 - ctx.model = m - - -@FS.step("set_operating_conditions") - def set_operating_conditions(ctx): - """Set operating conditions.""" - m = ctx.model - m.fs.flash.inlet.flow_mol.fix(1) - m.fs.flash.inlet.temperature.fix(368) - m.fs.flash.inlet.pressure.fix(101325) - m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) - m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) - m.fs.flash.heat_duty.fix(0) - m.fs.flash.deltaP.fix(0) - - -@FS.step("initialize") -def init_model(ctx): - """ "Initialize the model.""" - m = ctx.model - m.fs.flash.initialize(outlvl=idaeslog.INFO) - - -@FS.step("set_solver") -def set_solver(ctx): - """Set the solver.""" - ctx.solver = SolverFactory("ipopt") - - -@FS.step("solve_optimization") -def solve_opt(ctx): - ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) -``` - -Details on the changes: - -* **10**: Import the FlowsheetRunner class -* **13**: Create a global `FlowsheetRunner` object, here called `FS` -* **15, 28, 41, 48, 54**: Add a `@FS.step()` decorator in front of each function - with the name of the associated step -* **16, 29, 42, 39, 55**: Make each function take a single argument which is a - {py:class}`fsrunner.Context ` instance used to - pass state information between functions (here, that argument is named `ctx`) -* **25**: Assign the model created in the "build" step to `ctx.model`, a - standard attribute of the context object -* **31, 44**: Replace the direct passing of the model object (in this case, - called `m`) with a context object that has a `.model` attribute -* **48-51**: Add a function for the `set_solver` step, to select the solver - (here, IPOPT) -* **56**: In the "solve_optimization" step, assign the solver result to - `ctx["results"]` - -## Step 2: Execute and inspect - -Once the flowsheet has been 'wrapped' in the flowsheet runner interface, it can -be run and manipulated via the wrapper object. The basic steps to do this are: -import the flowsheet-runner object, build and execute the flowsheet, and inspect -the flowsheet. - -### Examples - -Some examples of doing this are shown in the -[structfs examples notebooks](/examples/structfs/index). - diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index e69de29bb2..7237f0ebca 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -0,0 +1,199 @@ +''' +The 'flowsheet runner' is an API in the +{py:mod}`idaes.core.util.structfs` subpackage, and in +particular that package's {py:mod}`runner ` and +{py:mod}`fsrunner ` modules. + +## Overview + +The core idea of the +{py:class}`FlowsheetRunner ` class is +that flowsheets should follow a standard set of "steps". By standardizing the +naming and ordering of these steps, it becomes easier to build tools that run +and inspect flowsheets. The Python mechanics of this are to put each step in a +function and wrap that function with decorator. The decorator uses a string to +indicate which standard step the function implements. + +Once these functions are defined, the API can be used to execute and inspect a +wrapped flowsheet. + +The framework can perform arbitrary actions before and after each run, +and before and after a given set of steps. This is implemented with +the {py:class}`Actions ` class +and methods `add_action`, `get_action`, and `remove_action` on the base +{py:class}`Runner ` class. +More details are given below in the Actions section. + +## Step 1: Define flowsheet + +It is assumed here that you have Python code to build, configure, and run an +IDAES flowsheet. You will first arrange this code to follow the standard "steps" +of a flowsheet workflow, which are listed in the +{py:class}`BaseFlowsheetRunner ` +class' `STEPS` attribute. Not all the steps need to be defined: the API will +skip over steps with no definition when executing a range of steps. To make the +code more structured you can also define internal sub-steps, as described later. + +The set of defined steps is: + +* build - create the flowsheet +* set_operating_conditions - set initial variable values +* set_scaling - set scaling +* initialize - initialize the flowsheet +* set_solver - choose the solver +* solve_initial - perform an initial (square problem) solve +* add_costing - add costing information (if any) +* check_model_structure - check the model for structural issues +* initialize_costing - initialize costing variables +* solve_optimization - setup and solve the optimization problem +* check_model_numerics - check the model for numerical issues + +### Example: Flash flowsheet + +This is illustrated below with a before/after of an extremely simple flowsheet +with a single Flash unit model. + +#### Before + +For now, let's assume this flowsheet uses only four of the standard steps: +"build", "set_operating_conditions", "initialize", and "solve_optimization". +Let's also assume you have four functions defined that correspond to these +steps. Below is a sample flowsheet (for a single Flash unit) that we will use as +an example: + +```{code} +from pyomo.environ import ConcreteModel, SolverFactory +from idaes.core import FlowsheetBlock +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, +) +from idaes.models.unit_models import Flash + +def build_model(): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), + activity_coeff_model="Ideal", + state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + return m + +def set_operating_conditions(m): + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + +def init_model(m): + m.fs.flash.initialize(outlvl=idaeslog.INFO) + +def solve(m): + solver = SolverFactory("ipopt") + return solver.solve(m, tee=ctx["tee"]) +``` + +#### After + +In order to make this into a +{py:class}`FlowsheetRunner `-wrapped +flowsheet, we need to do make a few changes. The modified file is shown below, +with changed lines highlighted and descriptions below. + +```{code} +:linenos: +:emphasize-lines: 7,9, 11, 25, 37, 43, 48, 12, 26, 38, 44, 49, 23, 28, 40, 44, 45, 46 + +from pyomo.environ import ConcreteModel, SolverFactory +from idaes.core import FlowsheetBlock +import idaes.logger as idaeslog +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE \ +import ( BTXParameterBlock, ) +from idaes.models.unit_models import Flash + +from idaes.core.util.structfs.fsrunner import FlowsheetRunner + +FS = FlowsheetRunner() + +@FS.step("build") +def build_model(ctx): + """Build the model.""" + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock( + valid_phase=("Liq", "Vap"), + activity_coeff_model="Ideal", + state_vars="FTPz" + ) + m.fs.flash = Flash(property_package=m.fs.properties) + # assert degrees_of_freedom(m) == 7 + ctx.model = m + +@FS.step("set_operating_conditions") + def set_operating_conditions(ctx): + """Set operating conditions.""" + m = ctx.model + m.fs.flash.inlet.flow_mol.fix(1) + m.fs.flash.inlet.temperature.fix(368) + m.fs.flash.inlet.pressure.fix(101325) + m.fs.flash.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.flash.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + m.fs.flash.heat_duty.fix(0) + m.fs.flash.deltaP.fix(0) + +@FS.step("initialize") +def init_model(ctx): + """ "Initialize the model.""" + m = ctx.model + m.fs.flash.initialize(outlvl=idaeslog.INFO) + +@FS.step("set_solver") +def set_solver(ctx): + """Set the solver.""" + ctx.solver = SolverFactory("ipopt") + +@FS.step("solve_optimization") +def solve_opt(ctx): + ctx["results"] = ctx.solver.solve(ctx.model, tee=ctx["tee"]) +``` + +Details on the changes: + +* **7**: Import the FlowsheetRunner class. +* **9**: Create a global {py:class}`FlowsheetRunner ` object, here called `FS`. +* **11, 25, 37, 43, 48**: Add a `@FS.step()` decorator in front of each function + with the name of the associated step. +* **12, 26, 38, 44, 49**: Make each function take a single argument which is a {py:class}`fsrunner.Context ` instance used to + pass state information between functions (here, that argument is named `ctx`). +* **23**: Assign the model created in the "build" step to `ctx.model`, a + standard attribute of the context object. +* **28, 40**: Replace the direct passing of the model object (in this case, + called `m`) with a context object that has a `.model` attribute. +* **44-46**: Add a function for the `set_solver` step, to select the solver + (here, IPOPT). +* **46**: In the "solve_optimization" step, assign the solver result to + `ctx["results"]`. + +## Step 2: Execute and inspect + +Once the flowsheet has been 'wrapped' in the flowsheet runner interface, it can +be run and manipulated via the wrapper object. The basic steps to do this are: +import the flowsheet-runner object, build and execute the flowsheet, and inspect +the flowsheet. + +Some examples of doing this are shown in the +example notebooks found under the `docs/examples/structfs` directory +([docs link](/examples/structfs/index)). + +## Actions + +```{autodoc2-docstring} structfs.runner.Action +render_plugin = "myst" +no_index = true +``` + +''' diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 090e894b23..b01ba59ac8 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -84,7 +84,9 @@ def __init__(self, solver=None, tee=False): self._ann = {} super().__init__(self.STEPS) # needs to be last - def run_steps(self, first: str = "", last: str = "", **kwargs): + def run_steps( + self, first: str = Runner.STEP_ANY, last: str = Runner.STEP_ANY, **kwargs + ): """Run the steps. Before it calls the superclass to run the steps, checks @@ -94,7 +96,7 @@ def run_steps(self, first: str = "", last: str = "", **kwargs): """ from_step_name = self.normalize_name(first) if ( - from_step_name == "" + from_step_name == "-" or from_step_name == self.build_step or self._context.model is None ): @@ -250,28 +252,5 @@ def build(self): def solve_initial(self): self.run_steps(last="solve_initial") - def run_steps(self, after=None, before=None, **kwargs): - if after is not None: - if "first" in kwargs: - raise ValueError("Cannot specify both 'after' and 'first'") - kwargs["first"] = before - if "endpoints" in kwargs: - ep = list(kwargs["endpoints"]) - ep[0] = False - kwargs["endpoints"] = tuple(ep) - else: - kwargs["endpoints"] = (False, True) - if before is not None: - if "last" in kwargs: - raise ValueError("Cannot specify both 'before' and 'last'") - kwargs["last"] = before - if "endpoints" in kwargs: - ep = list(kwargs["endpoints"]) - ep[1] = False - kwargs["endpoints"] = tuple(ep) - else: - kwargs["endpoints"] = (True, False) - return super().run_steps(**kwargs) - def show_diagram(self): return display_connectivity(input_model=self.model) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index f90cb270cb..0e5d7696e9 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -57,6 +57,8 @@ def add_substep(self, name: str, func: Callable): class Runner: """Run a set of defined steps.""" + STEP_ANY = "-" + def __init__(self, steps: Sequence[str]): """Constructor. @@ -127,31 +129,48 @@ def run_step(self, name): self.run_steps(first=name, last=name) def run_steps( - self, - first: str = "", - last: str = "", - endpoints: tuple[bool, bool] = (True, True), + self, first: str = "", last: str = "", after: str = "", before: str = "" ): - """Run steps from `first` to step `last`. + """Run steps from `first`/`after` to step `last`/`before`. + + Specify only one of the first/after and last/before pairs. + + Use the special value `STEP_ANY` to mean the first or last defined step. Args: - first: First step to run - last: Last step to run - endpoints: Whether to include (first, last) in steps (default=True for both) + first: First step to run (include) + after: Run first defined step after this one (exclude) + last: Last step to run (include) + before: Run last defined step before this one (exclude) Raises: KeyError: Unknown or undefined step given - ValueError: Steps out of order (`from` after `to`) + ValueError: Steps out of order or both first/after or before/last given """ + if first and after: + raise ValueError("Cannot specify both 'after' and 'first'") + if last and before: + raise ValueError("Cannot specify both 'before' and 'last'") if not self._steps: return # nothing to do, no steps defined + args = (first or after, last or before, (bool(first), bool(last))) + return self._run_steps(*args) + def _run_steps( + self, + first: str, + last: str, + endpoints: tuple[bool, bool], + ): names = (self.normalize_name(first), self.normalize_name(last)) + # get indexes of first/last step step_range = [-1, -1] for i, step_name in enumerate(names): - if step_name == "": - idx = self._first_step() if i == 0 else self._last_step() + if step_name == self.STEP_ANY: # meaning first or last defined + # this will always find a step as long as there is at least one, + # which we checked before calling this function + idx = self._find_step(reverse=(i == 1)) else: try: idx = self._step_names.index(step_name) @@ -161,23 +180,30 @@ def run_steps( raise KeyError(f"Empty step: {step_name}") step_range[i] = idx + # check that first comes before last if step_range[0] > step_range[1]: raise ValueError( "Steps out of order: {names[0]}={step_range[0]} > {names[1]}={step_range[1]}" ) + # execute overall before-run action for action in self._actions.values(): action.before_run() + # run each (defined) step for i in range(step_range[0], step_range[1] + 1): + # check whether to skip endpoints in range if (i == step_range[0] and not endpoints[0]) or ( i == step_range[1] and not endpoints[1] ): continue + # get the step associated with the index step = self._steps.get(self._step_names[i], None) + # if the step is defined, run it if step: step.func(self._context) + # execute overall after-run action for action in self._actions.values(): action.after_run() @@ -231,16 +257,13 @@ def remove_action(self, name: str): """ del self._actions[name] - def _first_step(self): - for i, name in enumerate(self._step_names): - if name in self._steps: - return i - assert False, "No first step defined" # should not get here - - def _last_step(self): - for i in range(len(self._step_names) - 1, -1, -1): - name = self._step_names[i] - if name in self._steps: + def _find_step(self, reverse=False): + start_step, end_step, incr = ( + (0, len(self._step_names), 1), + (len(self._step_names) - 1, -1, -1), + )[reverse] + for i in range(start_step, end_step, incr): + if self._step_names[i] in self._steps: return i return -1 @@ -325,7 +348,85 @@ def wrapper(*args, **kwargs): class Action: - """Do something before and/or after each step and/or run performed by a `Runner`.""" + """The Action class implements a simple framework to run arbitrary + functions before and/or after each step and/or run performed + by the `Runner` class. + + To create and use your own Action, inherit from this class + and then define one or more of the methods: + + * before_step - Called before a given step is executed + * after_step - Called after a given step is executed + * before/after_substep - Called before/after a named + substep is executed (these can have arbitrary names) + * before_run - Called before the first step is executed + * after_run - Called after the last step is executed + + Then add the action to the `Runner` class (e.g., `FlowsheetRunner`) + instance with `add_action()`. Note that you pass the action + *class*, not instance. Additional settings can be passed to + the created action instance with arguments to `add_action`. + Also note that the *name* argument is used to retrieve the + action instance later, as needed. + + ### Example + + Below is a simple example that prints a message + before/after every step and prints the total number + of steps run at the end of the run. + + ```{code} + class HelloGoodbye(Action): + "Example action, for tutorial purposes." + + def __init__(self, runner, hello="hi", goodbye="bye", **kwargs): + super().__init__(runner, **kwargs) + self._hello, self._goodbye = hello, goodbye + self.step_counter = -1 + + def before_run(self): + self.step_counter = 0 + + def before_step(self, name): + print(f">> {self._hello} from step {name}") + + def before_substep(self, name, subname): + print(f" >> {self._hello} from sub-step {subname}") + + def after_step(self, name): + print(f"<< {self._goodbye} from step {name}") + self.step_counter += 1 + + def after_substep(self, name, subname): + print(f" << {self._goodbye} from sub-step {subname}") + + def after_run(self): + print(f"Ran {self.step_counter} steps") + ``` + + You could add the above example to a Runner subclass, + here called `my_runner`, like this: + + ```{code} + my_runner.add_action( + "hg", + HelloGoodbye, + hello="Greetings and salutations", + goodbye="Smell you later", + ) + ``` + + Then, after running steps, you could print + the value of the *step_counter* attribute with: + + ```{code} + print(my_runner.get_action("hg").step_counter) + ``` + + See the pre-defined actions in the + {py:mod}`runner_actions ` + module, and their usage in the `FlowsheetRunner` class, for more examples. + """ def __init__(self, runner: Runner, log: Optional[logging.Logger] = None): """Constructor diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 96b686a3ab..ca4396d73f 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -32,6 +32,34 @@ from .fsrunner import FlowsheetRunner +class HelloGoodbye(Action): + """Example action, for tutorial purposes.""" + + def __init__(self, runner, hello="hi", goodbye="bye", **kwargs): + super().__init__(runner, **kwargs) + self._hello, self._goodbye = hello, goodbye + self.step_counter = -1 + + def before_run(self): + self.step_counter = 0 + + def before_step(self, name): + print(f">> {self._hello} from step {name}") + + def before_substep(self, name, subname): + print(f" >> {self._hello} from sub-step {subname}") + + def after_step(self, name): + print(f"<< {self._goodbye} from step {name}") + self.step_counter += 1 + + def after_substep(self, name, subname): + print(f" << {self._goodbye} from sub-step {subname}") + + def after_run(self): + print(f"Ran {self.step_counter} steps") + + class Timer(Action): """Simple step/run timer action.""" diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 6fd5bacfd6..1cbfe94454 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -11,7 +11,8 @@ # for full copyright and license information. ############################################################################### import pytest -from pyomo.environ import value +from pyomo.environ import ConcreteModel +from idaes.core import FlowsheetBlock from ..fsrunner import FlowsheetRunner from .flash_flowsheet import FS as flash_fs @@ -23,7 +24,9 @@ @fsr.step("build") def build_it(context): print("flowsheet - build") - assert context.model.fs is not None + context.model = ConcreteModel() + print(f"@@ build_it: id(model)={id(context.model)}") + context.model.fs = FlowsheetBlock(dynamic=False) add_units(context.model) @@ -60,26 +63,47 @@ def test_rerun(): fsr.run_steps() first_model = fsr.model + print(f"@@ test: id(model)={id(fsr.model)}") + + print("-- rerun --") # model not changed fsr.run_steps("solve_optimization") assert fsr.model == first_model + +@pytest.mark.unit +def test_rerun_reset(): + fsr.run_steps() + print(f"@@ test: id(model)={id(fsr.model)}") + first_model = fsr.model + + print("-- rerun --") + # reset forces new model fsr.reset() fsr.run_steps("solve_optimization") assert fsr.model != first_model second_model = fsr.model + +@pytest.mark.unit +def test_rerun_frombuild(): + fsr.run_steps() + first_model = fsr.model + print(f"@@ test: id(model)={id(fsr.model)}") + + print("-- rerun --") + # running from build also creates new model fsr.run_steps("build", "add_costing") - assert fsr.model != second_model + assert fsr.model != first_model @pytest.mark.unit def test_annotation(): runner = flash_fs - runner.run_steps(last="build") + runner.run_steps("build") print(runner.timings.history) ann = runner.annotate_var # alias diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index ba24f9f312..3dcd1fe427 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -12,6 +12,7 @@ ############################################################################### import pytest from ..runner import Runner +from .. import runner_actions ## -- setup -- @@ -124,4 +125,13 @@ def do_bad3(ctx): return -# 64, 76, 143, 146, 179, 223, 225 +@pytest.mark.unit +def test_hellogoodbye(): + simple.add_action( + "hg", + runner_actions.HelloGoodbye, + hello="Greetings and salutations", + goodbye="Smell you later", + ) + simple.run_steps(first="-", last="-") + assert simple.get_action("hg").step_counter == 2 From a13a37db5c5fcec0fce4b8f611779628338ef820 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 2 Jan 2026 15:06:40 -0800 Subject: [PATCH 29/73] tests failing but saving doctesting --- docs/conf.py | 14 ++- idaes/core/util/doctesting.py | 92 +++++++++++++++++++ idaes/core/util/structfs/__init__.py | 36 +++++--- idaes/core/util/structfs/runner.py | 2 + idaes/core/util/structfs/runner_actions.py | 28 ------ .../core/util/structfs/tests/test_fsrunner.py | 33 +++++++ idaes/core/util/structfs/tests/test_runner.py | 8 +- 7 files changed, 171 insertions(+), 42 deletions(-) create mode 100644 idaes/core/util/doctesting.py diff --git a/docs/conf.py b/docs/conf.py index 4a6441c4df..9dff9bd2c6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,9 +37,21 @@ "sphinx.ext.doctest", "sphinx_copybutton", "nbsphinx", + # MystMD extenstions "myst_parser", + "autodoc2", ] +# Myst autodoc2 (experimental) +autodoc2_packages = [ + "../idaes/core/util/structfs", +] +autodoc2_output_dir = "apidoc2" # keep separated +autodoc2_render_plugin = "myst" +autodoc2_docstring_parser_regexes = [ + # render docstrings in matching files as Markdown + ("../idaes/core/util/structfs/.*", "myst"), +] # Put type hints in the description, not signature autodoc_typehints = "description" @@ -53,7 +65,7 @@ # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = [".rst"] +source_suffix = [".rst", ".md"] # The encoding of source files. # # source_encoding = 'utf-8-sig' diff --git a/idaes/core/util/doctesting.py b/idaes/core/util/doctesting.py new file mode 100644 index 0000000000..ff1b652d06 --- /dev/null +++ b/idaes/core/util/doctesting.py @@ -0,0 +1,92 @@ +""" +Utility code for documentation-related tests. +""" + +__author__ = "Dan Gunter (LBNL)" + +import re + + +class Docstring: + """Pick the marked code sections out of a docstring. + + This is useful for (at least) writing tests that use the code + snippets in a markdown (e.g. myst and with autodoc2) docstring, + without duplicating those code snippets across the original module + and test file. + """ + + def __init__(self, text: str, style: str = "markdown"): + self._code = {} + if style == "markdown": + self._init_markdown(text) + # TODO: also handle RST + else: + raise ValueError( + f"Unknown docstring style: {style}. " f"Must be one of: markdown" + ) + + def code(self, section: str, func_prefix: str = None) -> str: + """Get code section. + + Args: + section: Label for the section + func_prefix: Add this prefix to all top-level + function definitions. If not provided, + the prefix will be the section name and an + underscore. Give an empty string to + have no prefixes. + + Returns: + Text of the code section, for exec() + """ + if func_prefix is None: + func_prefix = f"{section}_" + section_lines = self._prefix_def(self._code[section], func_prefix) + return "\n".join(section_lines) + + def _prefix_def(self, lines: list[str], prefix: str): + # Note that this will only match function definitions at + # module scope (not methods in a class, which are already, + # presumably, in a unique-enough namespace). + expr = re.compile(r"def\s+(?P\w+)") + result = [] + for line in lines: + m = expr.match(line) + if m: + # prepend prefix to function name + pos = m.start(1) + line = line[:pos] + prefix + line[pos:] + result.append(line) + return result + + def _init_markdown(self, text: str): + lines = text.split("\n") + state = 0 + dedent = None + for line in lines: + ls_line = line.lstrip() + if state == 0 and ls_line.startswith("```{code}"): + indent = len(line) - len(ls_line) # spaces to left + state = 1 + section_name = None + section_lines = [] + elif state > 0: + if ls_line.startswith("```"): + if section_name is None: + pass # silently ignore + elif len(section_lines) == 0: + pass # silently ignore + else: + self._code[section_name] = section_lines + state = 0 + elif state == 1: + if ls_line.startswith(":label:"): + section_name = ls_line[7:].strip() + elif ls_line == "" or ls_line.startswith(":"): + pass + else: + state = 2 # got past metadata + section_lines.append(line[indent:].rstrip()) + else: + section_lines.append(line[indent:].rstrip()) diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index 7237f0ebca..28ab3dc0ee 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -61,11 +61,12 @@ steps. Below is a sample flowsheet (for a single Flash unit) that we will use as an example: -```{code} -from pyomo.environ import ConcreteModel, SolverFactory +```{code} python +:label: before +from pyomo.environ import ConcreteModel, SolverFactory, SolverStatus from idaes.core import FlowsheetBlock from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( - BTXParameterBlock, + BTXParameterBlock, ) from idaes.models.unit_models import Flash @@ -73,13 +74,12 @@ def build_model(): m = ConcreteModel() m.fs = FlowsheetBlock(dynamic=False) m.fs.properties = BTXParameterBlock( - valid_phase=("Liq", "Vap"), - activity_coeff_model="Ideal", - state_vars="FTPz" + valid_phase=("Liq", "Vap"), activity_coeff_model="Ideal", state_vars="FTPz" ) m.fs.flash = Flash(property_package=m.fs.properties) return m + def set_operating_conditions(m): m.fs.flash.inlet.flow_mol.fix(1) m.fs.flash.inlet.temperature.fix(368) @@ -89,12 +89,15 @@ def set_operating_conditions(m): m.fs.flash.heat_duty.fix(0) m.fs.flash.deltaP.fix(0) + def init_model(m): - m.fs.flash.initialize(outlvl=idaeslog.INFO) + m.fs.flash.initialize() + def solve(m): - solver = SolverFactory("ipopt") - return solver.solve(m, tee=ctx["tee"]) + solver = SolverFactory("ipopt") + return solver.solve(m, tee=True) + ``` #### After @@ -105,6 +108,7 @@ def solve(m): with changed lines highlighted and descriptions below. ```{code} +:label: after :linenos: :emphasize-lines: 7,9, 11, 25, 37, 43, 48, 12, 26, 38, 44, 49, 23, 28, 40, 44, 45, 46 @@ -134,7 +138,7 @@ def build_model(ctx): ctx.model = m @FS.step("set_operating_conditions") - def set_operating_conditions(ctx): +def set_operating_conditions(ctx): """Set operating conditions.""" m = ctx.model m.fs.flash.inlet.flow_mol.fix(1) @@ -149,7 +153,7 @@ def set_operating_conditions(ctx): def init_model(ctx): """ "Initialize the model.""" m = ctx.model - m.fs.flash.initialize(outlvl=idaeslog.INFO) + m.fs.flash.initialize() @FS.step("set_solver") def set_solver(ctx): @@ -185,7 +189,15 @@ def solve_opt(ctx): import the flowsheet-runner object, build and execute the flowsheet, and inspect the flowsheet. -Some examples of doing this are shown in the +For example to run all the steps and get the status of the solve, you +could do this: + +```{code} +FS.run_steps() +assert FS.results.solver.status == SolverStatus.ok +``` + +Some more examples of using the FlowsheetRunner are shown in the example notebooks found under the `docs/examples/structfs` directory ([docs link](/examples/structfs/index)). diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 0e5d7696e9..4da3d2a989 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -376,6 +376,8 @@ class Action: of steps run at the end of the run. ```{code} + :label: hellogoodbye + from idaes.core.util.structfs.runner import Action class HelloGoodbye(Action): "Example action, for tutorial purposes." diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index ca4396d73f..96b686a3ab 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -32,34 +32,6 @@ from .fsrunner import FlowsheetRunner -class HelloGoodbye(Action): - """Example action, for tutorial purposes.""" - - def __init__(self, runner, hello="hi", goodbye="bye", **kwargs): - super().__init__(runner, **kwargs) - self._hello, self._goodbye = hello, goodbye - self.step_counter = -1 - - def before_run(self): - self.step_counter = 0 - - def before_step(self, name): - print(f">> {self._hello} from step {name}") - - def before_substep(self, name, subname): - print(f" >> {self._hello} from sub-step {subname}") - - def after_step(self, name): - print(f"<< {self._goodbye} from step {name}") - self.step_counter += 1 - - def after_substep(self, name, subname): - print(f" << {self._goodbye} from sub-step {subname}") - - def after_run(self): - print(f"Ran {self.step_counter} steps") - - class Timer(Action): """Simple step/run timer action.""" diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 1cbfe94454..689481f8c0 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -15,6 +15,8 @@ from idaes.core import FlowsheetBlock from ..fsrunner import FlowsheetRunner from .flash_flowsheet import FS as flash_fs +from idaes.core.util import structfs +from idaes.core.util.doctesting import Docstring # -- setup -- @@ -138,3 +140,34 @@ def test_annotation(): assert runner.model.fs.flash.inlet.flow_mol[0].value == 1 assert ann["fs.flash._temperature_inlet_ref"]["units"] == "Centipedes" assert ann["fs.flash.deltaP"]["is_input"] == False + + +##### +# Test the code blocks in the structfs/__init__.py +##### + +# pacify linters: +sfi_before_build_model = sfi_before_set_operating_conditions = sfi_before_init_model = ( + sfi_before_solve +) = lambda x: None +SolverStatus, FS = None, None + +# load the functions from the docstring +_ds = Docstring(structfs.__doc__) +exec(_ds.code("before", func_prefix="sfi_before_")) +exec(_ds.code("after", func_prefix="sfi_after_")) + + +@pytest.mark.unit +def test_sfi_before(): + m = sfi_before_build_model() + sfi_before_set_operating_conditions(m) + sfi_before_init_model(m) + result = sfi_before_solve(m) + assert result.solver.status == SolverStatus.ok + + +@pytest.mark.unit +def test_sfi_after(): + FS.run_steps() + assert FS.results.solver.status == SolverStatus.ok diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index 3dcd1fe427..ecb7346a0c 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -13,6 +13,7 @@ import pytest from ..runner import Runner from .. import runner_actions +from idaes.core.util.doctesting import Docstring ## -- setup -- @@ -125,11 +126,16 @@ def do_bad3(ctx): return +# load the HelloGoodbye class from the Action docstring +HelloGoodbye = None # pacify linters +exec(Docstring(runner_actions.Action.__doc__).code("hellogoodbye")) + + @pytest.mark.unit def test_hellogoodbye(): simple.add_action( "hg", - runner_actions.HelloGoodbye, + HelloGoodbye, hello="Greetings and salutations", goodbye="Smell you later", ) From aa57d289e89e65f90863ce3ede5fac885584982a Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 4 Jan 2026 07:25:45 -0800 Subject: [PATCH 30/73] documented annotation --- docs/conf.py | 2 + docs/how_to_guides/structfs/index.md | 4 +- idaes/core/util/doctesting.py | 4 +- idaes/core/util/structfs/__init__.py | 40 ++++++++----- idaes/core/util/structfs/fsrunner.py | 60 +++++++++++++++---- idaes/core/util/structfs/runner.py | 2 +- .../util/structfs/tests/flash_flowsheet.py | 1 - .../core/util/structfs/tests/test_fsrunner.py | 19 +++++- 8 files changed, 98 insertions(+), 34 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9dff9bd2c6..4cf3375dec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,6 +12,7 @@ # For importing from idaes. sys.path.insert(0, os.path.abspath("..")) +sys.path.insert(0, os.path.abspath(".")) # -- General configuration ------------------------------------------------ @@ -52,6 +53,7 @@ # render docstrings in matching files as Markdown ("../idaes/core/util/structfs/.*", "myst"), ] + # Put type hints in the description, not signature autodoc_typehints = "description" diff --git a/docs/how_to_guides/structfs/index.md b/docs/how_to_guides/structfs/index.md index 11075d8f22..0f3f58af6a 100644 --- a/docs/how_to_guides/structfs/index.md +++ b/docs/how_to_guides/structfs/index.md @@ -1,6 +1,6 @@ # Flowsheet Runner ```{autodoc2-docstring} structfs -render_plugin = "myst" -no_index = true +:parser: myst ``` + diff --git a/idaes/core/util/doctesting.py b/idaes/core/util/doctesting.py index ff1b652d06..9f4d050513 100644 --- a/idaes/core/util/doctesting.py +++ b/idaes/core/util/doctesting.py @@ -16,6 +16,8 @@ class Docstring: and test file. """ + LABEL_OPTION = ":name:" + def __init__(self, text: str, style: str = "markdown"): self._code = {} if style == "markdown": @@ -81,7 +83,7 @@ def _init_markdown(self, text: str): self._code[section_name] = section_lines state = 0 elif state == 1: - if ls_line.startswith(":label:"): + if ls_line.startswith(self.LABEL_OPTION): section_name = ls_line[7:].strip() elif ls_line == "" or ls_line.startswith(":"): pass diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index 28ab3dc0ee..a0dc2694dd 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -1,13 +1,13 @@ ''' The 'flowsheet runner' is an API in the -{py:mod}`idaes.core.util.structfs` subpackage, and in -particular that package's {py:mod}`runner ` and -{py:mod}`fsrunner ` modules. +{py:mod}`structfs` subpackage, and in +particular that package's {py:mod}`runner ` and +{py:mod}`fsrunner ` modules. ## Overview The core idea of the -{py:class}`FlowsheetRunner ` class is +{py:class}`FlowsheetRunner ` class is that flowsheets should follow a standard set of "steps". By standardizing the naming and ordering of these steps, it becomes easier to build tools that run and inspect flowsheets. The Python mechanics of this are to put each step in a @@ -19,9 +19,9 @@ The framework can perform arbitrary actions before and after each run, and before and after a given set of steps. This is implemented with -the {py:class}`Actions ` class +the {py:class}`Actions ` class and methods `add_action`, `get_action`, and `remove_action` on the base -{py:class}`Runner ` class. +{py:class}`Runner ` class. More details are given below in the Actions section. ## Step 1: Define flowsheet @@ -29,7 +29,7 @@ It is assumed here that you have Python code to build, configure, and run an IDAES flowsheet. You will first arrange this code to follow the standard "steps" of a flowsheet workflow, which are listed in the -{py:class}`BaseFlowsheetRunner ` +{py:class}`BaseFlowsheetRunner ` class' `STEPS` attribute. Not all the steps need to be defined: the API will skip over steps with no definition when executing a range of steps. To make the code more structured you can also define internal sub-steps, as described later. @@ -62,7 +62,7 @@ an example: ```{code} python -:label: before +:name: before from pyomo.environ import ConcreteModel, SolverFactory, SolverStatus from idaes.core import FlowsheetBlock from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( @@ -103,12 +103,12 @@ def solve(m): #### After In order to make this into a -{py:class}`FlowsheetRunner `-wrapped +{py:class}`FlowsheetRunner `-wrapped flowsheet, we need to do make a few changes. The modified file is shown below, with changed lines highlighted and descriptions below. ```{code} -:label: after +:name: after :linenos: :emphasize-lines: 7,9, 11, 25, 37, 43, 48, 12, 26, 38, 44, 49, 23, 28, 40, 44, 45, 46 @@ -168,10 +168,10 @@ def solve_opt(ctx): Details on the changes: * **7**: Import the FlowsheetRunner class. -* **9**: Create a global {py:class}`FlowsheetRunner ` object, here called `FS`. +* **9**: Create a global {py:class}`FlowsheetRunner ` object, here called `FS`. * **11, 25, 37, 43, 48**: Add a `@FS.step()` decorator in front of each function with the name of the associated step. -* **12, 26, 38, 44, 49**: Make each function take a single argument which is a {py:class}`fsrunner.Context ` instance used to +* **12, 26, 38, 44, 49**: Make each function take a single argument which is a {py:class}`fsrunner.Context ` instance used to pass state information between functions (here, that argument is named `ctx`). * **23**: Assign the model created in the "build" step to `ctx.model`, a standard attribute of the context object. @@ -204,8 +204,20 @@ def solve_opt(ctx): ## Actions ```{autodoc2-docstring} structfs.runner.Action -render_plugin = "myst" -no_index = true +``` + +## Annotation + +You can also 'annotate' variables for special +treatment in display, etc. with the +`annotate_var` function in the +{py:class}`FlowsheetRunner ` class. + +```{autodoc2-object} structfs.fsrunner.FlowsheetRunner.annotate_var +``` + +```{autodoc2-docstring} structfs.fsrunner.FlowsheetRunner.annotate_var +:parser: myst ``` ''' diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index b01ba59ac8..b00eeedfba 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -137,22 +137,57 @@ def annotate_var( """Annotate a Pyomo variable. Args: - variable: Pyomo variable being annotated - key: Key for this block in dict. Defaults to object name. - title: Name / title of the block. Defaults to object name. - desc: Description of the block. Defaults to object name. - units: Units. Defaults to string value of native units. - rounding: Significant digits - is_input: Is this variable an input - is_output: Is this variable an output - input_category: Name of input grouping to display under - output_category: Name of output grouping to display under + + - variable: Pyomo variable being annotated + - key: Key for this block in dict. Defaults to object name. + - title: Name / title of the block. Defaults to object name. + - desc: Description of the block. Defaults to object name. + - units: Units. Defaults to string value of native units. + - rounding: Significant digits + - is_input: Is this variable an input + - is_output: Is this variable an output + - input_category: Name of input grouping to display under + - output_category: Name of output grouping to display under Returns: - Input block (for chaining) + + - Input block (for chaining) Raises: - ValueError: if `is_input` and `is_output` are both False + + - ValueError: if `is_input` and `is_output` are both False + + ### Example + + Here is an example of annotating a single Pyomo variable. + You can apply this same technique to any Pyomo, and thus IDAES, + object. + + ```{code} + :name: annotate_vars + from idaes.core.util.structfs.fsrunner import FlowsheetRunner + from pyomo.environ import * + + def example(f: FlowsheetRunner): + v = Var() + v.construct() + f.annotate_var(v, key="example", title="Example variable").fix(1) + ``` + + To retrieve the annotated variables, use the `annotated_vars` + property: + + ```{code} + example(fr := FlowsheetRunner()) + print(fr.annotated_vars) + # prints something like this: + # {'example': {'var': , + # 'fullname': 'ScalarVar', 'title': 'Example variable', + # 'description': 'ScalarVar', 'units': 'dimensionless', + # 'rounding': 3, 'is_input': True, 'is_output': True, 'input_category': 'main', + # 'output_category': 'main'}} + ``` + """ if not is_input and not is_output: raise ValueError("One of 'is_input', 'is_output' must be True") @@ -182,6 +217,7 @@ def annotated_vars(self) -> dict[str,]: class FlowsheetRunner(BaseFlowsheetRunner): + """Interface for running and inspecting IDAES flowsheets.""" class DegreesOfFreedom: """Wrapper for the UnitDofChecker action""" diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 4da3d2a989..53db9c3ef5 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -376,7 +376,7 @@ class Action: of steps run at the end of the run. ```{code} - :label: hellogoodbye + :name: hellogoodbye from idaes.core.util.structfs.runner import Action class HelloGoodbye(Action): "Example action, for tutorial purposes." diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index f55d5d3a0a..61a470b408 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -47,7 +47,6 @@ def build_model(ctx): m.fs.flash = Flash(property_package=m.fs.properties) # assert degrees_of_freedom(m) == 7 ctx.model = m - print("@@ built flash") @FS.step("set_operating_conditions") diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 689481f8c0..741504b447 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -13,7 +13,7 @@ import pytest from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock -from ..fsrunner import FlowsheetRunner +from ..fsrunner import FlowsheetRunner, BaseFlowsheetRunner from .flash_flowsheet import FS as flash_fs from idaes.core.util import structfs from idaes.core.util.doctesting import Docstring @@ -77,7 +77,6 @@ def test_rerun(): @pytest.mark.unit def test_rerun_reset(): fsr.run_steps() - print(f"@@ test: id(model)={id(fsr.model)}") first_model = fsr.model print("-- rerun --") @@ -93,7 +92,6 @@ def test_rerun_reset(): def test_rerun_frombuild(): fsr.run_steps() first_model = fsr.model - print(f"@@ test: id(model)={id(fsr.model)}") print("-- rerun --") @@ -171,3 +169,18 @@ def test_sfi_before(): def test_sfi_after(): FS.run_steps() assert FS.results.solver.status == SolverStatus.ok + + +# pacify linters +annotate_vars_example = lambda x: None +# load example function from docstring +_ds = Docstring(BaseFlowsheetRunner.annotate_var.__doc__) +exec(_ds.code("annotate_vars")) + + +@pytest.mark.unit +def test_ann_docs(): + annotate_vars_example(fr := FlowsheetRunner()) + ex = fr.annotated_vars["example"] + assert ex["fullname"] == "ScalarVar" + assert ex["title"] == "Example variable" From 6cd009ed1b38f1224b19eb6d3a1e12d932d4d14a Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 6 Jan 2026 09:45:16 -0500 Subject: [PATCH 31/73] fixed run_steps args --- idaes/core/util/structfs/runner.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 53db9c3ef5..2efaeb6892 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -153,7 +153,11 @@ def run_steps( raise ValueError("Cannot specify both 'before' and 'last'") if not self._steps: return # nothing to do, no steps defined - args = (first or after, last or before, (bool(first), bool(last))) + args = ( + first or after, + last or before, + (bool(first) or not bool(after), bool(last) or not bool(before)), + ) return self._run_steps(*args) def _run_steps( @@ -163,6 +167,7 @@ def _run_steps( endpoints: tuple[bool, bool], ): names = (self.normalize_name(first), self.normalize_name(last)) + print(f"@@ RUN STEPS: first={first} last={last} endpoints={endpoints}") # get indexes of first/last step step_range = [-1, -1] @@ -267,8 +272,8 @@ def _find_step(self, reverse=False): return i return -1 - @staticmethod - def normalize_name(s: Optional[str]) -> str: + @classmethod + def normalize_name(cls, s: Optional[str]) -> str: """Normalize a step name. Args: s: Step name @@ -276,7 +281,7 @@ def normalize_name(s: Optional[str]) -> str: Returns: normalized name """ - return "" if s is None else s.lower() + return cls.STEP_ANY if not s else s.lower() def _step_begin(self, name: str): for action in self._actions.values(): From 8691d8e537de6a91c2f3affe3ee4b07f9be93029 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 8 Jan 2026 09:13:12 -0500 Subject: [PATCH 32/73] autodoc2 added --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index c2d1b60394..4d14f31772 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -11,6 +11,7 @@ sphinx-book-theme<=1.1.2,>=1.0.0 sphinx-copybutton==0.5.2 nbsphinx # for Jupyter Notebooks in the docs myst-parser # for MystMD +sphinx-autodoc2>=0.5.0 # for autodoc2 ### testing and linting # TODO/NOTE pytest is specified as a dependency in setup.py, but we might want to pin a specific version here From f041fbce89ae31f458b0f0b2661847057b15d7ce Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 8 Jan 2026 09:20:56 -0500 Subject: [PATCH 33/73] typos --- .github/workflows/typos.toml | 3 +++ docs/conf.py | 2 +- idaes/core/scaling/custom_scaler_base.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index ad5d99ec8e..1e7b78e10c 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -58,3 +58,6 @@ PN = "PN" hd = "hd" Tge = "Tge" iy = "iy" + +# more chemE abbreviations +HDA = "HDA" diff --git a/docs/conf.py b/docs/conf.py index 4cf3375dec..1626864a4d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -38,7 +38,7 @@ "sphinx.ext.doctest", "sphinx_copybutton", "nbsphinx", - # MystMD extenstions + # MystMD extensions "myst_parser", "autodoc2", ] diff --git a/idaes/core/scaling/custom_scaler_base.py b/idaes/core/scaling/custom_scaler_base.py index 06672e1204..fddf9725e3 100644 --- a/idaes/core/scaling/custom_scaler_base.py +++ b/idaes/core/scaling/custom_scaler_base.py @@ -367,7 +367,7 @@ def _scale_component_by_default( sf = self.get_scaling_factor(component) if sf is None or overwrite: # If the user told us to overwrite scaling factors, then - # accepting a preexisiting scaling factor is not good enough. + # accepting a preexisting scaling factor is not good enough. # They need to go manually alter the default entry to # DefaultScalingRecommendation.userInputRecommended raise ValueError( From c31c0570b519e732296e3ac4225ead79e9d0bff3 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 8 Jan 2026 09:35:08 -0500 Subject: [PATCH 34/73] ok w/o connectivity --- idaes/core/util/structfs/fsrunner.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index b00eeedfba..753cacc660 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -22,8 +22,11 @@ from pyomo.environ import units as pyunits from idaes.core import FlowsheetBlock -from idaes_connectivity.base import Connectivity -from idaes_connectivity.jupyter import display_connectivity +try: + from idaes_connectivity.base import Connectivity + from idaes_connectivity.jupyter import display_connectivity +except ImportError: + Connectivity = None # package from .runner import Runner @@ -289,4 +292,7 @@ def solve_initial(self): self.run_steps(last="solve_initial") def show_diagram(self): - return display_connectivity(input_model=self.model) + if Connectivity is not None: + return display_connectivity(input_model=self.model) + else: + return "" From a512f75c90b98da0b24c46ba321dca677efce016 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 11 Jan 2026 10:23:01 -0800 Subject: [PATCH 35/73] Newcastle is playing well --- idaes/core/util/structfs/runner.py | 3 ++ idaes/core/util/structfs/runner_actions.py | 6 +++ idaes/core/util/structfs/tests/test_runner.py | 15 +++++- .../structfs/tests/test_runner_actions.py | 47 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 2efaeb6892..50feaa6aad 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -476,3 +476,6 @@ def before_run(self): def after_run(self): """Perform this action after a run ends.""" return + + def report(self) -> dict: + return {} diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 96b686a3ab..9b107071e6 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -136,6 +136,9 @@ def summary(self, stream=None, run_idx=-1) -> str: def _ipython_display_(self): print(self.summary()) + def report(self) -> dict: + return self.step_times[-1].copy() # most recent run + # Hold degrees of freedom for one FlowsheetRunner 'step' # {key=component: value=dof} @@ -287,6 +290,9 @@ def steps(self, only_with_data: bool = False) -> list[str]: return [s for s in self._steps if s in self._steps_dof] return list(self._steps) + def report(self) -> dict: + return {"steps": self.get_dof(), "model": self.get_dof_model()} + @staticmethod def _get_dof(block, fix_inlets: bool = True): if fix_inlets: diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index ecb7346a0c..a841b792a5 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -11,7 +11,7 @@ # for full copyright and license information. ############################################################################### import pytest -from ..runner import Runner +from ..runner import Runner, Action from .. import runner_actions from idaes.core.util.doctesting import Docstring @@ -141,3 +141,16 @@ def test_hellogoodbye(): ) simple.run_steps(first="-", last="-") assert simple.get_action("hg").step_counter == 2 + + +class RunActionExample(Action): + def report(self) -> dict: + return {"example": True} + + +@pytest.mark.unit +def test_runaction(): + simple.reset() + simple.add_action("foo", RunActionExample) + simple.run_steps() + assert simple.get_action("foo").report() == {"example": True} diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 23b6905129..7156f54bc8 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -141,3 +141,50 @@ def test_unit_dof_action_getters(): assert dofs[0] != dofs[1] assert act.steps() == act.steps(only_with_data=True) + + +@pytest.mark.unit +def test_timer_report(): + rn = flash_flowsheet.FS + rn.reset() + rn.add_action("timer", Timer) + rn.run_steps() + report = rn.get_action("timer").report() + # {'build': 0.053082942962646484, + # 'set_operating_conditions': 0.0004742145538330078, + # 'initialize': 0.22397446632385254, + # 'set_solver': 7.581710815429688e-05, + # 'solve_initial': 0.03623509407043457} + expect_steps = ( + "build", + "set_operating_conditions", + "initialize", + "set_solver", + "solve_initial", + ) + assert report + for step_name in expect_steps: + assert step_name in report + assert report[step_name] < 1 + + +@pytest.mark.unit +def test_dof_report(): + rn = flash_flowsheet.FS + rn.reset() + check_steps = ( + "build", + "set_operating_conditions", + "initialize", + "solve_initial", + ) + rn.add_action("dof", UnitDofChecker, "fs", check_steps) + rn.run_steps() + report = rn.get_action("dof").report() + print(f"@@ REPORT:\n{report}") + assert report + assert report["model"] == 0 # model has DOF=0 + for step_name in check_steps: + assert step_name in report["steps"] + for unit, value in report["steps"][step_name].items(): + assert value >= 0 # DOF > 0 in all (step, unit) From 9bf3fb7df07adf33d136823c1de0c15e615d78a2 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 13 Jan 2026 19:06:41 -0800 Subject: [PATCH 36/73] added standard header --- .../io/idaes/core/io/pyomo_model_state_pb2.py | 59 +++++++++++++++++++ idaes/core/io/tests/__init__.py | 0 idaes/core/util/doctesting.py | 12 ++++ idaes/core/util/structfs/__init__.py | 12 ++++ idaes/core/util/structfs/fsrunner.py | 6 +- idaes/core/util/structfs/logutil.py | 6 +- idaes/core/util/structfs/runner.py | 6 +- idaes/core/util/structfs/runner_actions.py | 6 +- .../util/structfs/tests/flash_flowsheet.py | 7 +-- .../core/util/structfs/tests/test_fsrunner.py | 6 +- .../core/util/structfs/tests/test_logutil.py | 6 +- idaes/core/util/structfs/tests/test_runner.py | 6 +- .../structfs/tests/test_runner_actions.py | 6 +- 13 files changed, 110 insertions(+), 28 deletions(-) create mode 100644 idaes/core/io/idaes/core/io/pyomo_model_state_pb2.py create mode 100644 idaes/core/io/tests/__init__.py diff --git a/idaes/core/io/idaes/core/io/pyomo_model_state_pb2.py b/idaes/core/io/idaes/core/io/pyomo_model_state_pb2.py new file mode 100644 index 0000000000..4cbe7aefc5 --- /dev/null +++ b/idaes/core/io/idaes/core/io/pyomo_model_state_pb2.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2026 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: idaes/core/io/pyomo_model_state.proto +# Protobuf Python Version: 4.25.6 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n%idaes/core/io/pyomo_model_state.proto"V\n\x08Metadata\x12\x16\n\x0e\x66ormat_version\x18\x01 \x01(\t\x12\x0f\n\x07\x63reated\x18\x02 \x01(\x02\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x11\n\tcoauthors\x18\x04 \x03(\t"\xdf\x02\n\x05\x42lock\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x18\n\x04type\x18\x02 \x01(\x0e\x32\n.BlockType\x12\x0e\n\x06parent\x18\x03 \x01(\x05\x12\x1e\n\nindex_type\x18\x04 \x01(\x0e\x32\n.IndexType\x12\x30\n\x0eindexed_data_f\x18\x05 \x03(\x0b\x32\x18.Block.IndexedDataFEntry\x12\x30\n\x0eindexed_data_s\x18\x06 \x03(\x0b\x32\x18.Block.IndexedDataSEntry\x12\x18\n\x04\x64\x61ta\x18\x07 \x01(\x0b\x32\n.BlockData\x1a?\n\x11IndexedDataFEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.BlockData:\x02\x38\x01\x1a?\n\x11IndexedDataSEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.BlockData:\x02\x38\x01"P\n\tBlockData\x12\r\n\x05value\x18\x01 \x01(\x01\x12\r\n\x05\x66ixed\x18\x02 \x01(\x08\x12\r\n\x05stale\x18\x03 \x01(\x08\x12\n\n\x02lb\x18\x04 \x01(\x01\x12\n\n\x02ub\x18\x05 \x01(\x01"<\n\x05Model\x12\x1b\n\x08metadata\x18\x01 \x01(\x0b\x32\t.Metadata\x12\x16\n\x06\x62locks\x18\x02 \x03(\x0b\x32\x06.Block*B\n\tBlockType\x12\t\n\x05\x42LOCK\x10\x00\x12\x07\n\x03VAR\x10\x01\x12\t\n\x05PARAM\x10\x02\x12\n\n\x06SUFFIX\x10\x03\x12\n\n\x06\x43ONFIG\x10\x04*,\n\tIndexType\x12\x08\n\x04NONE\x10\x00\x12\n\n\x06STRING\x10\x01\x12\t\n\x05\x46LOAT\x10\x02\x62\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, "idaes.core.io.pyomo_model_state_pb2", _globals +) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals["_BLOCK_INDEXEDDATAFENTRY"]._options = None + _globals["_BLOCK_INDEXEDDATAFENTRY"]._serialized_options = b"8\001" + _globals["_BLOCK_INDEXEDDATASENTRY"]._options = None + _globals["_BLOCK_INDEXEDDATASENTRY"]._serialized_options = b"8\001" + _globals["_BLOCKTYPE"]._serialized_start = 627 + _globals["_BLOCKTYPE"]._serialized_end = 693 + _globals["_INDEXTYPE"]._serialized_start = 695 + _globals["_INDEXTYPE"]._serialized_end = 739 + _globals["_METADATA"]._serialized_start = 41 + _globals["_METADATA"]._serialized_end = 127 + _globals["_BLOCK"]._serialized_start = 130 + _globals["_BLOCK"]._serialized_end = 481 + _globals["_BLOCK_INDEXEDDATAFENTRY"]._serialized_start = 353 + _globals["_BLOCK_INDEXEDDATAFENTRY"]._serialized_end = 416 + _globals["_BLOCK_INDEXEDDATASENTRY"]._serialized_start = 418 + _globals["_BLOCK_INDEXEDDATASENTRY"]._serialized_end = 481 + _globals["_BLOCKDATA"]._serialized_start = 483 + _globals["_BLOCKDATA"]._serialized_end = 563 + _globals["_MODEL"]._serialized_start = 565 + _globals["_MODEL"]._serialized_end = 625 +# @@protoc_insertion_point(module_scope) diff --git a/idaes/core/io/tests/__init__.py b/idaes/core/io/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idaes/core/util/doctesting.py b/idaes/core/util/doctesting.py index 9f4d050513..5c147493d7 100644 --- a/idaes/core/util/doctesting.py +++ b/idaes/core/util/doctesting.py @@ -1,3 +1,15 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2026 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# """ Utility code for documentation-related tests. """ diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index a0dc2694dd..670125bfa9 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -1,3 +1,15 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2026 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# ''' The 'flowsheet runner' is an API in the {py:mod}`structfs` subpackage, and in diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 753cacc660..4ca68a4ca9 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# """ Specialize the generic `Runner` class to running a flowsheet, in `FlowsheetRunner`. diff --git a/idaes/core/util/structfs/logutil.py b/idaes/core/util/structfs/logutil.py index 037f759fc3..ec0e333da2 100644 --- a/idaes/core/util/structfs/logutil.py +++ b/idaes/core/util/structfs/logutil.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# """ Utility functions for logging """ diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 50feaa6aad..0707ba51cf 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# """ Run functions in a module in a defined, named, sequence. """ diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 9b107071e6..900c91d979 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# """ Predefined Actions for the generic Runner. """ diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index 61a470b408..a9a66b479d 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -1,16 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2025 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -# -############################################################################### +################################################################################# """ Simple Flash flowsheet for use in testing. """ diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 741504b447..84f5c28ba5 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# import pytest from pyomo.environ import ConcreteModel from idaes.core import FlowsheetBlock diff --git a/idaes/core/util/structfs/tests/test_logutil.py b/idaes/core/util/structfs/tests/test_logutil.py index af481907b1..17344845ef 100644 --- a/idaes/core/util/structfs/tests/test_logutil.py +++ b/idaes/core/util/structfs/tests/test_logutil.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# """ Tests for logutil module """ diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index a841b792a5..87fc2c35d8 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# import pytest from ..runner import Runner, Action from .. import runner_actions diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 7156f54bc8..2baa913233 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -1,15 +1,15 @@ -############################################################################### +################################################################################# # The Institute for the Design of Advanced Energy Systems Integrated Platform # Framework (IDAES IP) was produced under the DOE Institute for the # Design of Advanced Energy Systems (IDAES). # -# Copyright (c) 2018-2024 by the software owners: The Regents of the +# Copyright (c) 2018-2026 by the software owners: The Regents of the # University of California, through Lawrence Berkeley National Laboratory, # National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon # University, West Virginia University Research Corporation, et al. # All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md # for full copyright and license information. -############################################################################### +################################################################################# import pprint import time From 7a5fd494433e9fdc7247a57a4c3292688e710f16 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 16 Jan 2026 09:12:56 -0800 Subject: [PATCH 37/73] hardly strictly docs --- docs/build.py | 1 + docs/conf.py | 5 ++++- idaes/core/util/structfs/__init__.py | 3 +++ idaes/core/util/structfs/runner.py | 1 + idaes/core/util/structfs/tests/test_fsrunner.py | 10 +++++----- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/build.py b/docs/build.py index 004576a381..5b13340509 100644 --- a/docs/build.py +++ b/docs/build.py @@ -72,6 +72,7 @@ def run_apidoc(clean=True, dry_run=False, **kwargs): "apidoc", "../idaes", "../idaes/*tests*", + "../idaes/core/util/structfs", # handled by apidoc2 ], 60, dry_run, diff --git a/docs/conf.py b/docs/conf.py index 1626864a4d..eebeb17c35 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -100,7 +100,10 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["apidoc/*tests*"] +exclude_patterns = [ + "apidoc/*tests*", + "../idaes/core/util/structfs", +] # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index 670125bfa9..641387c1f6 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -75,6 +75,7 @@ ```{code} python :name: before + from pyomo.environ import ConcreteModel, SolverFactory, SolverStatus from idaes.core import FlowsheetBlock from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( @@ -226,10 +227,12 @@ def solve_opt(ctx): {py:class}`FlowsheetRunner ` class. ```{autodoc2-object} structfs.fsrunner.FlowsheetRunner.annotate_var +:no-index: ``` ```{autodoc2-docstring} structfs.fsrunner.FlowsheetRunner.annotate_var :parser: myst +:no-index: ``` ''' diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 0707ba51cf..4324c0865f 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -382,6 +382,7 @@ class Action: ```{code} :name: hellogoodbye + from idaes.core.util.structfs.runner import Action class HelloGoodbye(Action): "Example action, for tutorial purposes." diff --git a/idaes/core/util/structfs/tests/test_fsrunner.py b/idaes/core/util/structfs/tests/test_fsrunner.py index 84f5c28ba5..904d29bd3b 100644 --- a/idaes/core/util/structfs/tests/test_fsrunner.py +++ b/idaes/core/util/structfs/tests/test_fsrunner.py @@ -151,9 +151,9 @@ def test_annotation(): SolverStatus, FS = None, None # load the functions from the docstring -_ds = Docstring(structfs.__doc__) -exec(_ds.code("before", func_prefix="sfi_before_")) -exec(_ds.code("after", func_prefix="sfi_after_")) +_ds1 = Docstring(structfs.__doc__) +exec(_ds1.code("before", func_prefix="sfi_before_")) +exec(_ds1.code("after", func_prefix="sfi_after_")) @pytest.mark.unit @@ -174,8 +174,8 @@ def test_sfi_after(): # pacify linters annotate_vars_example = lambda x: None # load example function from docstring -_ds = Docstring(BaseFlowsheetRunner.annotate_var.__doc__) -exec(_ds.code("annotate_vars")) +_ds2 = Docstring(BaseFlowsheetRunner.annotate_var.__doc__) +exec(_ds2.code("annotate_vars")) @pytest.mark.unit From 0d93c47ee454bdc36c92a535dfc48d750ea912dd Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sat, 17 Jan 2026 21:29:40 -0800 Subject: [PATCH 38/73] cli --- idaes/commands/run_flowsheet.py | 5 ++ idaes/core/util/structfs/runner.py | 39 ++++++++-- idaes/core/util/structfs/runner_actions.py | 16 +++- idaes/core/util/structfs/runner_cli.py | 87 ++++++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 idaes/commands/run_flowsheet.py create mode 100644 idaes/core/util/structfs/runner_cli.py diff --git a/idaes/commands/run_flowsheet.py b/idaes/commands/run_flowsheet.py new file mode 100644 index 0000000000..b5e166633f --- /dev/null +++ b/idaes/commands/run_flowsheet.py @@ -0,0 +1,5 @@ +""" +Command to run a given flowsheet +""" + +from idaes.core.util.structfs.runner_cli import main diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 4324c0865f..454acb41d5 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -15,9 +15,14 @@ """ # stdlib +from abc import ABC, abstractmethod +import json import logging from typing import Callable, Optional, Tuple, Sequence, TypeVar +# third party +from pydantic import BaseModel, Field + __author__ = "Dan Gunter (LBNL)" _log = logging.Logger(__name__) @@ -167,7 +172,8 @@ def _run_steps( endpoints: tuple[bool, bool], ): names = (self.normalize_name(first), self.normalize_name(last)) - print(f"@@ RUN STEPS: first={first} last={last} endpoints={endpoints}") + + self._last_run_steps = [] # get indexes of first/last step step_range = [-1, -1] @@ -207,6 +213,7 @@ def _run_steps( # if the step is defined, run it if step: step.func(self._context) + self._last_run_steps.append(step.name) # execute overall after-run action for action in self._actions.values(): @@ -215,6 +222,7 @@ def _run_steps( def reset(self): """Reset runner internal state, especially the context.""" self._context = {} + self._last_run_steps = [] def list_steps(self, all_steps=False) -> list[str]: """Get list of [runnable] steps.""" @@ -351,8 +359,19 @@ def wrapper(*args, **kwargs): return step_decorator - -class Action: + def report(self) -> dict: + # create a mapping of actions to report dicts + action_reports = {} + for name, action in self._actions.items(): + classname = action.__class__.__name__ + rpt = action.report() + rpt_dict = rpt.model_dump() if isinstance(rpt, BaseModel) else rpt + action_reports[classname] = rpt_dict + # return actions and other metadata as a report + return {"actions": action_reports, "last_run": self._last_run_steps.copy()} + + +class Action(ABC): """The Action class implements a simple framework to run arbitrary functions before and/or after each step and/or run performed by the `Runner` class. @@ -374,6 +393,10 @@ class Action: Also note that the *name* argument is used to retrieve the action instance later, as needed. + All actions must also implement the `report()` method, + which returns the results of the action to the caller + as either a Pydantic BaseModel subclass or a Python dict. + ### Example Below is a simple example that prints a message @@ -478,5 +501,11 @@ def after_run(self): """Perform this action after a run ends.""" return - def report(self) -> dict: - return {} + @abstractmethod + def report(self) -> BaseModel | dict: + """Report the results of the action to the caller. + + Returns: + Results as a Pydantic model or Python dict + """ + pass diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 900c91d979..64a2876e9a 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -24,6 +24,7 @@ # third-party import pandas as pd from pyomo.network.port import ScalarPort +from pydantic import BaseModel, Field # package from idaes.core.util.model_statistics import degrees_of_freedom @@ -35,6 +36,10 @@ class Timer(Action): """Simple step/run timer action.""" + class Report(BaseModel): + # {"step_name": , ..} from most recent run + timings: dict[str, float] = Field(default={}) + def __init__(self, runner, **kwargs): """Constructor. @@ -136,8 +141,9 @@ def summary(self, stream=None, run_idx=-1) -> str: def _ipython_display_(self): print(self.summary()) - def report(self) -> dict: - return self.step_times[-1].copy() # most recent run + def report(self) -> Report: + rpt = self.Report(timings=self.step_times[-1].copy()) + return rpt # Hold degrees of freedom for one FlowsheetRunner 'step' @@ -158,6 +164,10 @@ class UnitDofChecker(Action): model are checked, saved, and passed to an optional function. """ + class Report(BaseModel): + steps: dict[str, UnitDofType] = Field(default={}) + model: int = Field(default=0) + def __init__( self, runner: FlowsheetRunner, @@ -291,7 +301,7 @@ def steps(self, only_with_data: bool = False) -> list[str]: return list(self._steps) def report(self) -> dict: - return {"steps": self.get_dof(), "model": self.get_dof_model()} + return self.Report(steps=self.get_dof(), model=self.get_dof_model()) @staticmethod def _get_dof(block, fix_inlets: bool = True): diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py new file mode 100644 index 0000000000..46f132341b --- /dev/null +++ b/idaes/core/util/structfs/runner_cli.py @@ -0,0 +1,87 @@ +""" +Command-line interface to run a module. +""" + +import argparse +import importlib +import json +import logging +import traceback +from io import FileIO +import sys + +from idaes.core.util.structfs.runner import Runner + +_log = logging.getLogger("idaes.core.util.structfs.runner_cli") + + +def error(ofile: FileIO, msg: str, code: int = -1) -> int: + stack_trace = traceback.format_exc() + d = {"status": code, "error": msg, "error_detail": stack_trace} + json.dump(d, ofile) + _log.error(msg) + return code + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("module", help="Python module name", default=None) + p.add_argument( + "--output", + default="result.json", + help="Output file for result JSON (default=result.json)", + ) + p.add_argument("--object", help="Object in module (default=FS)", default="FS") + p.add_argument( + "--to", help="Step name to run to (default=all)", default=Runner.STEP_ANY + ) + args = p.parse_args() + + try: + ofile = open(args.output, mode="w") + except Exception as err: + return error(sys.stderr, "Cannot open output file: {err}", -1) + + if args.module is None: + return error(ofile, f"Argument --module is required") + if args.to is None: + return error(ofile, f"Argument --to is required") + + module_name = args.module + if module_name.startswith("."): + return error(ofile, "Relative module names not allowed", 1) + try: + mod = importlib.import_module(module_name) + except ModuleNotFoundError: + return error(ofile, f"Could not find module '{module_name}'", 2) + + obj_name = args.object + try: + obj = getattr(mod, obj_name) + if not isinstance(obj, Runner): + return error( + ofile, + f"Object must be an instance of the Runner class, got '{obj.__class__.__name__}'", + 3, + ) + except AttributeError: + return error( + ofile, f"Could not find object '{obj_name}' in module '{module_name}'", 4 + ) + + to_step = args.to + try: + obj.run_steps(first=Runner.STEP_ANY, last=to_step) + except Exception as e: + return error(ofile, f"While running steps: {e}", 5) + + report = obj.report() + report["status"] = 0 + json.dump(report, ofile) + print(f"===> {ofile.name}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index ab13b5514b..d496914580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ include = ["idaes*"] [project.scripts] idaes = "idaes.commands.base:command_base" +idaes-run = "idaes.commands.run_flowsheet:main" [project.entry-points."idaes.flowsheets"] "0D_Fixed_Bed_TSA" = "idaes.models_extra.temperature_swing_adsorption.fixed_bed_tsa0d_ui" From 7bf8daa2d38c971baf4e0f32f98812ebc0f46fc4 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 18 Jan 2026 10:02:04 -0800 Subject: [PATCH 39/73] capture solver output --- idaes/core/util/structfs/fsrunner.py | 9 +++++--- idaes/core/util/structfs/runner_actions.py | 24 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 4ca68a4ca9..64f1c4c1c5 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -81,7 +81,7 @@ class BaseFlowsheetRunner(Runner): "check_model_numerics", ) - def __init__(self, solver=None, tee=False): + def __init__(self, solver=None, tee=True): self.build_step = self.STEPS[0] self._solver, self._tee = solver, tee self._ann = {} @@ -230,7 +230,7 @@ def __init__(self, runner): # check DoF after build, initial solve, and optimization solve self._a = runner.add_action( - "dof", + "degrees_of_freedom", UnitDofChecker, "fs", ["build", "solve_initial", "solve_optimization"], @@ -258,7 +258,7 @@ class Timings: def __init__(self, runner): from .runner_actions import Timer - self._a: Timer = runner.add_action("t", Timer) + self._a: Timer = runner.add_action("timings", Timer) @property def values(self) -> list[dict]: @@ -280,9 +280,12 @@ def _ipython_display_(self): self._a._ipython_display_() def __init__(self, **kwargs): + from .runner_actions import CaptureSolverOutput + super().__init__(**kwargs) self.dof = self.DegreesOfFreedom(self) self.timings = self.Timings(self) + self.add_action("capture_solver_output", CaptureSolverOutput) def build(self): """Run just the build step""" diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 64a2876e9a..18c38a7f35 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -325,3 +325,27 @@ def _get_dof(block, fix_inlets: bool = True): inlet.free() return dof + + +class CaptureSolverOutput(Action): + def __init__(self, runner, **kwargs): + super().__init__(runner, **kwargs) + self._logs = {} + self._solver_out = None + + def before_step(self, step_name: str): + if self._is_solve_step(step_name): + self._solver_out = StringIO() + self._save_stdout, sys.stdout = sys.stdout, self._solver_out + + def after_step(self, step_name: str): + if self._is_solve_step(step_name): + self._logs[step_name] = self._solver_out.getvalue() + self._solver_out = None + sys.stdout = self._save_stdout + + def _is_solve_step(self, name: str): + return name.startswith("solve") + + def report(self): + return {"solver_logs": self._logs} From 421b6b5daf2a81c0c0701e2bca1da6096c2e9b50 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 18 Jan 2026 19:23:55 -0800 Subject: [PATCH 40/73] added variables --- idaes/core/util/structfs/fsrunner.py | 3 +- idaes/core/util/structfs/runner_actions.py | 47 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 64f1c4c1c5..89b27c27b8 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -280,12 +280,13 @@ def _ipython_display_(self): self._a._ipython_display_() def __init__(self, **kwargs): - from .runner_actions import CaptureSolverOutput + from .runner_actions import CaptureSolverOutput, ModelVariables super().__init__(**kwargs) self.dof = self.DegreesOfFreedom(self) self.timings = self.Timings(self) self.add_action("capture_solver_output", CaptureSolverOutput) + self.add_action("model_variables", ModelVariables) def build(self): """Run just the build step""" diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 18c38a7f35..23f814e851 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -349,3 +349,50 @@ def _is_solve_step(self, name: str): def report(self): return {"solver_logs": self._logs} + + +class ModelVariables(Action): + """Extract and format model variables.""" + + VAR_TYPE = "var" + PARAM_TYPE = "param" + + def __init__(self, runner, **kwargs): + assert isinstance(runner, FlowsheetRunner) # makes no sense otherwise + super().__init__(runner, **kwargs) + + def after_run(self): + self._blocks = self._extract_vars(self._runner.model) + + def _extract_vars(self, m): + model_vars = [] + for c in m.component_objects(): + id_c = id(c) + if c.is_variable_type(): + subtype = self.VAR_TYPE + elif c.is_parameter_type(): + subtype = self.PARAM_TYPE + else: + continue # we just don't care! + # XXX block: subtype, id, name, parent_id, [is_indexed, num] + # XXX b = [subtype, id_c, c.name, id(c.parent_block())] + b = [subtype, c.name] + items = [] + for index in c: + v = c[index] + if index is None: + b.append(False) # not indexed + else: + b.append(True) # indexed + # item: index, value, [fixed, stale, lb, ub] + if subtype == self.VAR_TYPE: + item = (index, v.value, v.fixed, v.stale, v.lb, v.ub) + else: + item = (index, v.value) + items.append(item) + b.append(items) + model_vars.append(b) + return model_vars + + def report(self) -> dict: + return {"blocks": self._blocks} From b73eb743aba11664e3531b8cf1a9e72dfd4d04e0 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Mon, 19 Jan 2026 19:50:11 -0800 Subject: [PATCH 41/73] some cleanup of ModelVariable action --- idaes/core/util/structfs/runner_actions.py | 36 ++++++++++++---------- idaes/core/util/structfs/runner_cli.py | 11 ++----- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 23f814e851..0b448d3b36 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -339,7 +339,7 @@ def before_step(self, step_name: str): self._save_stdout, sys.stdout = sys.stdout, self._solver_out def after_step(self, step_name: str): - if self._is_solve_step(step_name): + if self._solver_out is not None: self._logs[step_name] = self._solver_out.getvalue() self._solver_out = None sys.stdout = self._save_stdout @@ -351,11 +351,21 @@ def report(self): return {"solver_logs": self._logs} +# for ModelVariables.Report +class _Block(BaseModel): + subtype: str + name: str + indexed: bool = Field(default=False) + items: list = Field(default=[]) + + class ModelVariables(Action): """Extract and format model variables.""" - VAR_TYPE = "var" - PARAM_TYPE = "param" + VAR_TYPE, PARAM_TYPE = "var", "param" + + class Report(BaseModel): + blocks: list[_Block] = Field(default=[]) def __init__(self, runner, **kwargs): assert isinstance(runner, FlowsheetRunner) # makes no sense otherwise @@ -374,25 +384,19 @@ def _extract_vars(self, m): subtype = self.PARAM_TYPE else: continue # we just don't care! - # XXX block: subtype, id, name, parent_id, [is_indexed, num] - # XXX b = [subtype, id_c, c.name, id(c.parent_block())] - b = [subtype, c.name] - items = [] + b = _Block(subtype=subtype, name=c.name) for index in c: v = c[index] - if index is None: - b.append(False) # not indexed - else: - b.append(True) # indexed - # item: index, value, [fixed, stale, lb, ub] + b.indexed = index is not None if subtype == self.VAR_TYPE: + # index, value, is-fixed, is-stale, lower-bound, upper-bound item = (index, v.value, v.fixed, v.stale, v.lb, v.ub) else: + # index, value item = (index, v.value) - items.append(item) - b.append(items) + b.items.append(item) model_vars.append(b) return model_vars - def report(self) -> dict: - return {"blocks": self._blocks} + def report(self) -> Report: + return self.Report(blocks=self._blocks) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 46f132341b..b10d5fa0c5 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -25,12 +25,8 @@ def error(ofile: FileIO, msg: str, code: int = -1) -> int: def main(): p = argparse.ArgumentParser() - p.add_argument("module", help="Python module name", default=None) - p.add_argument( - "--output", - default="result.json", - help="Output file for result JSON (default=result.json)", - ) + p.add_argument("module", help="Python module name") + p.add_argument("output", help="Output file for result JSON") p.add_argument("--object", help="Object in module (default=FS)", default="FS") p.add_argument( "--to", help="Step name to run to (default=all)", default=Runner.STEP_ANY @@ -40,7 +36,7 @@ def main(): try: ofile = open(args.output, mode="w") except Exception as err: - return error(sys.stderr, "Cannot open output file: {err}", -1) + return error(sys.stderr, "Cannot open output file '{args.output}': {err}", -1) if args.module is None: return error(ofile, f"Argument --module is required") @@ -78,7 +74,6 @@ def main(): report = obj.report() report["status"] = 0 json.dump(report, ofile) - print(f"===> {ofile.name}") return 0 From 651baa59923f3800a854dc3f32246cb52af480a0 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 20 Jan 2026 06:24:29 -0800 Subject: [PATCH 42/73] better ModelVariables serialization --- idaes/core/util/structfs/runner_actions.py | 96 ++++++++++++++++------ 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 0b448d3b36..0ebcd4f0a7 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -17,6 +17,8 @@ from collections import defaultdict from collections.abc import Callable from io import StringIO +from itertools import chain +import re import sys import time from typing import Union, Optional @@ -24,6 +26,9 @@ # third-party import pandas as pd from pyomo.network.port import ScalarPort +from pyomo.core.base.var import IndexedVar +from pyomo.core.base.param import IndexedParam +import pyomo.environ as pyo from pydantic import BaseModel, Field # package @@ -351,52 +356,95 @@ def report(self): return {"solver_logs": self._logs} -# for ModelVariables.Report -class _Block(BaseModel): - subtype: str - name: str - indexed: bool = Field(default=False) - items: list = Field(default=[]) - - class ModelVariables(Action): """Extract and format model variables.""" - VAR_TYPE, PARAM_TYPE = "var", "param" + VAR_TYPE, PARAM_TYPE = "V", "P" class Report(BaseModel): - blocks: list[_Block] = Field(default=[]) + variables: dict = Field(default={}) # list = Field(default=[]) def __init__(self, runner, **kwargs): assert isinstance(runner, FlowsheetRunner) # makes no sense otherwise super().__init__(runner, **kwargs) def after_run(self): - self._blocks = self._extract_vars(self._runner.model) + self._extract_vars(self._runner.model) def _extract_vars(self, m): - model_vars = [] + var_tree = {} + for c in m.component_objects(): - id_c = id(c) - if c.is_variable_type(): + # get component type + if self.is_var(c): subtype = self.VAR_TYPE - elif c.is_parameter_type(): + elif self.is_param(c): subtype = self.PARAM_TYPE else: - continue # we just don't care! - b = _Block(subtype=subtype, name=c.name) + continue # ignore other components + # start new block + b = [subtype] + # add values + # if isinstance(c, pyo.NumericValue): + # b.append(False) + # b.append([None, c.value]) + # else: + items = [] + indexed = False + # add each value from an indexed var/param, + # this also works ok for non-indexed ones for index in c: v = c[index] - b.indexed = index is not None + indexed = index is not None if subtype == self.VAR_TYPE: # index, value, is-fixed, is-stale, lower-bound, upper-bound - item = (index, v.value, v.fixed, v.stale, v.lb, v.ub) + item = (index, pyo.value(v), v.fixed, v.stale, v.lb, v.ub) else: # index, value - item = (index, v.value) - b.items.append(item) - model_vars.append(b) - return model_vars + item = (index, pyo.value(v)) + items.append(item) + b.append(indexed) + b.append(items) + # add block to list + # model_vars.append(b) + self._add_block(var_tree, c.name, b) + + self._vars = var_tree # {"components": model_vars} + + @staticmethod + def is_var(c): + return c.is_variable_type() or isinstance(c, IndexedVar) + + @staticmethod + def is_param(c): + return c.is_parameter_type() or isinstance(c, IndexedParam) + + @staticmethod + def _add_block(tree: dict, name: str, block): + # get parts of the name + # - mostly logic to handle 'foo.bar[0.0].baz' crap + p = name.split(".") + parts, i, n = [], 0, len(p) + indexes = None + while i < n: + cur = p[i] + # since split('.') creates ('foo[0.', '0]') from 'foo[0.0]', + # we need to rejoin them + if i < n - 1 and re.match(r".*\[\d+$", cur): + next = p[i + 1] + parts.append(cur + "." + next) + i += 2 + else: + parts.append(cur) + i += 1 + # insert in tree + t, prev = tree, None + for p in parts: + prev = t + if p not in t: + t[p] = {} + t = t[p] + prev[p] = block def report(self) -> Report: - return self.Report(blocks=self._blocks) + return self.Report(variables=self._vars) From c9eec90f2800b3fc9bc84fb846e111fa2f3a2363 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 20 Jan 2026 11:44:44 -0800 Subject: [PATCH 43/73] fixed tests, added headers --- idaes/commands/run_flowsheet.py | 12 ++++++++++++ idaes/core/util/structfs/runner.py | 3 +++ idaes/core/util/structfs/runner_cli.py | 12 ++++++++++++ .../core/util/structfs/tests/test_runner_actions.py | 7 ++++--- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/idaes/commands/run_flowsheet.py b/idaes/commands/run_flowsheet.py index b5e166633f..2a1e856eea 100644 --- a/idaes/commands/run_flowsheet.py +++ b/idaes/commands/run_flowsheet.py @@ -1,3 +1,15 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2026 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# """ Command to run a given flowsheet """ diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 454acb41d5..8be5bafeb3 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -433,6 +433,9 @@ def after_substep(self, name, subname): def after_run(self): print(f"Ran {self.step_counter} steps") + + def report(self): + return {"steps": self.step_counter} ``` You could add the above example to a Runner subclass, diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index b10d5fa0c5..7717c2eacc 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -1,3 +1,15 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2026 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# """ Command-line interface to run a module. """ diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index 2baa913233..cd01e49776 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -183,8 +183,9 @@ def test_dof_report(): report = rn.get_action("dof").report() print(f"@@ REPORT:\n{report}") assert report - assert report["model"] == 0 # model has DOF=0 + report_data = report.model_dump() + assert report_data["model"] == 0 # model has DOF=0 for step_name in check_steps: - assert step_name in report["steps"] - for unit, value in report["steps"][step_name].items(): + assert step_name in report_data["steps"] + for unit, value in report_data["steps"][step_name].items(): assert value >= 0 # DOF > 0 in all (step, unit) From 8b029b5435e0b0a0421e4e4a22eef608e6212512 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Wed, 21 Jan 2026 12:09:55 -0800 Subject: [PATCH 44/73] fixed timer report check for new obj --- idaes/core/util/structfs/tests/test_runner_actions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/tests/test_runner_actions.py b/idaes/core/util/structfs/tests/test_runner_actions.py index cd01e49776..f7ecfa6d93 100644 --- a/idaes/core/util/structfs/tests/test_runner_actions.py +++ b/idaes/core/util/structfs/tests/test_runner_actions.py @@ -163,9 +163,10 @@ def test_timer_report(): "solve_initial", ) assert report + print(f"Hey! here's the report: {report}") for step_name in expect_steps: - assert step_name in report - assert report[step_name] < 1 + assert step_name in report.timings + assert report.timings[step_name] < 1 @pytest.mark.unit From 3a6d79a40e8578ecc3543454ccb2e1c8e669ea04 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Wed, 21 Jan 2026 12:48:14 -0800 Subject: [PATCH 45/73] tweaks --- idaes/commands/run_flowsheet.py | 3 +++ idaes/core/util/structfs/runner_cli.py | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/idaes/commands/run_flowsheet.py b/idaes/commands/run_flowsheet.py index 2a1e856eea..b43871c862 100644 --- a/idaes/commands/run_flowsheet.py +++ b/idaes/commands/run_flowsheet.py @@ -15,3 +15,6 @@ """ from idaes.core.util.structfs.runner_cli import main + +if __name__ == "__main__": + main() diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 7717c2eacc..d43f278a7b 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -50,11 +50,6 @@ def main(): except Exception as err: return error(sys.stderr, "Cannot open output file '{args.output}': {err}", -1) - if args.module is None: - return error(ofile, f"Argument --module is required") - if args.to is None: - return error(ofile, f"Argument --to is required") - module_name = args.module if module_name.startswith("."): return error(ofile, "Relative module names not allowed", 1) From e1bf9b0c670fbac2cc5a84b1922f88445b84f55a Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:07:49 -0800 Subject: [PATCH 46/73] commented out mermaid diagrm by default for pygments/docs failure --- docs/examples/structfs/hda_flowsheet_nb.ipynb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/examples/structfs/hda_flowsheet_nb.ipynb b/docs/examples/structfs/hda_flowsheet_nb.ipynb index 65c822a4b8..30dd22c94e 100644 --- a/docs/examples/structfs/hda_flowsheet_nb.ipynb +++ b/docs/examples/structfs/hda_flowsheet_nb.ipynb @@ -424,7 +424,8 @@ } ], "source": [ - "FS.show_diagram()" + "# Uncomment to show diagram\n", + "# FS.show_diagram()" ] }, { From 7f4c9c99dbae92844fd3fd36ea8c5178aeb3ea40 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:08:10 -0800 Subject: [PATCH 47/73] structfs in docs --- docs/reference_guides/core/util/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference_guides/core/util/index.rst b/docs/reference_guides/core/util/index.rst index 384bbf3a50..32a5ae3e93 100644 --- a/docs/reference_guides/core/util/index.rst +++ b/docs/reference_guides/core/util/index.rst @@ -13,6 +13,7 @@ model_statistics phase_equilibria scaling + structured flowsheets tables tags utility_minimization From bfed8766231e8dc63c6b1cb8736b503e98505c0f Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:08:35 -0800 Subject: [PATCH 48/73] removed no-index option --- idaes/core/util/structfs/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index 641387c1f6..fc6f3d721a 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -227,12 +227,10 @@ def solve_opt(ctx): {py:class}`FlowsheetRunner ` class. ```{autodoc2-object} structfs.fsrunner.FlowsheetRunner.annotate_var -:no-index: ``` ```{autodoc2-docstring} structfs.fsrunner.FlowsheetRunner.annotate_var :parser: myst -:no-index: ``` ''' From e5b416ff6a38a0715968971aa82695609000ff40 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:10:14 -0800 Subject: [PATCH 49/73] cleanup of ModelVariables --- idaes/core/util/structfs/runner_actions.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index 0ebcd4f0a7..b09ae91b81 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -384,15 +384,11 @@ def _extract_vars(self, m): continue # ignore other components # start new block b = [subtype] - # add values - # if isinstance(c, pyo.NumericValue): - # b.append(False) - # b.append([None, c.value]) - # else: + # add its variables items = [] indexed = False - # add each value from an indexed var/param, - # this also works ok for non-indexed ones + # add each value from an indexed var/param, + # this also works ok for non-indexed ones for index in c: v = c[index] indexed = index is not None @@ -405,11 +401,10 @@ def _extract_vars(self, m): items.append(item) b.append(indexed) b.append(items) - # add block to list - # model_vars.append(b) + # add block to tree self._add_block(var_tree, c.name, b) - self._vars = var_tree # {"components": model_vars} + self._vars = var_tree @staticmethod def is_var(c): From c60e736fd1c8df143dc073c6b71a914926390bcc Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:26:50 -0800 Subject: [PATCH 50/73] put structfs into docs --- docs/conf.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index eebeb17c35..eb3555ec8a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,12 +47,14 @@ autodoc2_packages = [ "../idaes/core/util/structfs", ] -autodoc2_output_dir = "apidoc2" # keep separated +autodoc2_output_dir = "reference_guides/core/util" autodoc2_render_plugin = "myst" autodoc2_docstring_parser_regexes = [ # render docstrings in matching files as Markdown ("../idaes/core/util/structfs/.*", "myst"), ] +autodoc2_no_index = True +autodoc2_index_template = None # don't write index.rst # Put type hints in the description, not signature autodoc_typehints = "description" From 3157ae24a0a16e4a188904d5176f00b0ce03dc52 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 14:27:49 -0800 Subject: [PATCH 51/73] install pandoc for docs --- .github/workflows/core.yml | 37 ++++++++++++----------- docs/reference_guides/core/util/index.rst | 2 +- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 9327e830aa..b6b662ebaf 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -4,10 +4,10 @@ on: push: branches: - main - - '*_rel' + - "*_rel" schedule: # run daily at 5:00 am UTC (12 am ET/9 pm PT) - - cron: '0 5 * * *' + - cron: "0 5 * * *" repository_dispatch: # to run this, send a POST API call at repos/IDAES/idaes-pse/dispatches with the specified event_type # e.g. `gh repos/IDAES/idaes-pse/dispatches -F event_type=ci_run_tests` @@ -38,7 +38,7 @@ concurrency: env: # default Python version to use for checks that do not require multiple versions - DEFAULT_PYTHON_VERSION: '3.10' + DEFAULT_PYTHON_VERSION: "3.10" IDAES_CONDA_ENV_NAME_DEV: idaes-pse-dev PYTEST_ADDOPTS: "--color=yes" @@ -54,7 +54,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + - uses: actions/setup-python@v5 with: python-version: ${{ env.DEFAULT_PYTHON_VERSION }} @@ -76,10 +76,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 - + - name: Run Spell Checker uses: crate-ci/typos@v1.24.5 - with: + with: config: ./.github/workflows/typos.toml pytest: @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: - linux - win64 @@ -99,15 +99,14 @@ jobs: runner-image: ubuntu-24.04 - os: win64 runner-image: windows-2022 - - python-version: '3.11' + - python-version: "3.11" # only generate coverage report for a single python version in the matrix # to avoid overloading Codecov cov-report: true - steps: - uses: actions/checkout@v5 - + - uses: ./.github/actions/display-debug-info - name: Set up Conda environment uses: conda-incubator/setup-miniconda@v3 @@ -119,7 +118,7 @@ jobs: - name: Set up idaes uses: ./.github/actions/setup-idaes with: - install-target: -r requirements-dev.txt + install-target: -r requirements-dev.txt - name: Add pytest CLI options for coverage if: matrix.cov-report run: | @@ -134,7 +133,7 @@ jobs: name: coverage-report-${{ matrix.os }} path: coverage.xml if-no-files-found: error - + upload-coverage: name: Upload coverage report (Codecov) needs: [pytest] @@ -146,7 +145,7 @@ jobs: steps: # the checkout step is needed to have access to codecov.yml - uses: actions/checkout@v4 - + - uses: actions/download-artifact@v4 with: name: coverage-report-${{ matrix.report-variant }} @@ -170,13 +169,15 @@ jobs: needs: [code-formatting, spell-check] steps: - uses: actions/checkout@v4 - + - name: Set up Conda environment uses: conda-incubator/setup-miniconda@v3 with: activate-environment: ${{ env.IDAES_CONDA_ENV_NAME_DEV }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }} miniforge-version: latest + - name: Install pandoc + run: conda install pandoc - name: Set up idaes uses: ./.github/actions/setup-idaes with: @@ -206,7 +207,7 @@ jobs: # NOTE: using Conda instead of actions/setup-python in this job is not strictly necessary # as it doesn't need to run on Windows or use the setup-idaes local action, # but we do it for consistency with the other jobs - + - name: Set up Conda environment uses: conda-incubator/setup-miniconda@v3 with: @@ -216,7 +217,7 @@ jobs: - name: Set up idaes uses: ./.github/actions/setup-idaes with: - install-target: -r requirements-dev.txt + install-target: -r requirements-dev.txt - name: Run pylint run: | echo "::group::Display pylint version" @@ -240,7 +241,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - + - name: Set up Conda environment uses: conda-incubator/setup-miniconda@v3 with: @@ -250,7 +251,7 @@ jobs: - name: Set up idaes uses: ./.github/actions/setup-idaes with: - install-target: -r requirements-dev.txt + install-target: -r requirements-dev.txt - name: Create empty pytest.ini file run: | echo "" > pytest.ini diff --git a/docs/reference_guides/core/util/index.rst b/docs/reference_guides/core/util/index.rst index 32a5ae3e93..402e60f35b 100644 --- a/docs/reference_guides/core/util/index.rst +++ b/docs/reference_guides/core/util/index.rst @@ -13,7 +13,7 @@ model_statistics phase_equilibria scaling - structured flowsheets + Structured flowsheets tables tags utility_minimization From d6fe49b8d8c665485f686e6c6879ebec3a1ea23e Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 22 Jan 2026 21:01:11 -0800 Subject: [PATCH 52/73] make sure to use conda-forge --- .github/workflows/core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index b6b662ebaf..a02ebef493 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -177,7 +177,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON_VERSION }} miniforge-version: latest - name: Install pandoc - run: conda install pandoc + run: conda -c conda-forge install pandoc - name: Set up idaes uses: ./.github/actions/setup-idaes with: From 7e22f731311f141834e71ee29cc0d000d6759d6e Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 23 Jan 2026 04:28:17 -0800 Subject: [PATCH 53/73] unused var --- idaes/core/util/doctesting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/idaes/core/util/doctesting.py b/idaes/core/util/doctesting.py index 5c147493d7..320c8bdde0 100644 --- a/idaes/core/util/doctesting.py +++ b/idaes/core/util/doctesting.py @@ -77,7 +77,6 @@ def _prefix_def(self, lines: list[str], prefix: str): def _init_markdown(self, text: str): lines = text.split("\n") state = 0 - dedent = None for line in lines: ls_line = line.lstrip() if state == 0 and ls_line.startswith("```{code}"): From df1fcc3cec1ec9a211471759036b3a47acb0357b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 23 Jan 2026 04:28:35 -0800 Subject: [PATCH 54/73] tweak --- idaes/core/util/structfs/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/__init__.py b/idaes/core/util/structfs/__init__.py index fc6f3d721a..34dd2ae058 100644 --- a/idaes/core/util/structfs/__init__.py +++ b/idaes/core/util/structfs/__init__.py @@ -11,7 +11,9 @@ # for full copyright and license information. ################################################################################# ''' -The 'flowsheet runner' is an API in the +# Structured flowsheet runner API + +The *struct*ured *f*low*s*heet runner is an API in the {py:mod}`structfs` subpackage, and in particular that package's {py:mod}`runner ` and {py:mod}`fsrunner ` modules. From bdc88936ea6973178d175c18ac71ef47740bd436 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Sun, 25 Jan 2026 05:36:32 -0800 Subject: [PATCH 55/73] fixed pylint warnings --- idaes/core/util/structfs/runner_actions.py | 79 +++++++++++++++++----- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/idaes/core/util/structfs/runner_actions.py b/idaes/core/util/structfs/runner_actions.py index b09ae91b81..89b6003cfa 100644 --- a/idaes/core/util/structfs/runner_actions.py +++ b/idaes/core/util/structfs/runner_actions.py @@ -14,17 +14,14 @@ Predefined Actions for the generic Runner. """ # stdlib -from collections import defaultdict from collections.abc import Callable from io import StringIO -from itertools import chain import re import sys import time from typing import Union, Optional # third-party -import pandas as pd from pyomo.network.port import ScalarPort from pyomo.core.base.var import IndexedVar from pyomo.core.base.param import IndexedParam @@ -42,7 +39,9 @@ class Timer(Action): """Simple step/run timer action.""" class Report(BaseModel): - # {"step_name": , ..} from most recent run + """Report returned by report() method.""" + + # {"step_name": , ..} for each step timings: dict[str, float] = Field(default={}) def __init__(self, runner, **kwargs): @@ -117,7 +116,16 @@ def _get_summary(self, i): "exclusive": rt - step_total, } - def summary(self, stream=None, run_idx=-1) -> str: + def summary(self, stream=None, run_idx=-1) -> str | None: + """Summary of the timings. + + Args: + stream: Output stream, with `write()` method. Return a string if None. + run_idx: Index of run, -1 meaning "last one" + + Returns: + str: If output stream was None, the text summary; otherwise None + """ if stream is None: stream = StringIO() @@ -136,17 +144,24 @@ def summary(self, stream=None, run_idx=-1) -> str: for s, t in d["steps"].items(): if t >= 0: fmt = sfmt.format(slen=slen) - stream.write(fmt.format(s=s, t=t, p=(t / ttot * 100))) + stream.write(fmt.format(s=s, t=t, p=t / ttot * 100)) stream.write(f"\nTotal time: {d['run']:.3f} s\n") if isinstance(stream, StringIO): return stream.getvalue() + return None + def _ipython_display_(self): print(self.summary()) def report(self) -> Report: + """Report the timings. + + Returns: + The report object + """ rpt = self.Report(timings=self.step_times[-1].copy()) return rpt @@ -170,6 +185,8 @@ class UnitDofChecker(Action): """ class Report(BaseModel): + """Report on degrees of freedom in a model.""" + steps: dict[str, UnitDofType] = Field(default={}) model: int = Field(default=0) @@ -228,6 +245,7 @@ def after_step(self, step_name: str): self._step_func(step_name, units_dof) def after_run(self): + """Actions performed after a run.""" fs = self._get_flowsheet() model_dof = degrees_of_freedom(fs) self._model_dof = model_dof @@ -245,6 +263,15 @@ def _is_unit_model(block): return isinstance(block, ProcessBlockData) def summary(self, stream=sys.stdout, step=None): + """Readable summary of the degrees of freedom. + + Args: + stream: Output stream, with `write()` method. Return a string if None. + step: Specific step to summarize, otherwise all steps. + + Returns: + The summary as a string if `stream` was None, otherwise None + """ if stream is None: stream = StringIO() @@ -262,7 +289,7 @@ def write_step(sdof, indent=4): stream.write(f"Degrees of freedom: {self._model_dof}\n\n") if step is None: stream.write("Degrees of freedom after steps:\n") - for step in self._runner._steps: + for step in self._runner.list_steps(): if step in self._steps_dof: stream.write(f" {step}:\n") write_step(self._steps_dof[step]) @@ -305,7 +332,12 @@ def steps(self, only_with_data: bool = False) -> list[str]: return [s for s in self._steps if s in self._steps_dof] return list(self._steps) - def report(self) -> dict: + def report(self) -> Report: + """Machine-readable report of degrees of freedom. + + Returns: + Report object + """ return self.Report(steps=self.get_dof(), model=self.get_dof_model()) @staticmethod @@ -333,17 +365,21 @@ def _get_dof(block, fix_inlets: bool = True): class CaptureSolverOutput(Action): + """Capture the solver output.""" + def __init__(self, runner, **kwargs): super().__init__(runner, **kwargs) self._logs = {} self._solver_out = None def before_step(self, step_name: str): + """Action performed before the step.""" if self._is_solve_step(step_name): self._solver_out = StringIO() self._save_stdout, sys.stdout = sys.stdout, self._solver_out def after_step(self, step_name: str): + """Action performed after the step.""" if self._solver_out is not None: self._logs[step_name] = self._solver_out.getvalue() self._solver_out = None @@ -352,7 +388,12 @@ def after_step(self, step_name: str): def _is_solve_step(self, name: str): return name.startswith("solve") - def report(self): + def report(self) -> dict: + """Machine-readable report with solver output. + + Returns: + Report dict, {'solver_logs': ""} + """ return {"solver_logs": self._logs} @@ -362,13 +403,17 @@ class ModelVariables(Action): VAR_TYPE, PARAM_TYPE = "V", "P" class Report(BaseModel): - variables: dict = Field(default={}) # list = Field(default=[]) + """Report for ModelVariables.""" + + #: Tree of variables + variables: dict = Field(default={}) def __init__(self, runner, **kwargs): assert isinstance(runner, FlowsheetRunner) # makes no sense otherwise super().__init__(runner, **kwargs) def after_run(self): + """Actions performed after the run.""" self._extract_vars(self._runner.model) def _extract_vars(self, m): @@ -376,9 +421,9 @@ def _extract_vars(self, m): for c in m.component_objects(): # get component type - if self.is_var(c): + if self._is_var(c): subtype = self.VAR_TYPE - elif self.is_param(c): + elif self._is_param(c): subtype = self.PARAM_TYPE else: continue # ignore other components @@ -407,11 +452,11 @@ def _extract_vars(self, m): self._vars = var_tree @staticmethod - def is_var(c): + def _is_var(c): return c.is_variable_type() or isinstance(c, IndexedVar) @staticmethod - def is_param(c): + def _is_param(c): return c.is_parameter_type() or isinstance(c, IndexedParam) @staticmethod @@ -420,14 +465,13 @@ def _add_block(tree: dict, name: str, block): # - mostly logic to handle 'foo.bar[0.0].baz' crap p = name.split(".") parts, i, n = [], 0, len(p) - indexes = None while i < n: cur = p[i] # since split('.') creates ('foo[0.', '0]') from 'foo[0.0]', # we need to rejoin them if i < n - 1 and re.match(r".*\[\d+$", cur): - next = p[i + 1] - parts.append(cur + "." + next) + next_ = p[i + 1] + parts.append(cur + "." + next_) i += 2 else: parts.append(cur) @@ -442,4 +486,5 @@ def _add_block(tree: dict, name: str, block): prev[p] = block def report(self) -> Report: + """Report containing model variable values.""" return self.Report(variables=self._vars) From 603c81cd79337c8f6ae5c2af511ceef25beca822 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 27 Jan 2026 17:02:20 -0800 Subject: [PATCH 56/73] fixed pylint warnings --- idaes/core/util/structfs/runner.py | 32 +++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 8be5bafeb3..09857081bc 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -16,12 +16,11 @@ # stdlib from abc import ABC, abstractmethod -import json import logging from typing import Callable, Optional, Tuple, Sequence, TypeVar # third party -from pydantic import BaseModel, Field +from pydantic import BaseModel __author__ = "Dan Gunter (LBNL)" @@ -181,7 +180,7 @@ def _run_steps( if step_name == self.STEP_ANY: # meaning first or last defined # this will always find a step as long as there is at least one, # which we checked before calling this function - idx = self._find_step(reverse=(i == 1)) + idx = self._find_step(reverse=i == 1) else: try: idx = self._step_names.index(step_name) @@ -359,14 +358,21 @@ def wrapper(*args, **kwargs): return step_decorator - def report(self) -> dict: + def report(self) -> dict[str, dict]: + """Compile reports of each action into a combined report + + Returns: + dict: Mapping with two key-value pairs: + - `actions`: Keys are names given to actions during `add_action()`, values are the + reports returned by that action, in Python dictionary form. + - `last_run`: List of steps (names, as strings) in previous run + """ # create a mapping of actions to report dicts action_reports = {} for name, action in self._actions.items(): - classname = action.__class__.__name__ rpt = action.report() rpt_dict = rpt.model_dump() if isinstance(rpt, BaseModel) else rpt - action_reports[classname] = rpt_dict + action_reports[name] = rpt_dict # return actions and other metadata as a report return {"actions": action_reports, "last_run": self._last_run_steps.copy()} @@ -483,6 +489,12 @@ def before_step(self, step_name: str): return def before_substep(self, step_name: str, substep_name: str): + """Perform this action before the named sub-step. + + Args: + step_name: Name of the step + substep_name: Name of the sub-step + """ return def after_step(self, step_name: str): @@ -494,6 +506,12 @@ def after_step(self, step_name: str): return def after_substep(self, step_name: str, substep_name: str): + """Perform this action after the named sub-step. + + Args: + step_name: Name of the step + substep_name: Name of the sub-step + """ return def before_run(self): @@ -511,4 +529,4 @@ def report(self) -> BaseModel | dict: Returns: Results as a Pydantic model or Python dict """ - pass + return From 996d02e1c909ac47218872c82ee758e4792fd609 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 27 Jan 2026 22:14:57 -0800 Subject: [PATCH 57/73] fixed pylint warnings --- idaes/core/util/structfs/fsrunner.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 89b27c27b8..045576b10a 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -15,15 +15,14 @@ in `FlowsheetRunner`. """ # stdlib -from typing import Union # third-party -from pyomo.environ import ConcreteModel, is_variable_type +from pyomo.environ import ConcreteModel from pyomo.environ import units as pyunits from idaes.core import FlowsheetBlock try: - from idaes_connectivity.base import Connectivity + from idaes_connectivity import Connectivity from idaes_connectivity.jupyter import display_connectivity except ImportError: Connectivity = None @@ -226,7 +225,7 @@ class DegreesOfFreedom: """Wrapper for the UnitDofChecker action""" def __init__(self, runner): - from .runner_actions import UnitDofChecker + from .runner_actions import UnitDofChecker # pylint: disable=C0415 # check DoF after build, initial solve, and optimization solve self._a = runner.add_action( @@ -238,6 +237,7 @@ def __init__(self, runner): self._rnr = runner def model(self): + """Get the model.""" return self._a.get_dof_model() def __getattr__(self, name): @@ -256,16 +256,18 @@ class Timings: """Wrapper for the Timer action""" def __init__(self, runner): - from .runner_actions import Timer + from .runner_actions import Timer # pylint: disable=C0415 self._a: Timer = runner.add_action("timings", Timer) @property def values(self) -> list[dict]: + """Get timing values.""" return self._a.get_history() @property def history(self) -> str: + """Get a text report of the timing history""" h = [] for i in range(len(self._a)): h.append(f"== Run {i + 1} ==") @@ -277,10 +279,13 @@ def __str__(self): return self._a.summary() def _ipython_display_(self): - self._a._ipython_display_() + self._a._ipython_display_() # pylint: disable=protected-access def __init__(self, **kwargs): - from .runner_actions import CaptureSolverOutput, ModelVariables + from .runner_actions import ( # pylint: disable=C0415 + CaptureSolverOutput, + ModelVariables, + ) super().__init__(**kwargs) self.dof = self.DegreesOfFreedom(self) @@ -293,9 +298,11 @@ def build(self): self.run_step("build") def solve_initial(self): + """Perform all steps up to 'solve_initial'""" self.run_steps(last="solve_initial") def show_diagram(self): + """Return the diagram.""" if Connectivity is not None: return display_connectivity(input_model=self.model) else: From a1fce65c25c0bf7722f79386533be9e64eb36c64 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Tue, 27 Jan 2026 22:20:23 -0800 Subject: [PATCH 58/73] fixed pylint warnings --- idaes/core/util/structfs/runner_cli.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index d43f278a7b..9e588197a6 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -27,7 +27,7 @@ _log = logging.getLogger("idaes.core.util.structfs.runner_cli") -def error(ofile: FileIO, msg: str, code: int = -1) -> int: +def _error(ofile: FileIO, msg: str, code: int = -1) -> int: stack_trace = traceback.format_exc() d = {"status": code, "error": msg, "error_detail": stack_trace} json.dump(d, ofile) @@ -36,6 +36,7 @@ def error(ofile: FileIO, msg: str, code: int = -1) -> int: def main(): + """Program entry point.""" p = argparse.ArgumentParser() p.add_argument("module", help="Python module name") p.add_argument("output", help="Output file for result JSON") @@ -47,36 +48,36 @@ def main(): try: ofile = open(args.output, mode="w") - except Exception as err: - return error(sys.stderr, "Cannot open output file '{args.output}': {err}", -1) + except IOError as err: + return _error(sys.stderr, f"Cannot open output file '{args.output}': {err}", -1) module_name = args.module if module_name.startswith("."): - return error(ofile, "Relative module names not allowed", 1) + return _error(ofile, "Relative module names not allowed", 1) try: mod = importlib.import_module(module_name) except ModuleNotFoundError: - return error(ofile, f"Could not find module '{module_name}'", 2) + return _error(ofile, f"Could not find module '{module_name}'", 2) obj_name = args.object try: obj = getattr(mod, obj_name) if not isinstance(obj, Runner): - return error( + return _error( ofile, f"Object must be an instance of the Runner class, got '{obj.__class__.__name__}'", 3, ) except AttributeError: - return error( + return _error( ofile, f"Could not find object '{obj_name}' in module '{module_name}'", 4 ) to_step = args.to try: obj.run_steps(first=Runner.STEP_ANY, last=to_step) - except Exception as e: - return error(ofile, f"While running steps: {e}", 5) + except Exception as e: # pylint: disable=broad-exception-caught + return _error(ofile, f"While running steps: {e}", 5) report = obj.report() report["status"] = 0 From 9a6ede599f4d431848ac3ebd12ab8dd837529e1e Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Wed, 28 Jan 2026 07:31:14 -0800 Subject: [PATCH 59/73] minor rename --- idaes/core/util/structfs/runner.py | 2 +- idaes/core/util/structfs/tests/test_runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 09857081bc..0b5d387ade 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -410,7 +410,7 @@ class Action(ABC): of steps run at the end of the run. ```{code} - :name: hellogoodbye + :name: runner-hellogoodbye from idaes.core.util.structfs.runner import Action class HelloGoodbye(Action): diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index 87fc2c35d8..8c72bce1e6 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -128,7 +128,7 @@ def do_bad3(ctx): # load the HelloGoodbye class from the Action docstring HelloGoodbye = None # pacify linters -exec(Docstring(runner_actions.Action.__doc__).code("hellogoodbye")) +exec(Docstring(runner_actions.Action.__doc__).code("runner-hellogoodbye")) @pytest.mark.unit From f849862465ce4860c0be7b86e344d5cf4d7432e8 Mon Sep 17 00:00:00 2001 From: Sheng Pang Date: Wed, 28 Jan 2026 14:02:59 -0800 Subject: [PATCH 60/73] add _load_module function now module arg can support python module name, and path to .py file --- idaes/core/util/structfs/runner_cli.py | 68 ++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 9e588197a6..53d88617eb 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -13,9 +13,10 @@ """ Command-line interface to run a module. """ - +import os import argparse import importlib +import importlib.util import json import logging import traceback @@ -38,7 +39,7 @@ def _error(ofile: FileIO, msg: str, code: int = -1) -> int: def main(): """Program entry point.""" p = argparse.ArgumentParser() - p.add_argument("module", help="Python module name") + p.add_argument("module", help="Python module name or path to .py file") p.add_argument("output", help="Output file for result JSON") p.add_argument("--object", help="Object in module (default=FS)", default="FS") p.add_argument( @@ -55,7 +56,7 @@ def main(): if module_name.startswith("."): return _error(ofile, "Relative module names not allowed", 1) try: - mod = importlib.import_module(module_name) + mod = _load_module(module_name) except ModuleNotFoundError: return _error(ofile, f"Could not find module '{module_name}'", 2) @@ -85,6 +86,67 @@ def main(): return 0 +def _load_module(module_or_path: str): + """ + Load a module - supports both module names and file paths. + + Args: + module_or_path: Can be either: + - Module name: "idaes.models.flash_flowsheet" + - File path: "/Users/user/Downloads/my_flowsheet.py" + Returns: + module: The loaded Python module object. + + Note: + For file paths, this function sets up a pseudo-package structure to + support relative imports (e.g., 'from ..sibling import something'). + """ + # Check if input is a file path + if module_or_path.endswith('.py') or os.path.isfile(module_or_path): + # This is a file path + file_path = os.path.abspath(module_or_path) + + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + # Get directory structure for package simulation + dir_path = os.path.dirname(file_path) # e.g., /Users/user/workspace/subdir + parent_dir = os.path.dirname(dir_path) # e.g., /Users/user/workspace + package_name = os.path.basename(dir_path) # e.g., "subdir" + module_basename = os.path.splitext(os.path.basename(file_path))[0] # e.g., "test" + full_module_name = f"{package_name}.{module_basename}" # e.g., "subdir.test" + + # Add parent directory to sys.path so Python can find sibling packages + # This enables imports like "from ..fsrunner import ..." + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Create module spec with submodule_search_locations for package support + spec = importlib.util.spec_from_file_location( + full_module_name, + file_path, + submodule_search_locations=[dir_path] + ) + + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create module spec for {file_path}") + + # Create the module object from spec + module = importlib.util.module_from_spec(spec) + + # KEY: Set __package__ so relative imports know the package context + module.__package__ = package_name + + # Register in sys.modules so other imports can find it + sys.modules[full_module_name] = module + + # Execute the module code (this actually loads the content) + spec.loader.exec_module(module) + return module + else: + # This is a module name, use the original logic + return importlib.import_module(module_or_path) + if __name__ == "__main__": sys.exit(main()) From a9bc66b8acd71b46468f972e328e5e6c0b67eb0c Mon Sep 17 00:00:00 2001 From: Sheng Pang Date: Wed, 28 Jan 2026 14:04:17 -0800 Subject: [PATCH 61/73] update import path from relative path to module import --- idaes/core/util/structfs/tests/flash_flowsheet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/tests/flash_flowsheet.py b/idaes/core/util/structfs/tests/flash_flowsheet.py index a9a66b479d..c53f1350a8 100644 --- a/idaes/core/util/structfs/tests/flash_flowsheet.py +++ b/idaes/core/util/structfs/tests/flash_flowsheet.py @@ -23,7 +23,7 @@ BTXParameterBlock, ) from idaes.models.unit_models import Flash -from ..fsrunner import FlowsheetRunner +from idaes.core.util.structfs.fsrunner import FlowsheetRunner FS = FlowsheetRunner() From bf5f53e468ec0de0c57e2078288a2f03f03cdb5f Mon Sep 17 00:00:00 2001 From: Sheng Pang Date: Wed, 28 Jan 2026 14:16:08 -0800 Subject: [PATCH 62/73] format with black --- idaes/core/util/structfs/runner_cli.py | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 53d88617eb..beb065f05b 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -86,60 +86,61 @@ def main(): return 0 + def _load_module(module_or_path: str): """ Load a module - supports both module names and file paths. - + Args: module_or_path: Can be either: - Module name: "idaes.models.flash_flowsheet" - File path: "/Users/user/Downloads/my_flowsheet.py" Returns: module: The loaded Python module object. - + Note: For file paths, this function sets up a pseudo-package structure to support relative imports (e.g., 'from ..sibling import something'). """ # Check if input is a file path - if module_or_path.endswith('.py') or os.path.isfile(module_or_path): + if module_or_path.endswith(".py") or os.path.isfile(module_or_path): # This is a file path file_path = os.path.abspath(module_or_path) - + if not os.path.exists(file_path): raise FileNotFoundError(f"File not found: {file_path}") - + # Get directory structure for package simulation - dir_path = os.path.dirname(file_path) # e.g., /Users/user/workspace/subdir - parent_dir = os.path.dirname(dir_path) # e.g., /Users/user/workspace - package_name = os.path.basename(dir_path) # e.g., "subdir" - module_basename = os.path.splitext(os.path.basename(file_path))[0] # e.g., "test" + dir_path = os.path.dirname(file_path) # e.g., /Users/user/workspace/subdir + parent_dir = os.path.dirname(dir_path) # e.g., /Users/user/workspace + package_name = os.path.basename(dir_path) # e.g., "subdir" + module_basename = os.path.splitext(os.path.basename(file_path))[ + 0 + ] # e.g., "test" full_module_name = f"{package_name}.{module_basename}" # e.g., "subdir.test" - + # Add parent directory to sys.path so Python can find sibling packages - # This enables imports like "from ..fsrunner import ..." + # This enables imports like "from ..fsrunner import ..." if parent_dir not in sys.path: sys.path.insert(0, parent_dir) - + # Create module spec with submodule_search_locations for package support spec = importlib.util.spec_from_file_location( - full_module_name, - file_path, - submodule_search_locations=[dir_path] + full_module_name, file_path, submodule_search_locations=[dir_path] ) - + if spec is None or spec.loader is None: raise ImportError(f"Cannot create module spec for {file_path}") - + # Create the module object from spec module = importlib.util.module_from_spec(spec) - + # KEY: Set __package__ so relative imports know the package context module.__package__ = package_name - + # Register in sys.modules so other imports can find it sys.modules[full_module_name] = module - + # Execute the module code (this actually loads the content) spec.loader.exec_module(module) return module From 30561b79e392f185b98bf42a36bb8b224c1c56e0 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 07:51:45 -0800 Subject: [PATCH 63/73] hey --- docs/examples/structfs/hda_flowsheet_nb.ipynb | 343 +----------------- 1 file changed, 12 insertions(+), 331 deletions(-) diff --git a/docs/examples/structfs/hda_flowsheet_nb.ipynb b/docs/examples/structfs/hda_flowsheet_nb.ipynb index 30dd22c94e..f31e36ae4d 100644 --- a/docs/examples/structfs/hda_flowsheet_nb.ipynb +++ b/docs/examples/structfs/hda_flowsheet_nb.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "cd678c21", "metadata": {}, "outputs": [], @@ -21,111 +21,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "0bfdd163", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2025-12-18 09:01:51 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:51 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:51 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "WARNING: Error converting Ipopt log entry to float: could not convert string\n", - "to float: '0.00e+00S'\n", - " 59r 0.0000000e+00 2.06e+04 2.95e+09 -1.5 1.69e-01 6.9 5.41e-04\n", - " 0.00e+00S 9\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:52 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.H101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.H101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.R101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.R101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.F101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.purge_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101.recycle_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.S101: Initialization Step 2 Complete: optimal - \n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.C101.control_volume.properties_out: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.toluene_feed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.hydrogen_feed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.vapor_recycle_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101.mixed_state: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.M101: Initialization Complete: optimal - \n", - "WARNING: Wegstein failed to converge in 3 iterations\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.F102.control_volume.properties_in: Initialization Complete\n", - "2025-12-18 09:01:53 [INFO] idaes.init.fs.F102.control_volume.properties_out: Initialization Complete\n" - ] - } - ], + "outputs": [], "source": [ "# run up to, but not including, optimization\n", "FS.run_steps(before=\"solve_optimization\")" @@ -133,68 +32,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "85da5b80", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Degrees of freedom: 2\n", - "\n", - "Degrees of freedom after steps:\n", - " build:\n", - " fs : 29\n", - " fs.C101 : 1\n", - " fs.C101.control_volume : 12\n", - " fs.F101 : 2\n", - " fs.F101.control_volume : 12\n", - " fs.F101.split : 0\n", - " fs.F102 : 2\n", - " fs.F102.control_volume : 12\n", - " fs.F102.split : 0\n", - " fs.H101 : 1\n", - " fs.H101.control_volume : 11\n", - " fs.M101 : 20\n", - " fs.R101 : 2\n", - " fs.R101.control_volume : 12\n", - " fs.S101 : -9\n", - " fs.reaction_params : 0\n", - " fs.thermo_params : 0\n", - " fs.thermo_params.Liq : 0\n", - " fs.thermo_params.Vap : 0\n", - " fs.thermo_params.benzene : 0\n", - " fs.thermo_params.hydrogen : 0\n", - " fs.thermo_params.methane : 0\n", - " fs.thermo_params.toluene : 0\n", - " solve_initial:\n", - " fs : 0\n", - " fs.C101 : 0\n", - " fs.C101.control_volume : 11\n", - " fs.F101 : 0\n", - " fs.F101.control_volume : 10\n", - " fs.F101.split : 0\n", - " fs.F102 : 0\n", - " fs.F102.control_volume : 10\n", - " fs.F102.split : 0\n", - " fs.H101 : 0\n", - " fs.H101.control_volume : 10\n", - " fs.M101 : 0\n", - " fs.R101 : 0\n", - " fs.R101.control_volume : 11\n", - " fs.S101 : -10\n", - " fs.reaction_params : 0\n", - " fs.thermo_params : 0\n", - " fs.thermo_params.Liq : 0\n", - " fs.thermo_params.Vap : 0\n", - " fs.thermo_params.benzene : 0\n", - " fs.thermo_params.hydrogen : 0\n", - " fs.thermo_params.methane : 0\n", - " fs.thermo_params.toluene : 0\n" - ] - } - ], + "outputs": [], "source": [ "# print degrees of freedom\n", "FS.dof" @@ -202,7 +43,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "b7082ff1", "metadata": {}, "outputs": [], @@ -213,92 +54,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "008f9ccc", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Degrees of freedom: 5\n", - "\n", - "Degrees of freedom after steps:\n", - " build:\n", - " fs : 29\n", - " fs.C101 : 1\n", - " fs.C101.control_volume : 12\n", - " fs.F101 : 2\n", - " fs.F101.control_volume : 12\n", - " fs.F101.split : 0\n", - " fs.F102 : 2\n", - " fs.F102.control_volume : 12\n", - " fs.F102.split : 0\n", - " fs.H101 : 1\n", - " fs.H101.control_volume : 11\n", - " fs.M101 : 20\n", - " fs.R101 : 2\n", - " fs.R101.control_volume : 12\n", - " fs.S101 : -9\n", - " fs.reaction_params : 0\n", - " fs.thermo_params : 0\n", - " fs.thermo_params.Liq : 0\n", - " fs.thermo_params.Vap : 0\n", - " fs.thermo_params.benzene : 0\n", - " fs.thermo_params.hydrogen : 0\n", - " fs.thermo_params.methane : 0\n", - " fs.thermo_params.toluene : 0\n", - " solve_initial:\n", - " fs : 0\n", - " fs.C101 : 0\n", - " fs.C101.control_volume : 11\n", - " fs.F101 : 0\n", - " fs.F101.control_volume : 10\n", - " fs.F101.split : 0\n", - " fs.F102 : 0\n", - " fs.F102.control_volume : 10\n", - " fs.F102.split : 0\n", - " fs.H101 : 0\n", - " fs.H101.control_volume : 10\n", - " fs.M101 : 0\n", - " fs.R101 : 0\n", - " fs.R101.control_volume : 11\n", - " fs.S101 : -10\n", - " fs.reaction_params : 0\n", - " fs.thermo_params : 0\n", - " fs.thermo_params.Liq : 0\n", - " fs.thermo_params.Vap : 0\n", - " fs.thermo_params.benzene : 0\n", - " fs.thermo_params.hydrogen : 0\n", - " fs.thermo_params.methane : 0\n", - " fs.thermo_params.toluene : 0\n", - " solve_optimization:\n", - " fs : 5\n", - " fs.C101 : 0\n", - " fs.C101.control_volume : 11\n", - " fs.F101 : 1\n", - " fs.F101.control_volume : 11\n", - " fs.F101.split : 0\n", - " fs.F102 : 2\n", - " fs.F102.control_volume : 12\n", - " fs.F102.split : 0\n", - " fs.H101 : 1\n", - " fs.H101.control_volume : 11\n", - " fs.M101 : 0\n", - " fs.R101 : 1\n", - " fs.R101.control_volume : 12\n", - " fs.S101 : -10\n", - " fs.reaction_params : 0\n", - " fs.thermo_params : 0\n", - " fs.thermo_params.Liq : 0\n", - " fs.thermo_params.Vap : 0\n", - " fs.thermo_params.benzene : 0\n", - " fs.thermo_params.hydrogen : 0\n", - " fs.thermo_params.methane : 0\n", - " fs.thermo_params.toluene : 0\n" - ] - } - ], + "outputs": [], "source": [ "# print degrees of freedom again\n", "FS.dof" @@ -306,57 +65,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "0a96da4a", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "operating cost = $ 312786.3383406732\n", - "\n", - "====================================================================================\n", - "Unit : fs.F102 Time: 0.0\n", - "------------------------------------------------------------------------------------\n", - " Unit Performance\n", - "\n", - " Variables: \n", - "\n", - " Key : Value : Units : Fixed : Bounds\n", - " Heat Duty : 8377.0 : watt : False : (None, None)\n", - " Pressure Change : -2.4500e+05 : pascal : False : (None, None)\n", - "\n", - "------------------------------------------------------------------------------------\n", - " Stream Table\n", - " Units Inlet Vapor Outlet Liquid Outlet\n", - " flow_mol_phase_comp ('Liq', 'benzene') mole / second 0.21743 1.0000e-08 0.067425 \n", - " flow_mol_phase_comp ('Liq', 'toluene') mole / second 0.070695 1.0000e-08 0.037507 \n", - " flow_mol_phase_comp ('Liq', 'methane') mole / second 2.8812e-07 1.0000e-08 1.0493e-07 \n", - " flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 2.8812e-07 1.0000e-08 1.0493e-07 \n", - " flow_mol_phase_comp ('Vap', 'benzene') mole / second 1.0000e-08 0.15000 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'toluene') mole / second 1.0000e-08 0.033189 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'methane') mole / second 1.0000e-08 1.9319e-07 1.0000e-08 \n", - " flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 1.0000e-08 1.9319e-07 1.0000e-08 \n", - " temperature kelvin 301.88 362.93 362.93 \n", - " pressure pascal 3.5000e+05 1.0500e+05 1.0500e+05 \n", - "====================================================================================\n", - "\n", - "benzene purity = 0.818827657811582\n", - " Units Reactor Light Gases\n", - "flow_mol_phase_comp ('Liq', 'benzene') mole / second 4.3534e-08 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'toluene') mole / second 7.5866e-07 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'methane') mole / second 1.0000e-12 1.0000e-08 \n", - "flow_mol_phase_comp ('Liq', 'hydrogen') mole / second 1.0000e-12 1.0000e-08 \n", - "flow_mol_phase_comp ('Vap', 'benzene') mole / second 0.27178 0.054356 \n", - "flow_mol_phase_comp ('Vap', 'toluene') mole / second 0.076085 0.0053908 \n", - "flow_mol_phase_comp ('Vap', 'methane') mole / second 1.2414 1.2414 \n", - "flow_mol_phase_comp ('Vap', 'hydrogen') mole / second 0.35887 0.35887 \n", - "temperature kelvin 696.11 301.88 \n", - "pressure pascal 3.5000e+05 3.5000e+05 \n" - ] - } - ], + "outputs": [], "source": [ "# examine results\n", "\n", @@ -388,41 +100,10 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "e3ec5e4b", "metadata": {}, - "outputs": [ - { - "data": { - "text/markdown": [ - "```mermaid\n", - "flowchart LR\n", - " Unit_B[\"M101\"]\n", - " Unit_C[\"H101\"]\n", - " Unit_D[\"R101\"]\n", - " Unit_E[\"F101\"]\n", - " Unit_F[\"S101\"]\n", - " Unit_G[\"C101\"]\n", - " Unit_H[\"F102\"]\n", - " Unit_B --> Unit_C\n", - " Unit_C --> Unit_D\n", - " Unit_D --> Unit_E\n", - " Unit_E --> Unit_F\n", - " Unit_F --> Unit_G\n", - " Unit_G --> Unit_B\n", - " Unit_E --> Unit_H\n", - "\n", - "```" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# Uncomment to show diagram\n", "# FS.show_diagram()" From 0d7af99fcbc9a04f7fcf4673eafff2ace76aaa0b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 07:52:08 -0800 Subject: [PATCH 64/73] identify code by either name/label or caption --- idaes/core/util/doctesting.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/doctesting.py b/idaes/core/util/doctesting.py index 320c8bdde0..bdd5764bd4 100644 --- a/idaes/core/util/doctesting.py +++ b/idaes/core/util/doctesting.py @@ -28,7 +28,9 @@ class Docstring: and test file. """ + # options on 'code' directive that can provide the name LABEL_OPTION = ":name:" + CAPTION_OPTION = ":caption:" def __init__(self, text: str, style: str = "markdown"): self._code = {} @@ -77,6 +79,7 @@ def _prefix_def(self, lines: list[str], prefix: str): def _init_markdown(self, text: str): lines = text.split("\n") state = 0 + sec_num = 1 for line in lines: ls_line = line.lstrip() if state == 0 and ls_line.startswith("```{code}"): @@ -95,11 +98,23 @@ def _init_markdown(self, text: str): state = 0 elif state == 1: if ls_line.startswith(self.LABEL_OPTION): - section_name = ls_line[7:].strip() + offset = len(self.LABEL_OPTION) + 1 + section_name = ls_line[offset:].strip() + elif section_name is None and ls_line.startswith( + self.CAPTION_OPTION + ): + # only use caption if no label + offset = len(self.CAPTION_OPTION) + 1 + section_name = ls_line[offset:].strip() elif ls_line == "" or ls_line.startswith(":"): pass else: state = 2 # got past metadata + if section_name is None: + section_name = f"section{sec_num}" + sec_num += 1 + else: + section_name = section_name.replace(" ", "-").lower() section_lines.append(line[indent:].rstrip()) else: section_lines.append(line[indent:].rstrip()) From ee6f5cbe8888028d4b454c5ea30c0a2e1b10ee19 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 07:52:24 -0800 Subject: [PATCH 65/73] before/after fix --- idaes/core/util/structfs/fsrunner.py | 16 +++++++++++++++- idaes/core/util/structfs/runner.py | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/fsrunner.py b/idaes/core/util/structfs/fsrunner.py index 045576b10a..c0e541def5 100644 --- a/idaes/core/util/structfs/fsrunner.py +++ b/idaes/core/util/structfs/fsrunner.py @@ -87,7 +87,12 @@ def __init__(self, solver=None, tee=True): super().__init__(self.STEPS) # needs to be last def run_steps( - self, first: str = Runner.STEP_ANY, last: str = Runner.STEP_ANY, **kwargs + self, + first: str = Runner.STEP_ANY, + last: str = Runner.STEP_ANY, + before=None, + after=None, + **kwargs, ): """Run the steps. @@ -103,6 +108,15 @@ def run_steps( or self._context.model is None ): self._context.model = self._create_model() + + # replace first/last with before/after, if present + if before is not None: + kwargs["before"] = before + last = "" + if after is not None: + kwargs["after"] = after + first = "" + super().run_steps(first, last, **kwargs) def reset(self): diff --git a/idaes/core/util/structfs/runner.py b/idaes/core/util/structfs/runner.py index 0b5d387ade..f39c59aaee 100644 --- a/idaes/core/util/structfs/runner.py +++ b/idaes/core/util/structfs/runner.py @@ -410,7 +410,7 @@ class Action(ABC): of steps run at the end of the run. ```{code} - :name: runner-hellogoodbye + :caption: Runner HelloGoodbye class from idaes.core.util.structfs.runner import Action class HelloGoodbye(Action): From 43e6682078aa1b031fe5883a327c6d9f0b633f81 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 07:52:55 -0800 Subject: [PATCH 66/73] test for runner changes --- idaes/core/util/structfs/tests/test_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/tests/test_runner.py b/idaes/core/util/structfs/tests/test_runner.py index 8c72bce1e6..ca5d1319fa 100644 --- a/idaes/core/util/structfs/tests/test_runner.py +++ b/idaes/core/util/structfs/tests/test_runner.py @@ -128,7 +128,7 @@ def do_bad3(ctx): # load the HelloGoodbye class from the Action docstring HelloGoodbye = None # pacify linters -exec(Docstring(runner_actions.Action.__doc__).code("runner-hellogoodbye")) +exec(Docstring(runner_actions.Action.__doc__).code("runner-hellogoodbye-class")) @pytest.mark.unit From 39519675a38b8f44cbf2246ef547176c0e36e49b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 08:17:32 -0800 Subject: [PATCH 67/73] move error check --- idaes/core/util/structfs/runner_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index beb065f05b..70fb77974b 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -53,10 +53,10 @@ def main(): return _error(sys.stderr, f"Cannot open output file '{args.output}': {err}", -1) module_name = args.module - if module_name.startswith("."): - return _error(ofile, "Relative module names not allowed", 1) try: mod = _load_module(module_name) + except ValueError as err: + return _error(ofile, str(err), 1) except ModuleNotFoundError: return _error(ofile, f"Could not find module '{module_name}'", 2) @@ -145,6 +145,8 @@ def _load_module(module_or_path: str): spec.loader.exec_module(module) return module else: + if module_or_path.startswith("."): + raise ValueError("Relative module names not allowed") # This is a module name, use the original logic return importlib.import_module(module_or_path) From fb17e5b8f5b641c36a255565ef1ce6c757c1a6b5 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 08:29:17 -0800 Subject: [PATCH 68/73] safeguards for missing solver --- docs/examples/structfs/hda_flowsheet_nb.ipynb | 78 +++++++------------ 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/docs/examples/structfs/hda_flowsheet_nb.ipynb b/docs/examples/structfs/hda_flowsheet_nb.ipynb index f31e36ae4d..815905f92c 100644 --- a/docs/examples/structfs/hda_flowsheet_nb.ipynb +++ b/docs/examples/structfs/hda_flowsheet_nb.ipynb @@ -16,7 +16,14 @@ "outputs": [], "source": [ "from pyomo.environ import TerminationCondition, value\n", - "from hda_flowsheet import FS" + "from hda_flowsheet import FS\n", + "from idaes.core.solvers import get_solver\n", + "\n", + "solver = None\n", + "try:\n", + " solver = get_solver()\n", + "except:\n", + " pass" ] }, { @@ -27,40 +34,14 @@ "outputs": [], "source": [ "# run up to, but not including, optimization\n", - "FS.run_steps(before=\"solve_optimization\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "85da5b80", - "metadata": {}, - "outputs": [], - "source": [ - "# print degrees of freedom\n", - "FS.dof" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b7082ff1", - "metadata": {}, - "outputs": [], - "source": [ - "# run rest of steps\n", - "FS.run_steps(first=\"solve_optimization\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "008f9ccc", - "metadata": {}, - "outputs": [], - "source": [ - "# print degrees of freedom again\n", - "FS.dof" + "if solver:\n", + " FS.run_steps(before=\"solve_optimization\")\n", + " # print degrees of freedom\n", + " print(FS.dof)\n", + " # run rest of steps\n", + " FS.run_steps(first=\"solve_optimization\")\n", + " # print degrees of freedom again\n", + " print(FS.dof)" ] }, { @@ -74,22 +55,23 @@ "\n", "m = FS.model\n", "\n", - "# What is the total operating cost?\n", - "print(\"operating cost = $\", value(m.fs.operating_cost))\n", + "if m:\n", + " # What is the total operating cost?\n", + " print(\"operating cost = $\", value(m.fs.operating_cost))\n", "\n", - "# For this operating cost, what is the amount of benzene we are able to produce and what purity we are able to achieve?\n", - "m.fs.F102.report()\n", - "print()\n", - "print(\"benzene purity = \", value(m.fs.purity))\n", + " # For this operating cost, what is the amount of benzene we are able to produce and what purity we are able to achieve?\n", + " m.fs.F102.report()\n", + " print()\n", + " print(\"benzene purity = \", value(m.fs.purity))\n", "\n", "\n", - "# How much benzene are we losing in the F101 vapor outlet stream?\n", - "from idaes.core.util.tables import (\n", - " create_stream_table_dataframe,\n", - " stream_table_dataframe_to_string,\n", - ")\n", - "st = create_stream_table_dataframe({\"Reactor\": m.fs.s05, \"Light Gases\": m.fs.s06})\n", - "print(stream_table_dataframe_to_string(st))" + " # How much benzene are we losing in the F101 vapor outlet stream?\n", + " from idaes.core.util.tables import (\n", + " create_stream_table_dataframe,\n", + " stream_table_dataframe_to_string,\n", + " )\n", + " st = create_stream_table_dataframe({\"Reactor\": m.fs.s05, \"Light Gases\": m.fs.s06})\n", + " print(stream_table_dataframe_to_string(st))" ] }, { From 60312c46853da4ad0394afbe20776cca30806a43 Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Thu, 29 Jan 2026 08:35:11 -0800 Subject: [PATCH 69/73] fix pandoc install cmd --- .github/workflows/core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index a02ebef493..8b95384c79 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -177,7 +177,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON_VERSION }} miniforge-version: latest - name: Install pandoc - run: conda -c conda-forge install pandoc + run: conda install -c conda-forge pandoc - name: Set up idaes uses: ./.github/actions/setup-idaes with: From e1e79c7167afa5a22e734f72f6bb1bdfa1c189e2 Mon Sep 17 00:00:00 2001 From: Sheng Pang Date: Thu, 29 Jan 2026 15:55:52 -0800 Subject: [PATCH 70/73] Fix runner_cli to support relative file paths and same-directory imports --- idaes/core/util/structfs/runner_cli.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 70fb77974b..882d876568 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -53,12 +53,13 @@ def main(): return _error(sys.stderr, f"Cannot open output file '{args.output}': {err}", -1) module_name = args.module + # Only reject relative Python module names (like .module), not file paths (like ./file.py) + if module_name.startswith(".") and not module_name.startswith("./") and not module_name.startswith("..\\"): + return _error(ofile, "Relative module names not allowed", 1) try: mod = _load_module(module_name) - except ValueError as err: - return _error(ofile, str(err), 1) - except ModuleNotFoundError: - return _error(ofile, f"Could not find module '{module_name}'", 2) + except (ModuleNotFoundError, FileNotFoundError) as e: + return _error(ofile, f"Could not find module '{module_name}': {e}", 2) obj_name = args.object try: @@ -119,8 +120,11 @@ def _load_module(module_or_path: str): ] # e.g., "test" full_module_name = f"{package_name}.{module_basename}" # e.g., "subdir.test" - # Add parent directory to sys.path so Python can find sibling packages - # This enables imports like "from ..fsrunner import ..." + # Add both current directory and parent directory to sys.path + # Current dir is needed for same-directory imports (import hda_ideal_VLE) + # Parent dir is needed for sibling package imports + if dir_path not in sys.path: + sys.path.insert(0, dir_path) if parent_dir not in sys.path: sys.path.insert(0, parent_dir) From b4ca4e6ce29c86e4d94bfdc89a38f251b9764dcc Mon Sep 17 00:00:00 2001 From: Sheng Pang Date: Thu, 29 Jan 2026 15:56:25 -0800 Subject: [PATCH 71/73] format with black --- idaes/core/util/structfs/runner_cli.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 882d876568..2c9563dde0 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -54,7 +54,11 @@ def main(): module_name = args.module # Only reject relative Python module names (like .module), not file paths (like ./file.py) - if module_name.startswith(".") and not module_name.startswith("./") and not module_name.startswith("..\\"): + if ( + module_name.startswith(".") + and not module_name.startswith("./") + and not module_name.startswith("..\\") + ): return _error(ofile, "Relative module names not allowed", 1) try: mod = _load_module(module_name) From 3f0a36a0f04aa13396c77db801422bf20fa1232b Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 30 Jan 2026 10:47:55 -0800 Subject: [PATCH 72/73] added --info flag to get information without running --- idaes/core/util/structfs/runner_cli.py | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index 70fb77974b..940b82f98c 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -45,12 +45,20 @@ def main(): p.add_argument( "--to", help="Step name to run to (default=all)", default=Runner.STEP_ANY ) + p.add_argument( + "--info", + action="store_true", + help="Instead of running module, get information like list of steps", + default=False, + ) args = p.parse_args() try: ofile = open(args.output, mode="w") except IOError as err: - return _error(sys.stderr, f"Cannot open output file '{args.output}': {err}", -1) + return _error( + sys.stderr, f"Cannot open output fNoneile '{args.output}': {err}", -1 + ) module_name = args.module try: @@ -74,14 +82,18 @@ def main(): ofile, f"Could not find object '{obj_name}' in module '{module_name}'", 4 ) - to_step = args.to - try: - obj.run_steps(first=Runner.STEP_ANY, last=to_step) - except Exception as e: # pylint: disable=broad-exception-caught - return _error(ofile, f"While running steps: {e}", 5) + if args.info: + report = {"steps": obj.list_steps(), "class_name": obj.__class__.__name__} + else: + to_step = args.to + try: + obj.run_steps(first=Runner.STEP_ANY, last=to_step) + except Exception as e: # pylint: disable=broad-exception-caught + return _error(ofile, f"While running steps: {e}", 5) + + report = obj.report() + report["status"] = 0 - report = obj.report() - report["status"] = 0 json.dump(report, ofile) return 0 From a63f80f0a748be547bd0944c13eaecc93dfb4eef Mon Sep 17 00:00:00 2001 From: Dan Gunter Date: Fri, 30 Jan 2026 10:57:34 -0800 Subject: [PATCH 73/73] fixed logic for module, added info --- idaes/core/util/structfs/runner_cli.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/idaes/core/util/structfs/runner_cli.py b/idaes/core/util/structfs/runner_cli.py index d36fdc89ac..fe60e96449 100644 --- a/idaes/core/util/structfs/runner_cli.py +++ b/idaes/core/util/structfs/runner_cli.py @@ -38,6 +38,7 @@ def _error(ofile: FileIO, msg: str, code: int = -1) -> int: def main(): """Program entry point.""" + # Set up and parse commandline arguments p = argparse.ArgumentParser() p.add_argument("module", help="Python module name or path to .py file") p.add_argument("output", help="Output file for result JSON") @@ -53,26 +54,20 @@ def main(): ) args = p.parse_args() + # open output file try: ofile = open(args.output, mode="w") except IOError as err: - return _error( - sys.stderr, f"Cannot open output fNoneile '{args.output}': {err}", -1 - ) + return _error(sys.stderr, f"Cannot open output file '{args.output}': {err}", -1) + # find and import module module_name = args.module - # Only reject relative Python module names (like .module), not file paths (like ./file.py) - if ( - module_name.startswith(".") - and not module_name.startswith("./") - and not module_name.startswith("..\\") - ): - return _error(ofile, "Relative module names not allowed", 1) try: mod = _load_module(module_name) except (ModuleNotFoundError, FileNotFoundError) as e: return _error(ofile, f"Could not find module '{module_name}': {e}", 2) + # find flowsheet object in module obj_name = args.object try: obj = getattr(mod, obj_name) @@ -87,9 +82,12 @@ def main(): ofile, f"Could not find object '{obj_name}' in module '{module_name}'", 4 ) + # branch based on run mode if args.info: + # get static flowsheet information only report = {"steps": obj.list_steps(), "class_name": obj.__class__.__name__} else: + # run the flowsheet and collect results to_step = args.to try: obj.run_steps(first=Runner.STEP_ANY, last=to_step) @@ -99,6 +97,7 @@ def main(): report = obj.report() report["status"] = 0 + # dump results to output file json.dump(report, ofile) return 0 @@ -166,7 +165,7 @@ def _load_module(module_or_path: str): return module else: if module_or_path.startswith("."): - raise ValueError("Relative module names not allowed") + raise ValueError("Relative module names not allowed!!") # This is a module name, use the original logic return importlib.import_module(module_or_path)