diff --git a/.coveragerc b/.coveragerc index 9f3ee40..6968d43 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,7 @@ omit = */tests/* test/* */test/* + copy_template.py [report] omit = @@ -11,3 +12,4 @@ omit = */tests/* test/* */test/* + copy_template.py diff --git a/src/idaes_fi/structfs/actions/__init__.py b/src/idaes_fi/structfs/actions/__init__.py index 8794f51..1bcabde 100644 --- a/src/idaes_fi/structfs/actions/__init__.py +++ b/src/idaes_fi/structfs/actions/__init__.py @@ -32,6 +32,7 @@ "Timer", "UnitDofChecker", "UnitDofType", + "UnitModelReport", ] _EXPORT_MODULES = { @@ -46,6 +47,7 @@ "Timer": "timer", "UnitDofChecker": "unit_dof_checker", "UnitDofType": "unit_dof_checker", + "UnitModelReport": "model_report", } diff --git a/src/idaes_fi/structfs/actions/mermaid_diagram.py b/src/idaes_fi/structfs/actions/mermaid_diagram.py index 6663149..6d19a27 100644 --- a/src/idaes_fi/structfs/actions/mermaid_diagram.py +++ b/src/idaes_fi/structfs/actions/mermaid_diagram.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/actions/model_report.py b/src/idaes_fi/structfs/actions/model_report.py new file mode 100644 index 0000000..59d46e3 --- /dev/null +++ b/src/idaes_fi/structfs/actions/model_report.py @@ -0,0 +1,281 @@ +################################################################################# +# Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 +# +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is +# copyrighted by the software owners: The Regents of the University of California, through Lawrence +# Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through +# Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West +# Virginia University Research Corporation. +# +# NOTICE. This Software was developed under funding from the U.S. Department of Energy and the +# U.S. Government consequently retains certain rights. As such, the U.S. Government has been granted +# for itself and others acting on its behalf a paid-up, nonexclusive, irrevocable, worldwide license +# in the Software to reproduce, distribute copies to the public, prepare derivative works, and perform +# publicly and display publicly, and to permit other to do so. +# +################################################################################# +""" +Unit model report extraction action +""" + +# stdlib +from enum import Enum +import logging + +# IDAES and Pyomo +from idaes.core.util.units_of_measurement import report_quantity +from idaes.core.util.model_statistics import ( + number_activated_blocks, + number_activated_constraints, + number_variables, + degrees_of_freedom, +) +from idaes.core.base.unit_model import UnitModelBlockData +from pyomo.network import Arc +from idaes.core.util.exceptions import ConfigurationError + +# Pydantic +from pydantic import BaseModel, Field + +# package +from ..action_base import Action + + +class ModelType(str, Enum): + unit = "unit" + flowsheet = "flowsheet" + + +class PerfReport(BaseModel): + """Report for UnitModelReport""" + + model_type: ModelType + performance: dict = Field(default={}) + stream_table: dict = Field(default={}) + dof: dict = Field(default={}) + time_point: float = 0.0 + + +class ComponentReports(BaseModel): + reports: dict[str, PerfReport] = Field(default={}) + + +class UnitModelReport(Action): + """Extract report from unit model. + + The 'Report' in the name of this class refers to the `report()` method you + call on the IDAES unit model, not to the Report class or `report()` method + in this class. + + The resulting report is structured as a set of `reports` (in the unit model + method sense), one for each component in the overall model that implements + the reporting interface. Below is an example from the simplest + flowsheet with one Flash unit, with one step (`step1`) and one unit (`fs.flash`). + ``` + { + "step_reports": { + "step1": { + "reports": { + "fs.flash": { + "model_type": "unit", + "performance": { + "vars": { + "Heat Duty": { + "value": 0.0, + "units": "watt", + "fixed": false, + "bounds": [ + null, + null + ] + }, + "Pressure Change": { + "value": 0.0, + "units": "pascal", + "fixed": false, + "bounds": [ + null, + null + ] + } + } + }, + "stream_table": { + "Units": { + "flow_mol": "mole / second", + "mole_frac_comp benzene": "dimensionless", + "mole_frac_comp toluene": "dimensionless", + "temperature": "kelvin", + "pressure": "pascal" + }, + "Inlet": { + "flow_mol": 1.0, + "mole_frac_comp benzene": 0.5, + "mole_frac_comp toluene": 0.5, + "temperature": 298.15, + "pressure": 101325.0 + }, + "Vapor Outlet": { + "flow_mol": 0.5, + "mole_frac_comp benzene": 0.5, + "mole_frac_comp toluene": 0.5, + "temperature": 298.15, + "pressure": 101325.0 + }, + "Liquid Outlet": { + "flow_mol": 0.5, + "mole_frac_comp benzene": 0.5, + "mole_frac_comp toluene": 0.5, + "temperature": 298.15, + "pressure": 101325.0 + } + }, + "dof": { + "dof_stat": 7, + "num_variables": 48, + "num_act_constraints": 41, + "num_act_blocks": 5 + }, + "time_point": 0.0 + } + } + } + }, + "last_step": "step1" + } + ``` + + + """ + + class Report(BaseModel): + # report for each step; the report for the run is just the last one + step_reports: dict[str, ComponentReports] = Field(default={}) + last_step: str = "" + + def __init__(self, *args, **kwargs): + """Constructor. + + Args: + args: Passed to superclass + kwargs: Passed to superclass, except: + - 'allow_empty_performance': If True, include units with + no performance data (but + possibly stream tables). + """ + if "allow_empty_performance" in kwargs: + self._allow_empty_perf = bool(kwargs["allow_empty_performance"]) + del kwargs["allow_empty_performance"] + else: + self._allow_empty_perf = False + super().__init__(*args, **kwargs) + self._dof = True # XXX: allow user to control + self._rpt = self.Report() + + def after_step(self, name: str): + """Get all the component reports in the model, after each step.""" + self._rpt.step_reports[name] = self._get_component_reports() + self._rpt.last_step = name # make it easy to find last report + + def _get_component_reports(self) -> dict[str, ComponentReports]: + m, r = self._runner.model, ComponentReports() + for comp in m.component_objects(): + comp_name = comp.name + # print(f"{comp_name} ({type(comp_name)})") + if not comp_name in r and self._has_report(comp): + rpt = self._get_report(comp) + if rpt is not None: + r.reports[comp_name] = rpt + return r + + @staticmethod + def _has_report(comp: object): + return isinstance(comp, UnitModelBlockData) and hasattr( + comp, "_get_performance_contents" + ) + + def _get_report(self, comp): + self.log.debug("begin _get_report()") + time_point = 0.0 + + is_fs = hasattr(comp, "is_flowsheet") and comp.is_flowsheet + rpt = PerfReport( + model_type="flowsheet" if is_fs else "unit", time_point=time_point + ) + + # Get DoF and model stats + if self._dof: + rpt.dof = dict( + dof_stat=degrees_of_freedom(comp), + num_variables=number_variables(comp), + num_act_constraints=number_activated_constraints(comp), + num_act_blocks=number_activated_blocks(comp), + ) + + # Get performance variables + debug = self.log.isEnabledFor(logging.DEBUG) + performance = comp._get_performance_contents(time_point=time_point) + if performance is None or performance == {}: + self.log.debug( + f"Empty performance contents for {rpt.model_type.value} model {comp}" + ) + if not self._allow_empty_perf: + self.log.debug(f"Skipping {comp} due to empty performance data") + return None # stop! + else: + # reformat variable values + for section in ("vars", "exprs", "params"): + try: + performance_section = performance[section] + except KeyError: + if section == "vars": + self.log.warning( + f"Missing '{section}' section in model report for {comp}" + ) + continue + if debug: + self.log.debug(f"section {section} for {comp.name}") + for k, v in performance_section.items(): + if section == "vars": + d = { + "value": report_quantity(v).m, + "units": str(report_quantity(v).u), + "fixed": v.fixed, + "bounds": v.bounds, + } + elif section == "exprs": + d = { + "value": report_quantity(v).m, + "units": str(report_quantity(v).u), + } + elif section == "params": + d = { + "value": report_quantity(v).m, + "units": str(report_quantity(v).u), + "mutable": not v.is_constant(), + } + else: + raise RuntimeError( + f"Internal logic error: bad performance section {section}" + ) + performance[section][k] = d + # leave other objects alone + rpt.performance = performance + + # Get stream table + try: + stream_table = comp._get_stream_table_contents(time_point=time_point) + stream_dict = stream_table.to_dict() + stream_dict["Units"] = {k: str(v) for k, v in stream_dict["Units"].items()} + except (AttributeError, ConfigurationError): + stream_dict = {} + rpt.stream_table = stream_dict + + self.log.debug("end _get_report()") + + return rpt + + def report(self) -> Report: + """Report containing unit model or flowsheet report values after each step.""" + return self._rpt diff --git a/src/idaes_fi/structfs/actions/model_variables.py b/src/idaes_fi/structfs/actions/model_variables.py index 0548beb..ffd8587 100644 --- a/src/idaes_fi/structfs/actions/model_variables.py +++ b/src/idaes_fi/structfs/actions/model_variables.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/actions/solver.py b/src/idaes_fi/structfs/actions/solver.py index af648ce..5335500 100644 --- a/src/idaes_fi/structfs/actions/solver.py +++ b/src/idaes_fi/structfs/actions/solver.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/actions/stream_table.py b/src/idaes_fi/structfs/actions/stream_table.py index fbf9be5..6012558 100644 --- a/src/idaes_fi/structfs/actions/stream_table.py +++ b/src/idaes_fi/structfs/actions/stream_table.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/actions/timer.py b/src/idaes_fi/structfs/actions/timer.py index 1bc80ee..1736aa7 100644 --- a/src/idaes_fi/structfs/actions/timer.py +++ b/src/idaes_fi/structfs/actions/timer.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/actions/unit_dof_checker.py b/src/idaes_fi/structfs/actions/unit_dof_checker.py index 4f71330..ab1be4b 100644 --- a/src/idaes_fi/structfs/actions/unit_dof_checker.py +++ b/src/idaes_fi/structfs/actions/unit_dof_checker.py @@ -1,8 +1,8 @@ ################################################################################# # Process Optimization and Modeling for Minerals Sustainability (PrOMMiS) Copyright (c) 2023-2026 # -# "Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)" was produced under the DOE -# Process Optimization and Modeling for Minerals Sustainability ("PrOMMiS") initiative, and is +# “Process Optimization and Modeling for Minerals Sustainability (PrOMMiS)” was produced under the DOE +# Process Optimization and Modeling for Minerals Sustainability (“PrOMMiS”) initiative, and is # copyrighted by the software owners: The Regents of the University of California, through Lawrence # Berkeley National Laboratory, National Technology & Engineering Solutions of Sandia, LLC through # Sandia National Laboratories, Carnegie Mellon University, University of Notre Dame, and West diff --git a/src/idaes_fi/structfs/common.py b/src/idaes_fi/structfs/common.py index 33abf77..420d61b 100644 --- a/src/idaes_fi/structfs/common.py +++ b/src/idaes_fi/structfs/common.py @@ -37,13 +37,14 @@ class ActionNames(Enum): - SOLVER_OUTPUT = "solver_output" - SOLVER_RESULTS = "solver_results" DIAGNOSTICS = "diagnostics" - MODEL_VARIABLES = "model_variables" + DOF = "degrees_of_freedom" MERMAID_DIAGRAM = "mermaid_diagram" + MODEL_REPORTS = "model_reports" + MODEL_VARIABLES = "model_variables" + SOLVER_OUTPUT = "solver_output" + SOLVER_RESULTS = "solver_results" STREAM_TABLE = "stream_table" - DOF = "degrees_of_freedom" TIMINGS = "timings" diff --git a/src/idaes_fi/structfs/fsrunner.py b/src/idaes_fi/structfs/fsrunner.py index 43f21d2..014e679 100644 --- a/src/idaes_fi/structfs/fsrunner.py +++ b/src/idaes_fi/structfs/fsrunner.py @@ -330,22 +330,21 @@ def __init__(self, solve_steps: list[str] = None, **kwargs): Diagnostics, StreamTable, UnitDofChecker, + UnitModelReport, ) super().__init__(**kwargs) - self.add_action(ActionNames.TIMINGS.value, Timer) - self.add_action( - ActionNames.DOF.value, - UnitDofChecker, - "fs", - [Steps.build, Steps.solve_initial, Steps.solve_optimization], - ) + dof_steps = [Steps.build, Steps.solve_initial, Steps.solve_optimization] + # note: put solver_output first to re-enable stdout self.add_action(ActionNames.SOLVER_OUTPUT.value, CaptureSolverOutput) - self.add_action(ActionNames.SOLVER_RESULTS.value, GetSolverResults) self.add_action(ActionNames.DIAGNOSTICS.value, Diagnostics) - self.add_action(ActionNames.MODEL_VARIABLES.value, ModelVariables) + self.add_action(ActionNames.DOF.value, UnitDofChecker, "fs", dof_steps) self.add_action(ActionNames.MERMAID_DIAGRAM.value, MermaidDiagram) + self.add_action(ActionNames.MODEL_REPORTS.value, UnitModelReport) + self.add_action(ActionNames.MODEL_VARIABLES.value, ModelVariables) + self.add_action(ActionNames.SOLVER_RESULTS.value, GetSolverResults) self.add_action(ActionNames.STREAM_TABLE.value, StreamTable) + self.add_action(ActionNames.TIMINGS.value, Timer) def build(self): """Run just the build step""" diff --git a/src/idaes_fi/structfs/logutil.py b/src/idaes_fi/structfs/logutil.py index 127d58e..ec22314 100644 --- a/src/idaes_fi/structfs/logutil.py +++ b/src/idaes_fi/structfs/logutil.py @@ -49,3 +49,13 @@ def unquiet(): lg = logging.getLogger(k) lg.setLevel(v) del g_quiet[k] + + +def init_fi(): + """Initialize logging for flowsheet inspector""" + log = logging.getLogger("idaes_fi") + if not log.hasHandlers(): + h = logging.StreamHandler() + fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") + h.setFormatter(fmt) + log.addHandler(h) diff --git a/src/idaes_fi/structfs/runner.py b/src/idaes_fi/structfs/runner.py index c081b09..c50d126 100644 --- a/src/idaes_fi/structfs/runner.py +++ b/src/idaes_fi/structfs/runner.py @@ -329,7 +329,7 @@ def _run_steps( p = None if mod.__name__ == "__main__": # if in VSCode, use special attr - nb_path = getattr(mod, "__vsc_ipynb_file__") + nb_path = getattr(mod, "__vsc_ipynb_file__", None) if nb_path: p = Path(nb_path) # clear any existing values @@ -356,7 +356,7 @@ def _run_steps( self._last_run_steps = [] # get indexes of first/last step - _log.warning( + _log.info( f"get indexes of first step '{names[0]}' and last step '{names[1]}' " f"in steps {self._step_names}" ) @@ -426,7 +426,7 @@ def _run_steps( if self._failed: _log.error("Run failed") else: - for action in self._actions.values(): + for action_name, action in self._actions.items(): try: action.after_run() except Exception as err: diff --git a/src/idaes_fi/structfs/simple_wrap.py b/src/idaes_fi/structfs/simple_wrap.py index 79dded6..ba8fc8d 100644 --- a/src/idaes_fi/structfs/simple_wrap.py +++ b/src/idaes_fi/structfs/simple_wrap.py @@ -27,7 +27,8 @@ # package from .fsrunner import BaseFlowsheetRunner -from .common import RESULT_FLOWSHEET_KEY, ActionNames +from .common import RESULT_FLOWSHEET_KEY, ActionNames, Steps +from .logutil import init_fi _log = logging.getLogger(__name__) @@ -49,25 +50,33 @@ def __init__(self, *args, **kwargs): MermaidDiagram, StreamTable, Diagnostics, + UnitModelReport, ) super().__init__(*args, **kwargs) self.main_func = None self.main_func_args = [] self.main_func_kwargs = {} - self.add_action(ActionNames.TIMINGS.value, Timer) - self.add_action(ActionNames.DOF.value, UnitDofChecker, "fs", ["build"]) + dof_steps = [Steps.build, Steps.solve_initial, Steps.solve_optimization] + # note: put solver_output first so stdout is re-enabled after + # solve steps for all other actions self.add_action(ActionNames.SOLVER_OUTPUT.value, CaptureSolverOutput) - self.add_action(ActionNames.SOLVER_RESULTS.value, GetSolverResults) - self.add_action(ActionNames.MODEL_VARIABLES.value, ModelVariables) + self.add_action(ActionNames.DIAGNOSTICS.value, Diagnostics) + self.add_action(ActionNames.DOF.value, UnitDofChecker, "fs", dof_steps) self.add_action(ActionNames.MERMAID_DIAGRAM.value, MermaidDiagram) + self.add_action(ActionNames.MODEL_REPORTS.value, UnitModelReport) + self.add_action(ActionNames.MODEL_VARIABLES.value, ModelVariables) + self.add_action(ActionNames.SOLVER_RESULTS.value, GetSolverResults) self.add_action(ActionNames.STREAM_TABLE.value, StreamTable) - self.add_action(ActionNames.DIAGNOSTICS.value, Diagnostics) + self.add_action(ActionNames.TIMINGS.value, Timer) # Global flowsheet runner, will create as needed _FS = SimpleFlowsheetRunner() +# Init logging +init_fi() + class _Wrapper: """Wrapper to create fi_main() decorator.""" diff --git a/src/idaes_fi/structfs/tests/test_fsrunner.py b/src/idaes_fi/structfs/tests/test_fsrunner.py index 2883f5a..729800f 100644 --- a/src/idaes_fi/structfs/tests/test_fsrunner.py +++ b/src/idaes_fi/structfs/tests/test_fsrunner.py @@ -236,7 +236,7 @@ def solve_initial(ctx): def extra_checks(runner): if runnerclass is FlowsheetRunner: assert runner.failed - assert "stream_table.after_run" in runner.failed_actions + assert len(runner.failed_actions) == 1 # stop after 1 failure runner.run_steps(save_report=False) extra_checks(runner) diff --git a/src/idaes_fi/structfs/tests/test_runner_actions.py b/src/idaes_fi/structfs/tests/test_runner_actions.py index 6b1ef9f..52daaf2 100644 --- a/src/idaes_fi/structfs/tests/test_runner_actions.py +++ b/src/idaes_fi/structfs/tests/test_runner_actions.py @@ -18,7 +18,7 @@ # stdlib import pprint -import time +from types import SimpleNamespace import pytest from pytest import approx @@ -33,6 +33,7 @@ StreamTable, CaptureSolverOutput, GetSolverResults, + UnitModelReport, ) from . import flash_flowsheet @@ -41,6 +42,7 @@ def set_tmp_db(tmp_path): dbpath = tmp_path / "test_runner_actions.db" flash_flowsheet.FS.set_report_db(dbfile=dbpath) + return dbpath class FakeRunner: @@ -291,3 +293,44 @@ def test_get_solver_results(failed): # Check that ScalarData values were copied over assert r.values["value"] == added_value.value assert r.values["dvalue"] == added_dict.value + + +@pytest.mark.integration +def test_unit_model_report(set_tmp_db): + struct_fs = flash_flowsheet.FS + struct_fs.build() + runner = FakeRunner() + runner.model = struct_fs.model + rpt_action = UnitModelReport(runner) + rpt_action.after_step("step1") + + rpt = rpt_action.report() + assert rpt + print(rpt.model_dump_json(indent=4)) + + last = rpt.step_reports[rpt.last_step].reports + # expect 1 component since only flash has performance + assert len(last) == 1 + for expected in ("fs.flash",): + assert expected in last + + # allow empty performance now + rpt_action = UnitModelReport(runner, allow_empty_performance=True) + rpt_action.after_step("step1") + rpt = rpt_action.report() + last = rpt.step_reports[rpt.last_step].reports + # should get 2 components, one of them with empty performance data + assert len(last) == 2 + for expected in ("fs.flash", "fs.flash.split"): + assert expected in last + + +@pytest.mark.unit +def test_unit_model_report_bad_perf(): + action = UnitModelReport(FakeRunner()) + # this component will trigger both the missing-var-section in performance contents + # and the attribute error for _get_stream_table_contents + comp = SimpleNamespace(_get_performance_contents=lambda time_point: {"foo": {}}) + action._dof = False # avoids calls involving `comp` + print("> get report") + action._get_report(comp)