Source code for openswmm.engine.catalog

# SPDX-License-Identifier: Apache-2.0
#
# Copyright 2026 Caleb Buahin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Machine-readable map of the Python engine API.

``catalog.json`` is generated from the stubs, Cython sources and C headers by
``python/scripts/gen_catalog.py``; CI fails when it is stale. It lists every
*target* reachable from a :class:`Solver` -- services such as ``forcing`` and
``surface2d.groundwater``, element kinds such as ``node`` and ``link`` and
their sub-views such as ``node.stats`` -- and every property and method on
them, with type, access, units, lifecycle phases and wrapped C symbols.

.. code-block:: python

    from openswmm.engine import catalog

    catalog.lookup("node.stats.max_depth")["units"]      # 'length'
    obj = catalog.resolve(solver, "node.stats", "J1")    # the live NodeStatsView
"""

from __future__ import annotations

import json
from functools import lru_cache
from pathlib import Path
from typing import Any

__all__ = ["load", "targets", "members", "lookup", "resolve", "unit_label", "UNIT_KINDS"]

_PATH = Path(__file__).with_name("catalog.json")


[docs] @lru_cache(maxsize=1) def load() -> dict[str, Any]: """Return the whole catalog (cached).""" data: dict[str, Any] = json.loads(_PATH.read_text(encoding="utf-8")) return data
@lru_cache(maxsize=1) def _by_path() -> dict[str, dict[str, Any]]: return {m["path"]: m for m in load()["members"]}
[docs] def targets() -> dict[str, dict[str, Any]]: """``{target name: entry}``; element targets carry ``collection`` and ``key``.""" entries: dict[str, dict[str, Any]] = load()["targets"] return entries
[docs] def members(target: str | None = None) -> list[dict[str, Any]]: """Members of one target, or of all targets.""" return [m for m in load()["members"] if target is None or m["target"] == target]
[docs] def lookup(path: str) -> dict[str, Any]: """Return the member entry for a dotted *path* such as ``"node.depth"``.""" try: return _by_path()[path] except KeyError: raise KeyError(f"{path!r} is not in the engine catalog") from None
[docs] def resolve(solver: Any, target: str, key: int | str | None = None) -> Any: """Return the live object behind *target* on *solver*. Element targets (``node``, ``link.pump``, ``species`` ...) need the element *key* (id or index). Standalone roots (``builder``, ``output``, ...) are not reachable from a solver and raise ``KeyError``. """ entry = targets()[target] if "construct" in entry: raise KeyError(f"{target!r} is a standalone root; construct it directly") obj = solver for part in filter(None, entry["path"].split(".")): if part.endswith("[]"): if key is None: raise ValueError(f"{target!r} is an element target; pass its id or index") obj = getattr(obj, part[:-2])[key] else: obj = getattr(obj, part) return obj
# Unit kinds (a member's ``units``) that are not literal units, and their labels. _FIXED = { "dimensionless": "dimensionless", "fraction": "fraction", "percent": "%", "count": "count", "manning_n": "Manning's n", "concentration": "pollutant concentration units", "user_defined": "units implied by the model's expressions", "shape_dependent": "depends on the cross-section shape (see xsect.shape)", "unverified": "stored as given; the engine applies no unit conversion", "datetime": "DateTime (decimal days)", } _BY_SYSTEM = { # kind: (US label, SI label) "length": ("ft", "m"), "area": ("ft2", "m2"), "land_area": ("ac", "ha"), "volume": ("ft3", "m3"), "velocity": ("ft/s", "m/s"), "rain_rate": ("in/hr", "mm/hr"), "rain_depth": ("in", "mm"), "evap_rate": ("in/day", "mm/day"), "temperature": ("degF", "degC"), "user_temperature": ("degF", "degC"), "wind_speed": ("mph", "km/hr"), "length2/s": ("ft2/s", "m2/s"), "weir_coeff": ("ft^0.5/s", "m^0.5/s"), "section_factor": ("ft^8/3", "m^8/3"), } #: Every symbolic unit kind; any other ``units`` value is a literal unit ("m3/s", "s"). UNIT_KINDS = frozenset(_FIXED) | frozenset(_BY_SYSTEM) | {"flow"}
[docs] def unit_label( kind: str | None, unit_system: str | None = None, flow_units: Any = None ) -> str | None: """Human-readable units for a catalog unit kind in a model's unit system. ``flow`` takes the model's flow units, as a token (``"CFS"``, ``"CMS"`` ...) or as ``Solver.flow_units`` itself; other system-dependent kinds take ``"US"`` or ``"SI"`` and come back unchanged without one; literal units pass through. .. code-block:: python catalog.unit_label(catalog.lookup("link.length")["units"], "SI") # 'm' """ if kind is None or kind in _FIXED: return _FIXED.get(kind) if kind else None if kind == "flow": if flow_units is None or flow_units == "": return kind # A FlowUnits member (CFS is 0, so falsy) labels by its name. return str(getattr(flow_units, "name", flow_units)) if kind in _BY_SYSTEM and unit_system in ("US", "SI"): return _BY_SYSTEM[kind][unit_system == "SI"] return kind