Skip to content

Junctions and plenums

How pipe port states become ghost states, volume rates and species inventories: the four junction kinds (the plenum Junction, the constant-pressure CorberanJunction, the EHRJunction and the PassthroughJunction), what each writes into the pipes' ghost rows, what balance each keeps, and where each refusal happens. The contracts of every function below are in their docstrings (the standard is Function contracts and flow charts); the stage they run in is drawn in Explicit network step, and the pipe side of a port face in Pipe time step.

Conventions that every junction shares

A Port names the pipe end that touches the junction: End.LEFT is the pipe's x = 0 end, End.RIGHT its x = L end. Every velocity and flux at a port is in the pipe's own +x frame. The sign that turns a pipe-frame flux into a rate on the volume is end_sign = +1 for a RIGHT port (+x leaves the pipe and enters the volume) and -1 for a LEFT port:

Quantity Value Notes
Volume mass rate sum end_sign * F_mass * A_port [kg/s] F is the HLLC face flux between the pipe's boundary cell and its ghost
Volume energy rate sum end_sign * F_energy * A_port [W] F_energy = u (rho E + p): the whole total-enthalpy flux, the gas in the volume being at rest
Species rate into the volume mass_into_vol * Y Y is the pipe's boundary-cell fraction on inflow, the volume's m_species / m on outflow
Conservation across a port exact only when A_port equals the pipe's end-face area the pipe integrates the same face flux over its own A_face[0] / A_face[-1]

The enthalpy a stream carries is the enthalpy of the state the HLLC sees on its own side. Gas leaving a pipe carries the pipe's boundary cell (its own entropy and total enthalpy); gas leaving the volume carries the reservoir state of the ghost. The plenum ghost is not the static (rho_V, 0, p_V): it is the mouth of a reservoir, an isentropic acceleration to the pipe's velocity (_plenum_ghost_primitives, feed branch, choked at Mach 1) when gas enters the pipe, and p = p_V at the pipe's own entropy (dump branch) when gas leaves it.

From a case to a junction

build_network builds the junction of each kind with the port area filled from the pipe. The component constructors refuse what cannot work, before any step.

flowchart TD
    IN["JunctionSpec: kind, ports, volume, init"] --> AV{"validate_case: _validate_junction_port_area, on an explicit port area"}
    AV -- "not positive and finite, or a gross mismatch with the pipe" --> XV["CaseValidationError"]
    AV -- "a small mismatch (a rounded literal)" --> WV["warning"]
    AV --> A{"_resolve_port_area"}
    A -- "area omitted" --> AF["the pipe's built end-face area A_face[0] or A_face[-1]"]
    A -- "area given" --> AG["kept as given"]
    AF --> K{"kind"}
    AG --> K
    K -- plenum --> BP["_build_plenum_junction: _make_volume_for_junction, Volume.set_state, Junction"]
    K -- ehr --> BE["_build_ehr_junction: the same volume, EHRPort per port"]
    K -- corberan --> BC["_build_corberan_junction: CPPort per port, K_loss"]
    K -- passthrough --> BT["_build_passthrough_junction: two Ports"]
    BP --> VM{"_make_volume_for_junction: ports share one EOS and one set of constants?"}
    VM -- no --> XM["ValueError"]
    VM --> JI{"Junction.__post_init__"}
    JI -- "neck_cells < 0, or a length without a neck" --> X1["ValueError"]
    JI -- "lodi on a real-gas pipe" --> X2["ValueError"]
    JI -- "more than one of neck, callable, lodi on a port" --> X3["ValueError"]
    JI -- "a neck on a real-gas, per-cell-gamma, mixture-gamma, low-Mach or unlike-gamma pipe" --> X4["ValueError: the neck would evaluate the shared face with another gas model"]
    JI --> NK["_build_neck_pipe: neck pipe, Rayleigh length written back to the port, at rest at the volume state"]
    NK -- "empty volume" --> X5["ValueError from Pipe._encode: state not physical"]
    JI --> SD["m_species seeded: plain average of the boundary fractions over the ports that carry each name"]
    BE --> EI{"EHRPort.__post_init__"}
    EI -- "L_eff not finite or <= 0, or a zero area with no L_eff" --> X6["ValueError"]
    BT --> PI{"PassthroughJunction.__post_init__"}
    PI -- "not two ports, same-end pairing, neck / callable / lodi, declared areas 5 % apart" --> X7["ValueError"]

