Source code for fluidsolve.path

'''
One-dimensional hydraulic path model (series-connected components).

This module implements a simplified hydraulic topology for ordered component
chains. A ``Path`` is effectively a restricted network with:

* no branches,
* no cycles,
* explicit component order,
* explicit per-component flow direction (sense).

Compared with the full network solver, this representation is easier to build
and reason about when modeling single-line circuits or pump-and-piping strings.

Main responsibilities:

* store and validate ordered component entries,
* evaluate total head/pressure behavior along the chain,
* solve for operating points when combined with pump/circuit relations,
* expose convenience views for plotting and reporting.

Conventions:

* each entry stores ``comp``, ``sense``, ``pin``, and ``pout``,
* component physics is delegated to each component's ``calcH`` / ``calcP``,
* path-level solve routines aggregate these local relations in order.

Typical usage::

  p = Path(name='loop', components=[
      {'comp': pump, 'sense': +1, 'pin': 1, 'pout': 2},
      {'comp': tube, 'sense': +1, 'pin': 1, 'pout': 2},
  ])
  H = p.calcH(2.0 * u.m**3 / u.h)

Use ``Path`` when a full graph-based network is unnecessary and a deterministic
series model is sufficient.
'''
# =============================================================================
# PYLINT DIRECTIVES
# =============================================================================

# =============================================================================
# IMPORTS
# =============================================================================
from typing import Any
# module own
import fluidsolve.medium      as flsme
import fluidsolve.aux_tools   as flsa
import fluidsolve.comp_base   as flsb
import fluidsolve.wpoint      as flswp
# units
u         = flsme.unitRegistry
Quantity  = flsme.Quantity  # type: ignore[misc]

