Source code for cases.alpha_d.physics.baseline
"""Closed-form sudden contraction + throat-friction alpha_D baseline.
For an axisymmetric pipe with an abrupt contraction-then-expansion this
baseline keeps two terms whose CFD signature is genuinely localised:
ΔP_baseline = K_c · q_throat + f_Darcy · (L_throat / D_throat) · q_throat
with q_throat = 0.5 ρ V_throat², and
β² = (D_throat / D_big)² = Dr²
K_c = 0.5 · (1 − β²) # sudden contraction (Idelchik)
V_throat = V_bulk / Dr²
Re_throat = Re_bulk / Dr
f_Darcy = 0.316 · Re_throat^(-1/4) # Blasius (turbulent)
For this dataset Re_throat ∈ [5.5e3, 7.5e5] — always turbulent, so the
laminar branch is omitted.
The Borda-Carnot expansion loss K_e = (1 − β²)² is **deliberately not
included**. In CFD the expansion loss is dissipated gradually over
several diameters downstream, and the local α_D right at the expansion
plane is dominated by Bernoulli-like pressure recovery (often slightly
negative). Adding K_e as a single positive spike at the outlet bin
forces the model to fight a phantom artifact (worth O(K_e · D_h / dz)
in α_D), which empirically destroyed the residual fit. We let the
model learn the distributed downstream loss on its own.
The baseline is exposed as a *profile* α_D(z) so that integrating
dp/dz = α_D · ρ V_bulk² / (2 D_h) # bulk-basis convention
over the ROI reproduces ΔP_baseline above. K_c is localised at the
contraction-inlet bin (single station of physical width dz_phys =
L_roi / n_stations); friction is uniform across the throat interior.
The closed form is intentionally simple — its job is to provide a
smooth shape prior so the MLP can fit the *residual* against CFD,
not to replace the CFD computation.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
[docs]
@dataclass(frozen=True)
class BaselineGeometry:
"""Per-case geometry constants required to evaluate the baseline."""
Re: float
Dr: float
Lr: float
D_big: float = 0.2
outer_height_m: float = 1.0
buffer_diams: float = 1.0
rho: float = 1.0
V_bulk: float = 1.0
n_stations: int = 50
@property
def L_roi(self) -> float:
return self.Lr * self.outer_height_m + 2.0 * self.buffer_diams * self.D_big
@property
def dz_phys(self) -> float:
return self.L_roi / float(self.n_stations)
@property
def dz_norm(self) -> float:
return 1.0 / float(self.n_stations)
@property
def z_throat_start_norm(self) -> float:
# z_roi_start = 0.5*outer - buffer*D_big → throat start = 0.5*outer
return self.buffer_diams * self.D_big / self.L_roi
@property
def z_throat_end_norm(self) -> float:
return (self.Lr * self.outer_height_m + self.buffer_diams * self.D_big) / self.L_roi
def _blasius_friction_factor(re: float | np.ndarray) -> float | np.ndarray:
"""Darcy friction factor from Blasius (turbulent)."""
return 0.316 * np.power(np.maximum(re, 1e-12), -0.25)
[docs]
def alpha_d_baseline_profile(z_hat: np.ndarray, geom: BaselineGeometry) -> np.ndarray:
"""Compute the bulk-basis α_D baseline at each station.
Parameters
----------
z_hat
Per-station normalised axial coordinate, shape ``[n_stations]``.
geom
Geometry + flow constants for the case.
Returns
-------
alpha_D : ndarray
Bulk-basis α_D = -dp/dz · 2 D_h / (ρ V_bulk²) at each station.
"""
z = np.asarray(z_hat, dtype=np.float64)
Dr = geom.Dr
Dr2 = Dr * Dr
Dr4 = Dr2 * Dr2
K_c = 0.5 * (1.0 - Dr2)
Re_throat = geom.Re / Dr
f_blasius = _blasius_friction_factor(Re_throat)
D_h_throat = geom.D_big * Dr
in_throat = (z >= geom.z_throat_start_norm) & (z <= geom.z_throat_end_norm)
if not np.any(in_throat):
# Degenerate (Lr → 0); model has no signal here either.
return np.zeros_like(z)
is_inlet_bin = in_throat & ((z - geom.z_throat_start_norm) < geom.dz_norm)
alpha = np.zeros_like(z)
# Throat-interior friction (constant over the throat in bulk basis):
# α_D_friction_bulk = f / Dr⁴, since α_D_local = f and the
# bulk↔local rescaling is V_throat²/V_bulk² = 1/Dr⁴.
alpha[in_throat] += f_blasius / Dr4
# K_c localised in the inlet bin, scaled so it integrates to
# ΔP_c = K_c * 0.5 * ρ * V_throat²
# over a span of dz_phys.
alpha[is_inlet_bin] += K_c * D_h_throat / (Dr4 * geom.dz_phys)
# K_e is intentionally omitted — see the module docstring for why.
return alpha
[docs]
def integrated_baseline_delta_p(geom: BaselineGeometry) -> float:
"""ΔP predicted by the closed-form profile (K_c + throat friction).
K_e is intentionally excluded — see the module docstring.
"""
Dr2 = geom.Dr**2
K_c = 0.5 * (1.0 - Dr2)
Re_throat = geom.Re / geom.Dr
f = _blasius_friction_factor(Re_throat)
V_throat = geom.V_bulk / Dr2
q_throat = 0.5 * geom.rho * V_throat**2
L_throat = geom.Lr * geom.outer_height_m
D_throat = geom.D_big * geom.Dr
return float((K_c + f * L_throat / D_throat) * q_throat)