One RK stage: pipe ports in, ghost states and rates out

fill_network_ghosts runs the far BCs, then one compiled sweep of the plain plenum ports (_adopt_ports decides which: plain Junction ports on a simple pipe and a simple volume, at least PORT_SWEEP_MIN_PORTS of them), then every component's apply_port_bcs, which skips exactly the swept ports' ghost fill and still writes their species slots. The pipe residuals then use those ghosts; stage_rates asks every component for its volume rates at the stage weight.

flowchart TD
    PS["pipe boundary cells: U_pad interior rows next to each port"] --> FG["fill_network_ghosts: far BCs, compiled plenum sweep, then each apply_port_bcs"]
    FG --> KIND{"junction kind"}
    KIND -- "Junction (plenum)" --> PL["plenum ghost per port (next chart), then species inflow slots"]
    KIND -- "CorberanJunction" --> CPB["algebraic constant-pressure solve (fourth chart)"]
    KIND -- "EHRJunction" --> EH["_fill_ehr_ghost: (rho_V, u_n, p_V) with the port's own neck velocity; species slots"]
    KIND -- "PassthroughJunction" --> PT{"_check_port_face_areas_match: both end faces > 0, within 5 %"}
    PT -- "NaN, zero, negative or 5 % apart" --> XP["ValueError, nothing written"]
    PT --> CP["_copy_pipe_face_to_ghost both ways: layer g from the other side's cell g; verbatim if the gas model matches at that slot, else re-encoded"]
    CP --> SX["_exchange_pipe_species: the other pipe's boundary fractions by species name; a missing name enters at 0"]
    PL --> GH["port ghost rows written on every Ng layer"]
    CPB --> GH
    EH --> GH
    SX --> GH
    GH --> RES["pipe residuals: HLLC at each port face against the ghost (make_pipe_residual_fn)"]
    RES --> ACC["stage_rates: _accumulate_at_weight, which calls accumulate_rates per junction"]
    ACC --> AR{"kind"}
    AR -- "Junction, EHRJunction" --> FLX["_pipe_port_flux per port: the SAME variant the pipe residual applies at that face; dm, dE, species rates recorded at the stage weight"]
    AR -- "CorberanJunction, PassthroughJunction" --> ZR["(0, 0): no volume state"]
    FLX --> UN["EHR only: port_u_rates = end_sign (p_pipe - p_V) / (rho_V L_eff)"]
    FLX --> CB["volume m and E advanced by the SSP combine; _advance_port_u_n and _combine_port_u_n advance u_n"]
    UN --> CB
    CB --> FS["post_rk: apply_fuel_step drains the stage-mean species and fuel rates"]

accumulate_rates reads whatever the ghost rows hold: it returns the stale flux on a stale ghost without complaint, so apply_port_bcs must have run for the current state. A zero-density ghost (an empty volume) fails in the flux kernel with ZeroDivisionError, not in the junction.

Inside a plenum port

Junction.apply_port_bcs picks the first strategy that matches; one port carries at most one of them (refused at construction).

flowchart TD
    P["one Port"] --> SW{"port._soa_ghost and skip_swept_ports?"}
    SW -- yes --> SK["ghost already filled by the compiled sweep: skipped"]
    SW -- no --> S{"strategy"}
    S -- "py_junction_bc" --> PY["_fill_port_ghost_py: callable gets the boundary cell as the pipe holds it and the volume state"]
    PY -- "NaN, rho <= 0 or p <= 0 returned" --> XPY["ValueError naming the state and the pipe end; ghost untouched"]
    S -- "lodi" --> LO["_fill_lodi_ghost: characteristic ghost, p_b relaxed towards p_V by sigma (1 - M^2)"]
    S -- "no neck" --> PG["_fill_plenum_ghost: _plenum_ghost_primitives, then _ghost_rho_E in the pipe's own model"]
    S -- "neck pipe" --> NG["_fill_main_pipe_from_neck: layer g from neck cell g; _fill_neck_far_ghost_from_volume: reservoir ghost plus _fill_neck_near_ghost_from_main"]
    PG --> DB{"gas direction at the port"}
    DB -- "leaving the pipe (u > 0 at RIGHT, u < 0 at LEFT)" --> DU["dump: p = p_V, density at the pipe's own entropy, the boundary cell's gamma"]
    DB -- "entering the pipe, or at rest" --> FE["feed: isentropic reservoir discharge to speed |u|, choked at M = 1; at rest exactly (rho_V, 0, p_V)"]
    SK --> SP
    PY --> SP
    LO --> SP
    DU --> SP
    FE --> SP
    NG --> SP
    SP["fuel and species: Y_fuel to the fuel slot, m_species / m to every other species slot; 0 for an empty volume"]