# =============================================================================
# PATH CLASS
# =============================================================================
[docs] class Path(flsb.Comp_Base): ''' One-dimensional hydraulic path of series-connected components. Ordered, branch-free, cycle-free subset of a network with an explicit flow direction per component. Args: name (str, optional): Path label. components (list, optional): Ordered list of component dicts to add via addComponents. ''' # -------------------------------------------------------------------------- # FIXED PROPERTIES # -------------------------------------------------------------------------- # INITIALIZE # Path intentionally does not rely on Comp_Base.__init__ state.
[docs] def __init__(self, **kwargs: Any) -> None: # pylint: disable=super-init-not-called args_in = flsa.GetArgs(kwargs) self._name: str = args_in.getArg( 'name', [ flsa.vFun.default(''), flsa.vFun.istype(str), ] ) components: list = args_in.getArg( 'components', [ flsa.vFun.default([]), flsa.vFun.istype(list), ] ) args_in.isEmpty() self._items : list = [] self.addComponents(components)
#---------------------------------------------------------------------------- # PROPERTIES @property def name(self) -> str: ''' Path name. ''' return self._name @property def components(self) -> Any: ''' Ordered list of component entries (comp, sense, pin, pout). ''' return self._items
[docs] def getComp(self, idx: int) -> dict: ''' Return the component entry at index ``idx``. Args: idx (int): Component index. Returns: dict: Component entry with keys ``comp``, ``sense``, ``pin``, ``pout``. ''' return self._items[idx]
[docs] def setComp(self, idx: int, item: dict) -> dict: ''' Replace the component entry at index ``idx``. Args: idx (int): Component index. item (dict): Component entry with keys ``comp``, ``sense``, ``pin``, ``pout``. Returns: dict: Stored component entry. ''' self._items[idx] = item return item
#---------------------------------------------------------------------------- # METHODS
[docs] def addComponents(self, components: list) -> None: ''' Append components to the path. Args: components (list): List of dicts with keys ``comp``, optional ``sense``, ``pin``, and ``pout``. ''' for item in components: if not isinstance(item, dict): raise ValueError(f'Invalid component entry (dict expected): {item}') if 'comp' not in item: raise ValueError(f'Component entry missing key "comp": {item}') comp = item['comp'] sense = item.get('sense', +1) pin = item.get('pin', None) pout = item.get('pout', None) if not isinstance(comp, flsb.Comp_Base): raise ValueError(f'Unknown component: {comp}') if comp.sign > 0: raise ValueError(f'Path accepts only dissipative components (sign=-1), got sign={comp.sign} for "{comp.name}"') if sense not in (+1, -1): raise ValueError(f'sense must be +1 or -1, got {sense}') if comp.nports > 2: if pin is None or pout is None: raise ValueError(f'Component "{comp.name}" has {comp.nports} ports, need pin and pout') if not (1 <= pin <= comp.nports and 1 <= pout <= comp.nports): raise ValueError(f'Invalid ports pin={pin}, pout={pout} for component "{comp.name}"') else: # Implicit 2-port behavior. pin = 1 pout = 2 self._items.append({'comp': comp, 'sense': sense, 'pin': pin, 'pout': pout})
#---------------------------------------------------------------------------- # PHYSICS
[docs] def calcH(self, Q: Quantity, sense: int=1, pin: Any=None, pout: Any=None) -> Quantity: ''' Calculate total head change along the path. Args: Q: Flow rate. sense: Path flow direction (+1 or -1). Returns: Quantity: Total head change (m). ''' H = 0.0 * u.m for item in self._items: comp = item['comp'] csense = item['sense'] * sense pin = item['pin'] pout = item['pout'] H += comp.calcH(Q, csense, pin, pout) return H
[docs] def calcP(self, Q: Quantity, sense: int=1, pin: Any=None, pout: Any=None) -> Quantity: ''' Calculate total pressure change along the path. Args: Q: Flow rate. sense: Path flow direction (+1 or -1). Returns: Quantity: Total pressure change. ''' P = 0.0 * u.bar for item in self._items: P += item['comp'].calcP(Q, item['sense']*sense, item['pin'], item['pout']) return P
[docs] def calcHprofile(self, Q: int | float | Quantity, sense: int=1, pin: int=1, pout:int=2, incr: bool=False) -> Any: # pylint: disable=unused-argument ''' Build a list of working points over the path. Args: Q (int | float | Quantity): Flow rate. sense (int, optional): Path flow direction (+1 or -1). pin (int, optional): Unused compatibility argument. pout (int, optional): Unused compatibility argument. incr (bool, optional): If True, head is accumulated component by component. Returns: list[flswp.Wpoint]: Working points per component plus total. ''' lQ = flsa.toUnits(Q, u.m**3/u.h) pts = [] H = 0 *u.m for i, item in enumerate(self._items): if incr: H = H + item['comp'].calcH(lQ, sense*item['sense'], item['pin'], item['pout']) else: H = item['comp'].calcH(lQ, sense*item['sense'], item['pin'], item['pout']) pts.append(flswp.Wpoint(name=f"{i}:{item['comp'].name}", Q=lQ, H=H)) H = self.calcH(lQ, sense) pts.append(flswp.Wpoint(name='Tot', Q=lQ, H=H)) return pts
#---------------------------------------------------------------------------- # REPRESENTATION
[docs] def __str__(self) -> str: ''' Return a compact string representation. Returns: str: Compact path summary. ''' return self.toString(detail=0)
def __format__(self, format_spec: str) -> str: if format_spec == '': return str(self) try: detail = int(format_spec) except ValueError as exc: raise ValueError(f'Invalid format spec for {type(self).__name__}: {format_spec!r}') from exc return self.toString(detail)
[docs] def toString(self, detail: int = 0) -> str: ''' Return a formatted multi-line path description. Args: detail (int, optional): Include extra metadata when non-zero. Returns: str: Formatted path text. ''' txt = f'Path "{self._name}"' if detail == 0: txt = f': {len(self._items)} Components "\n' else: txt += f'\n Components ({len(self._items)}):\n' txt += self.componentsString() return txt
[docs] def componentsString(self) -> str: ''' Format the ordered component list for display. Returns: str: Components section text. ''' if not self._items: return ' <none>\n\n' idx_w = max(3, len(str(len(self._items)))) name_w = max(4, max(len(getattr(item['comp'], 'name', item['comp'].__class__.__name__)) for item in self._items)) type_w = max(4, max(len(item['comp'].__class__.__name__) for item in self._items)) ports_w = max(6, max(len(f"{item['pin']} -> {item['pout']}") for item in self._items)) header = f'{"idx":>{idx_w}} | {"Comp":<{name_w}} | {"Type":<{type_w}} | {"Dir":<3} | {"Ports":<{ports_w}}' # pylint: disable=inconsistent-quotes txt = ' ' + header + f'\n {"-" * len(header)}\n' # pylint: disable=inconsistent-quotes for i, item in enumerate(self._items, start=1): comp = item['comp'] compstr = getattr(comp, 'name', '-') dir_txt = '→' if item['sense'] > 0 else '←' ports_txt = f"{item['pin']} -> {item['pout']}" txt += f' {i:>{idx_w}} | {compstr:<{name_w}} | {comp.__class__.__name__:<{type_w}} | {dir_txt} | {ports_txt:<{ports_w}}\n' return txt