The rates side of the same port: accumulate_rates takes the port face flux (_pipe_port_flux, or _neck_volume_flux at the neck's volume-side face) and turns it into dm, dE and the per-species rates with the sign convention at the top. The wall loss h A (T_volume - T_wall) comes off dE, with h fixed or from a Nusselt correlation of the inflow mass rate only.

The constant-pressure junction

CorberanJunction.apply_port_bcs has no state: it finds the single static pressure p_J at which the port mass flows balance and writes the balanced states as ghosts. The balance is on the port states, not on the pipes' HLLC fluxes against them, and energy is carried by the mixing-cup total enthalpy of the branches entering the junction, so total energy is conserved only approximately across the joint: on three-port junctions of air (1.0 to 1.3 bar, 300 to 320 K, |u| 0 to 80 m/s) the enthalpy flux leaving the junction at the written port states differed from the flux entering it by -0.02 % to +0.6 %.

flowchart TD
    IP["_pipe_interior_primitives per port: rho, u, p, a, g of the boundary cell"] --> NP{"any ports?"}
    NP -- no --> X1["ValueError: no ports"]
    NP --> BR["bracket [0.5 min p, 2 max p]"]
    BR --> RF{"brentq on the net mass inflow"}
    RF -- "no sign change, or out of iterations" --> X2["RuntimeError naming the bracket and the port pressures; nothing written"]
    RF --> SO["_solve_ports(p_J): every port first as outflow from its pipe (_port_state, pipe entropy)"]
    SO --> ST["ports whose gas enters the junction: m_dot, h_t, p, T go to _weighted_stagnation (mixing cup)"]
    ST --> RE["ports whose gas enters their pipe: _port_state again with the stagnation state: density from the junction, velocity from the pipe"]
    RE --> TOT["net mass inflow: sum sigma rho_b u_b A_port"]
    TOT --> RF
    RF --> GH["_fill_pipe_ghost with rho E from _ghost_rho_E in the pipe's own model; p_J, port_states set"]
    GH --> SC["_apply_cp_species_coupling: mixing-cup composition by species name to the ports whose gas enters their pipe"]

With K_loss > 0 a port's static pressure sits above p_J by K rho_b u_b^2 / 2 (a damped fixed point inside _port_state, at most 40 passes, last iterate returned without a warning).

Functions

  • src/aeolus1d/io/build.py::build_network
  • src/aeolus1d/io/build.py::_resolve_port_area
  • src/aeolus1d/io/validate.py::validate_case
  • src/aeolus1d/io/validate.py::_validate_junction_port_area
  • src/aeolus1d/io/build.py::_make_volume_for_junction
  • src/aeolus1d/io/build.py::_build_plenum_junction
  • src/aeolus1d/io/build.py::_build_ehr_junction
  • src/aeolus1d/io/build.py::_build_corberan_junction
  • src/aeolus1d/io/build.py::_build_passthrough_junction
  • src/aeolus1d/components/junction.py::Junction.__post_init__
  • src/aeolus1d/components/junction.py::Junction.neck_pipes
  • src/aeolus1d/components/junction.py::Junction.apply_port_bcs
  • src/aeolus1d/components/junction.py::Junction.accumulate_rates
  • src/aeolus1d/components/junction.py::Junction.apply_fuel_step
  • src/aeolus1d/components/junction.py::_ghost_rho_E
  • src/aeolus1d/components/junction.py::_real_gas_cell_p_and_gamma
  • src/aeolus1d/components/junction.py::_pipe_cell_gamma
  • src/aeolus1d/components/junction.py::_plenum_ghost_primitives
  • src/aeolus1d/components/junction.py::_fill_plenum_ghost
  • src/aeolus1d/components/junction.py::_fill_port_ghost_py
  • src/aeolus1d/components/junction.py::_fill_main_pipe_from_neck
  • src/aeolus1d/components/junction.py::_fill_neck_far_ghost_from_volume
  • src/aeolus1d/components/junction.py::_fill_neck_near_ghost_from_main
  • src/aeolus1d/components/junction.py::_pipe_port_flux
  • src/aeolus1d/components/junction.py::_pipe_port_flux_muscl
  • src/aeolus1d/components/junction.py::_neck_volume_flux
  • src/aeolus1d/components/junction.py::_rayleigh_end_correction
  • src/aeolus1d/components/junction.py::_build_neck_pipe
  • src/aeolus1d/components/junction.py::_fill_lodi_ghost
  • src/aeolus1d/components/junction_cp.py::CorberanJunction.apply_port_bcs
  • src/aeolus1d/components/junction_cp.py::CorberanJunction._solve_ports
  • src/aeolus1d/components/junction_cp.py::CorberanJunction.mass_flows
  • src/aeolus1d/components/junction_cp.py::_pipe_interior_primitives
  • src/aeolus1d/components/junction_cp.py::_port_state
  • src/aeolus1d/components/junction_cp.py::_weighted_stagnation
  • src/aeolus1d/components/junction_cp.py::_apply_cp_species_coupling
  • src/aeolus1d/components/junction_cp.py::_fill_pipe_ghost
  • src/aeolus1d/components/junction_ehr.py::EHRPort.__post_init__
  • src/aeolus1d/components/junction_ehr.py::EHRJunction.__post_init__
  • src/aeolus1d/components/junction_ehr.py::EHRJunction.apply_port_bcs
  • src/aeolus1d/components/junction_ehr.py::EHRJunction.accumulate_rates
  • src/aeolus1d/components/junction_ehr.py::EHRJunction.apply_fuel_step
  • src/aeolus1d/components/junction_ehr.py::_fill_ehr_ghost
  • src/aeolus1d/components/junction_passthrough.py::PassthroughJunction.__post_init__
  • src/aeolus1d/components/junction_passthrough.py::PassthroughJunction.apply_port_bcs
  • src/aeolus1d/components/junction_passthrough.py::_check_port_face_areas_match
  • src/aeolus1d/components/junction_passthrough.py::_copy_pipe_face_to_ghost
  • src/aeolus1d/components/junction_passthrough.py::_same_gas_at
  • src/aeolus1d/components/junction_passthrough.py::_exchange_pipe_species
  • src/aeolus1d/components/junction_passthrough.py::_write_inflow_from_other
  • src/aeolus1d/components/species_coupling.py::pipe_fuel_key
  • src/aeolus1d/components/species_coupling.py::read_pipe_boundary_fuel
  • src/aeolus1d/components/species_coupling.py::write_pipe_inflow_fuel
  • src/aeolus1d/components/species_coupling.py::iter_pipe_species
  • src/aeolus1d/components/pipe.py::Pipe._encode
  • src/aeolus1d/components/pipe.py::Pipe.primitives
  • src/aeolus1d/components/pipe.py::Pipe.apply_closed_ends
  • src/aeolus1d/components/pipe.py::Pipe.apply_transmissive_ends
  • src/aeolus1d/bc/base.py::fill_network_ghosts
  • src/aeolus1d/solver/soa.py::_adopt_ports
  • src/aeolus1d/solver/soa.py::port_ghost_sweep
  • src/aeolus1d/solver/soa.py::_ghost_core
  • src/aeolus1d/solver/timeloop.py::make_pipe_residual_fn
  • src/aeolus1d/solver/network_timeloop.py::_ExplicitNetwork.stage_rates
  • src/aeolus1d/solver/network_timeloop.py::_accumulate_at_weight
  • src/aeolus1d/solver/network_timeloop.py::_advance_port_u_n
  • src/aeolus1d/solver/network_timeloop.py::_combine_port_u_n
  • src/aeolus1d/solver/network_timeloop.py::_ExplicitNetwork.post_rk