From 9d745c1ed90e78739479143e2f684c78f3970b68 Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:15:33 -0500 Subject: [PATCH 01/10] plume: dispatch collisionless plume models through a small registry The strike pipeline hard-coded SimplifiedGasKinetics, so a study could name a model but never select one. CollisionlessGasKinetics already subclasses it with an identical constructor and overrides only the field getters, so dispatch is a class lookup rather than a plugin framework. pyrpod/plume/gas_kinetics_models.py adds that lookup plus the two pieces of model-independent physics the pipeline needs: * local_field_state() reduces any model to one common LocalFieldState (number density, mass density, axial/radial velocity, velocity magnitude, temperature, local speed ratio), calling every field getter through the instance so an overriding model is honoured; * maxwellian_surface_loads() applies the Shen gas-surface interaction to that state, so pressure/shear/heat-transfer logic is written once. The model is selected by the case's existing [pm] kinetics key (Simplified, Collisionless, or None to disable surface loads), so both the vectorized core and the scalar reference pick it up without any change to PlumeStrikeEstimationStudy. An unknown key is an error, never a silent fall back. Omitting it keeps the historical behavior, and the Simplified path is preserved bit-for-bit. Co-Authored-By: Claude Opus 5 --- pyrpod/plume/PlumeStrikeCalculator.py | 95 ++++---- pyrpod/plume/gas_kinetics_models.py | 328 ++++++++++++++++++++++++++ 2 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 pyrpod/plume/gas_kinetics_models.py diff --git a/pyrpod/plume/PlumeStrikeCalculator.py b/pyrpod/plume/PlumeStrikeCalculator.py index fb06572..36e267c 100644 --- a/pyrpod/plume/PlumeStrikeCalculator.py +++ b/pyrpod/plume/PlumeStrikeCalculator.py @@ -16,7 +16,7 @@ - The vectorized core operates on plain serializable inputs (arrays, dicts, floats) so it can also run inside process-based workers. - Surface loads use the TRUE incidence angle: the positional off-axis angle - theta locates a face in the plume field (SimplifiedGasKinetics evaluates + theta locates a face in the plume field (the selected plume model evaluates n, U, T there, and the legacy 3.14-based theta still gates the wedge hit test, bit-for-bit unchanged), but the Shen/Maxwellian wall formulas receive the angle between the local flow direction (face centroid minus @@ -24,6 +24,12 @@ normal. Previously the positional theta was fed to the wall formulas as the incidence angle, so plate orientation never affected load magnitudes; _surface_loads_with_incidence() is the shared fix for both paths. +- Which collisionless plume model evaluates the field is selected by the + case's [pm] kinetics key and dispatched through + pyrpod.plume.gas_kinetics_models (Simplified -> SimplifiedGasKinetics, + Collisionless -> CollisionlessGasKinetics). Both reduce to the same + LocalFieldState, so the surface-load logic is written once; omitting the + key keeps the historical SimplifiedGasKinetics behavior. Future work (no new dependencies planned): - Vectorize the SimplifiedGasKinetics evaluations for struck faces. @@ -38,13 +44,14 @@ from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, overload import numpy as np -from pyrpod.plume.RarefiedPlumeGasKinetics import ( - AVOGADROS_NUMBER, - Scalar, - SimplifiedGasKinetics, - get_maxwellian_heat_transfer, - get_maxwellian_pressure, - get_maxwellian_shear_pressure, +from pyrpod.plume.RarefiedPlumeGasKinetics import Scalar, SimplifiedGasKinetics +from pyrpod.plume.gas_kinetics_models import ( + DEFAULT_PLUME_MODEL, + KINETICS_DISABLED, + create_model, + local_field_state, + maxwellian_surface_loads, + model_name_for_kinetics, ) @@ -55,38 +62,26 @@ def _surface_loads_with_incidence( simple_plume carries the plume-field state at the face's position (its constructor theta is the positional off-axis angle -- a plume-field - coordinate); this helper extracts the same local field values the class's - own get_pressure/get_shear_pressure/get_heat_flux use (including their - exact-centerline branch at theta == 0) and feeds them to the Shen wall + coordinate); this helper reduces it to the model-independent + LocalFieldState (the same local field values the class's own + get_pressure/get_shear_pressure/get_heat_flux use, including their + exact-centerline branch at theta == 0) and feeds that to the Shen wall formulas with `incidence`, the angle between the local flow direction (radial from the thruster exit) and the face unit normal. + Any model in pyrpod.plume.gas_kinetics_models.PLUME_MODELS is accepted; + the reduction and the wall formulas both live in that module, so the + surface-load logic is written once for every model. + Returns (pressure, shear, heat_flux) in SI units; shear is signed as returned by get_maxwellian_shear_pressure (callers take abs, matching the legacy accumulation). """ - if simple_plume.theta != 0: # not on plume centerline - n_inf = simple_plume.n_0 * simple_plume.get_num_density_ratio() - T = simple_plume.T_0 * simple_plume.get_temp_ratio() - u = simple_plume.get_U_normalized() / simple_plume.beta_0 - w = simple_plume.get_W_normalized() / simple_plume.beta_0 - U = np.sqrt(u ** 2 + w ** 2) - else: # on plume centerline: exact closed forms - n_inf = simple_plume.n_0 * simple_plume.get_num_density_centerline() - T = simple_plume.T_0 * simple_plume.get_temp_centerline() - U = simple_plume.get_velocity_centerline() / simple_plume.beta_0 - rho_inf = n_inf * simple_plume.molar_mass / AVOGADROS_NUMBER - S = U * simple_plume.get_beta(T) - - sigma = simple_plume.sigma - T_w = simple_plume.T_w - pressure = get_maxwellian_pressure(rho_inf, U, S, sigma, incidence, - T, T_w) - shear = get_maxwellian_shear_pressure(rho_inf, U, S, sigma, incidence) - heat_flux = get_maxwellian_heat_transfer(rho_inf, S, sigma, incidence, - T, T_w, simple_plume.R, - simple_plume.gamma) - return pressure, shear, heat_flux + state = local_field_state(simple_plume) + return maxwellian_surface_loads(state, sigma=simple_plume.sigma, + T_w=simple_plume.T_w, R=simple_plume.R, + gamma=simple_plume.gamma, + incidence=incidence) def compute_face_centroids(vectors: np.ndarray) -> np.ndarray: @@ -115,21 +110,29 @@ def extract_plume_params(environment: Any) -> Dict[str, Any]: """Extract the plain config values needed for strike computation. Returns a picklable dict (radius, wedge_theta, use_kinetics, and — only - when kinetics is enabled — surface_temp and sigma) so workers never need - the full environment object. + when kinetics is enabled — surface_temp, sigma and the selected + plume_model) so workers never need the full environment object. + + The plume model is named by the case's ``[pm] kinetics`` key + (``Simplified`` -> SimplifiedGasKinetics, ``Collisionless`` -> + CollisionlessGasKinetics; see pyrpod.plume.gas_kinetics_models). An + unknown key is an error rather than a silent fall back to the default. """ config = environment.config - use_kinetics = config['pm']['kinetics'] != 'None' + kinetics = config['pm']['kinetics'] + use_kinetics = kinetics != KINETICS_DISABLED params: Dict[str, Any] = { 'radius': float(config['plume']['radius']), 'wedge_theta': float(config['plume']['wedge_theta']), 'use_kinetics': use_kinetics, 'surface_temp': None, 'sigma': None, + 'plume_model': None, } if use_kinetics: params['surface_temp'] = float(config['tv']['surface_temp']) params['sigma'] = float(config['tv']['sigma']) + params['plume_model'] = model_name_for_kinetics(kinetics) return params @@ -144,7 +147,8 @@ def _compute_plume_strikes_core( """Vectorized strike computation on plain serializable inputs. Geometry is evaluated with NumPy over all faces per active thruster. - Gas-kinetics quantities remain scalar: SimplifiedGasKinetics is + Gas-kinetics quantities remain scalar: the selected plume model (see + plume_params['plume_model'], defaulting to SimplifiedGasKinetics) is instantiated only for struck face indices, exactly as in the scalar reference. Memory scales with the number of faces (a few (N,) and (N,3) temporaries), independent of the number of firings. @@ -153,6 +157,9 @@ def _compute_plume_strikes_core( strikes = np.zeros(num_faces) use_kinetics = plume_params['use_kinetics'] + # Absent for a plume_params dict built before model dispatch existed; + # the default is the model the pipeline has always used. + model_name = plume_params.get('plume_model') or DEFAULT_PLUME_MODEL if use_kinetics: pressures = np.zeros(num_faces) shear_stresses = np.zeros(num_faces) @@ -229,8 +236,9 @@ def _compute_plume_strikes_core( incidence = np.arccos(np.clip( (unit_distance * normals).sum(axis=1), -1.0, 1.0)) for idx in np.nonzero(hit)[0]: - simple_plume = SimplifiedGasKinetics( - norm_distance[idx], theta[idx], metrics, T_w, sigma + simple_plume = create_model( + model_name, norm_distance[idx], theta[idx], metrics, + T_w, sigma ) p, shear, hf = _surface_loads_with_incidence( simple_plume, incidence[idx]) @@ -303,13 +311,16 @@ def _compute_plume_strikes_scalar( debugging, and benchmarking against the vectorized path; the two must produce identical strike arrays, struck-face IDs, and load values. Surface loads use the true incidence angle via the shared - _surface_loads_with_incidence(), exactly as the vectorized core does. + _surface_loads_with_incidence(), and the plume model is selected through + the same factory, exactly as the vectorized core does. """ num_faces = len(target_mesh.vectors) strikes = np.zeros(num_faces) - use_kinetics = environment.config['pm']['kinetics'] != 'None' + kinetics = environment.config['pm']['kinetics'] + use_kinetics = kinetics != KINETICS_DISABLED if use_kinetics: + model_name = model_name_for_kinetics(kinetics) pressures = np.zeros(num_faces) shear_stresses = np.zeros(num_faces) heat_flux = np.zeros(num_faces) @@ -369,7 +380,7 @@ def _compute_plume_strikes_scalar( sigma = float(environment.config['tv']['sigma']) t_type = vv.thruster_data[thruster_id]['type'][0] thruster_metrics = vv.thruster_metrics[t_type] - simple_plume = SimplifiedGasKinetics(norm_distance, theta, thruster_metrics, T_w, sigma) + simple_plume = create_model(model_name, norm_distance, theta, thruster_metrics, T_w, sigma) # True incidence angle (see module header): local flow # direction -unit_distance vs the face unit normal. incidence = np.arccos(np.clip( diff --git a/pyrpod/plume/gas_kinetics_models.py b/pyrpod/plume/gas_kinetics_models.py new file mode 100644 index 0000000..4ea4c1c --- /dev/null +++ b/pyrpod/plume/gas_kinetics_models.py @@ -0,0 +1,328 @@ +""" +Collisionless plume-model dispatch and the shared local field state. + +PyRPOD ships two collisionless analytical plume models, both from Cai & Wang +2012 and both already verified in this repository: + +* :class:`~pyrpod.plume.RarefiedPlumeGasKinetics.SimplifiedGasKinetics` -- + the far-field simplification (Eq. 13's ``Q'``), and +* :class:`~pyrpod.plume.RarefiedPlumeGasKinetics.CollisionlessGasKinetics` -- + the full model, which integrates the exact special factor Q over the finite + exit disk and therefore stays valid in the near field. + +The second is a subclass of the first with an identical constructor, so +selecting between them is a class lookup, not a plugin framework. This module +is that lookup (:data:`PLUME_MODELS`, :func:`create_model`) plus the two +pieces of model-INDEPENDENT physics the strike pipeline needs: + +``local_field_state(model)`` + Reduces whichever model was selected to one common + :class:`LocalFieldState` -- number density, mass density, axial/radial + velocity, velocity magnitude, temperature and local speed ratio at the + evaluated point. Every model-specific getter is called through the + instance, so a model that overrides the field solutions is honoured + without this function knowing which one it holds. + +``maxwellian_surface_loads(state, ...)`` + Applies the Maxwellian (Shen) gas-surface interaction to that common + state. It is written once here, so pressure, shear and heat-transfer + logic is never duplicated per model. + +Scope +----- +Both models are COLLISIONLESS. Nothing here consumes a Knudsen number, +applies a collisional correction, or reads DSMC data; the study layer records +Kn purely as derived metadata (see :mod:`pyrpod.mdao.study_config`). + +Naming +------ +Two vocabularies meet here and are deliberately kept distinct: + +* the *model name* is the Python class name, which is what a study + configuration writes (``plume_model.name: CollisionlessGasKinetics``); +* the *kinetics key* is the short token a case's ``config.ini`` carries in + ``[pm] kinetics`` (``Simplified``, ``Collisionless``, or ``None`` to + disable surface loads entirely). + +:func:`model_name_for_kinetics` and :func:`kinetics_key_for` convert between +them. ``Simplified`` keeps meaning exactly what it always meant, so every +existing case and its results are unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping + +import numpy as np + +from pyrpod.plume.RarefiedPlumeGasKinetics import ( + AVOGADROS_NUMBER, + CollisionlessGasKinetics, + Scalar, + SimplifiedGasKinetics, + get_maxwellian_heat_transfer, + get_maxwellian_pressure, + get_maxwellian_shear_pressure, +) + +__all__ = [ + "DEFAULT_PLUME_MODEL", + "KINETICS_DISABLED", + "KINETICS_KEYS", + "LocalFieldState", + "PLUME_MODELS", + "PlumeModelError", + "create_model", + "kinetics_key_for", + "local_field_state", + "maxwellian_surface_loads", + "model_name_for_kinetics", + "resolve_model_name", +] + + +class PlumeModelError(ValueError): + """Raised when an unknown plume model or kinetics key is requested.""" + + +#: The collisionless plume models the strike pipeline can dispatch to, keyed +#: by class name (what a study configuration names). +PLUME_MODELS: dict[str, type[SimplifiedGasKinetics]] = { + "SimplifiedGasKinetics": SimplifiedGasKinetics, + "CollisionlessGasKinetics": CollisionlessGasKinetics, +} + +#: Model used when nothing selects one -- the historical behavior of every +#: existing case and of the whole strike pipeline before model dispatch. +DEFAULT_PLUME_MODEL = "SimplifiedGasKinetics" + +#: ``[pm] kinetics`` value that disables the gas-kinetics surface loads. +KINETICS_DISABLED = "None" + +#: ``config.ini`` ``[pm] kinetics`` token -> model class name. +KINETICS_KEYS: dict[str, str] = { + "Simplified": "SimplifiedGasKinetics", + "Collisionless": "CollisionlessGasKinetics", +} + + +def resolve_model_name(name: str | None) -> str: + """Validate a plume-model class name, defaulting when none is given. + + Raises + ------ + PlumeModelError + If ``name`` is not one of :data:`PLUME_MODELS`. Unknown models are + never silently replaced by the default. + """ + if name is None: + return DEFAULT_PLUME_MODEL + model_name = str(name) + if model_name not in PLUME_MODELS: + raise PlumeModelError( + f"unknown plume model {model_name!r}; supported models are " + f"{sorted(PLUME_MODELS)}") + return model_name + + +def kinetics_key_for(model_name: str) -> str: + """``[pm] kinetics`` token that selects ``model_name``.""" + model_name = resolve_model_name(model_name) + for key, name in KINETICS_KEYS.items(): + if name == model_name: + return key + raise PlumeModelError( # pragma: no cover - PLUME_MODELS/KINETICS_KEYS agree + f"no [pm] kinetics key is mapped to plume model {model_name!r}") + + +def model_name_for_kinetics(kinetics: str) -> str: + """Plume-model class name selected by a ``[pm] kinetics`` token. + + ``'None'`` has no model (surface loads are disabled) and is rejected + here; callers test for it before asking. + """ + key = str(kinetics) + if key == KINETICS_DISABLED: + raise PlumeModelError( + "[pm] kinetics = None disables the gas-kinetics surface loads; " + "there is no plume model to select") + if key not in KINETICS_KEYS: + raise PlumeModelError( + f"unknown [pm] kinetics value {key!r}; supported values are " + f"{sorted(KINETICS_KEYS)} (or {KINETICS_DISABLED!r} to disable " + "gas-kinetics surface loads)") + return KINETICS_KEYS[key] + + +def create_model(model_name: str, distance: Scalar, theta: Scalar, + thruster_characteristics: Mapping[str, Any], T_w: float, + sigma: float) -> SimplifiedGasKinetics: + """Instantiate the named plume model at one evaluation point. + + Every supported model shares the constructor signature + ``(distance, theta, thruster_characteristics, T_w, sigma)``, so the + factory is a class lookup and a call. + + Parameters + ---------- + model_name : str + A key of :data:`PLUME_MODELS`. + distance : float + Distance from the nozzle exit center to the evaluated point (m). + theta : float + Plume-centerline off-axis angle of the evaluated point (rad). + thruster_characteristics : mapping + The case's thruster-definition entry (``d``, ``ve``, ``R``, + ``gamma``, ``Te``, ``n``). + T_w, sigma : float + Surface temperature (K) and diffuse-reflection fraction. + """ + return PLUME_MODELS[resolve_model_name(model_name)]( + distance, theta, thruster_characteristics, T_w, sigma) + + +@dataclass(frozen=True) +class LocalFieldState: + """Plume flow state at one point, common to every collisionless model. + + This is the interface between a plume model and the gas-surface + interaction: once a model has been reduced to these numbers, the surface + loads no longer depend on which model produced them. + + Attributes + ---------- + number_density : float + Local number density n (particles / m^3). + mass_density : float + Local mass density rho = n * M / N_A (kg / m^3). + axial_velocity : float + Macroscopic velocity component along the plume axis, U (m/s). + radial_velocity : float + Macroscopic velocity component transverse to the plume axis, W + (m/s). Zero on the centerline. + velocity_magnitude : float + |(U, W)| (m/s); the speed the Maxwellian wall formulas use. + temperature : float + Local translational temperature T (K). + speed_ratio : float + Local molecular speed ratio S = |U| * beta(T), beta = 1/sqrt(2 R T). + on_centerline : bool + Whether the point was evaluated with the exact centerline closed + forms (theta == 0) rather than the off-axis field solutions. + """ + + number_density: float + mass_density: float + axial_velocity: float + radial_velocity: float + velocity_magnitude: float + temperature: float + speed_ratio: float + on_centerline: bool = False + + @property + def velocity(self) -> tuple[float, float]: + """(axial, radial) velocity components in the plume frame (m/s).""" + return (self.axial_velocity, self.radial_velocity) + + def to_dict(self) -> dict[str, float | bool]: + """Plain-data form, for recording a sampled field state.""" + return { + "number_density": self.number_density, + "mass_density": self.mass_density, + "axial_velocity": self.axial_velocity, + "radial_velocity": self.radial_velocity, + "velocity_magnitude": self.velocity_magnitude, + "temperature": self.temperature, + "speed_ratio": self.speed_ratio, + "on_centerline": self.on_centerline, + } + + +def local_field_state(model: SimplifiedGasKinetics) -> LocalFieldState: + """Reduce any collisionless plume model to the common local field state. + + Off the centerline the model's own field solutions are used; ON the + centerline (``theta == 0``) the exact closed forms are, exactly as the + models' own ``get_pressure`` / ``get_shear_pressure`` / ``get_heat_flux`` + do. Both branches call through the instance, so an overriding model (the + full :class:`CollisionlessGasKinetics`) supplies its own field values + while the reduction itself stays model-independent. + + Note that the two field getters are normalized differently by the models + (``get_U_normalized`` returns U * sqrt(beta_0), the centerline form + returns U * beta_0); the division by ``beta_0`` reproduces the + established pipeline behavior in both branches unchanged. + """ + if model.theta != 0: # off the plume centerline + number_density = model.n_0 * model.get_num_density_ratio() + temperature = model.T_0 * model.get_temp_ratio() + axial = model.get_U_normalized() / model.beta_0 + radial = model.get_W_normalized() / model.beta_0 + speed = float(np.sqrt(axial ** 2 + radial ** 2)) + on_centerline = False + else: # exact centerline closed forms + number_density = model.n_0 * model.get_num_density_centerline() + temperature = model.T_0 * model.get_temp_centerline() + speed = model.get_velocity_centerline() / model.beta_0 + axial, radial = speed, 0.0 + on_centerline = True + + mass_density = number_density * model.molar_mass / AVOGADROS_NUMBER + return LocalFieldState( + number_density=float(number_density), + mass_density=float(mass_density), + axial_velocity=float(axial), + radial_velocity=float(radial), + velocity_magnitude=float(speed), + temperature=float(temperature), + speed_ratio=float(speed * model.get_beta(temperature)), + on_centerline=on_centerline, + ) + + +def maxwellian_surface_loads(state: LocalFieldState, *, sigma: float, + T_w: float, R: float, gamma: float, + incidence: Scalar) -> tuple[float, float, float]: + """Maxwellian (Shen) surface loads for one local field state. + + The single place pressure, shear and heat transfer are computed from a + plume field, so no model adapter reimplements them. + + Parameters + ---------- + state : LocalFieldState + Plume flow state at the face, from :func:`local_field_state`. + sigma : float + Fraction of diffuse molecular reflections, in [0, 1]. + T_w : float + Surface (wall) temperature (K). + R : float + Specific gas constant (J / kg / K). + gamma : float + Ratio of specific heats. + incidence : float + Angle between the LOCAL FLOW DIRECTION (radial from the nozzle exit, + because the flow is collisionless) and the face unit normal (rad). + This is the true incidence angle, not the positional off-axis angle + that locates the face in the plume field. + + Returns + ------- + (float, float, float) + Pressure (Pa), shear stress (Pa, signed as the Shen formula returns + it -- callers take the magnitude) and heat flux (W/m^2). + """ + pressure = get_maxwellian_pressure(state.mass_density, + state.velocity_magnitude, + state.speed_ratio, sigma, incidence, + state.temperature, T_w) + shear = get_maxwellian_shear_pressure(state.mass_density, + state.velocity_magnitude, + state.speed_ratio, sigma, incidence) + heat_flux = get_maxwellian_heat_transfer(state.mass_density, + state.speed_ratio, sigma, + incidence, state.temperature, + T_w, R, gamma) + return pressure, shear, heat_flux From ba073beffdf18c6a5507e15c24efb0ca85a4a9be Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:21:47 -0500 Subject: [PATCH 02/10] mdao: select the plume model, sweep panel-local offsets, derive Kn study_config no longer hard-rejects every model but SimplifiedGasKinetics; plume_model.name is validated against the registry and now SELECTS the model that computes the plume field. An omitted block keeps the historical default, and an unknown name is still a clear StudyConfigError. Three schema additions, all optional and all defaulted so existing YAML parses unchanged: * sweep.source_offsets_u / source_offsets_v translate the plume source parallel to the target surface, on the surface-local basis TargetSpec.local_basis() derives from the existing normal/tangent keys (u = tangent, v = n x u, u x v = n). Both default to [0.0]. * sweep.source_axis_mode picks the pose convention: aim_at_reference (existing arc, aimed at the reference point) or parallel_to_normal (translated source, axis fixed anti-parallel to the normal). The two agree exactly at zero offset, so the new mode extends rather than redefines the old one. Incompatible combinations -- offsets while aiming, an approach angle with a fixed axis -- are rejected outright rather than silently combined. * a knudsen block records DERIVED metadata only: the mean free path must be given (never inferred from gas properties), exactly one of the two reference-length modes must be chosen, and nothing in the pipeline reads Kn back. The models stay collisionless. SweepSpec.sweep_poses enumerates the full parameterization distance-major, then u, then v, then angle, which collapses to the historical order at the default offsets; SweepSpec.poses keeps its (angle, distance) projection for existing callers. firing_plan gains translated_pose_for() and dispatches on the axis mode in one place, so the per-case and single-history engines cannot drift apart. The obsolete assertion that CollisionlessGasKinetics must be REJECTED is replaced by tests that both supported models are accepted, that an unknown name is refused, and that omitting the block keeps the default. Co-Authored-By: Claude Opus 5 --- pyrpod/mdao/firing_plan.py | 218 ++++++++++++++---- pyrpod/mdao/study_config.py | 394 +++++++++++++++++++++++++++++--- tests/mdao/mdao_unit_test_05.py | 19 +- 3 files changed, 559 insertions(+), 72 deletions(-) diff --git a/pyrpod/mdao/firing_plan.py b/pyrpod/mdao/firing_plan.py index 28c91ab..38d8b59 100644 --- a/pyrpod/mdao/firing_plan.py +++ b/pyrpod/mdao/firing_plan.py @@ -16,22 +16,44 @@ an explicitly prescribed firing list whose length disagrees is an error -- never a silent truncation or extension. -Pose convention ---------------- -For a target reference point ``C`` with outward normal ``n_hat`` (pointing -toward the plume source side) and in-plane tangent ``t_hat``: - - d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * t_hat - source position = C + L * d_hat(alpha) - thruster axis = -d_hat(alpha) (aimed at C) - -``alpha = 0`` is head-on. The JFH DCM is built with the thruster axis as its -first COLUMN, which is what the strike pipeline reads as the plume normal -(``dcm.T`` rows, with an identity thruster DCM). This reproduces the pose -convention of the committed sweep-JFH generators -(``case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py``) exactly, -including the binormal choice ``cross(n_hat, t_hat)`` for the DCM's second -column. +Pose conventions +---------------- +Two axis modes are available; a study picks one with +``sweep.source_axis_mode`` (see +:data:`pyrpod.mdao.study_config.SOURCE_AXIS_MODES`). Both use the target +reference point ``C``, its outward normal ``n_hat`` (pointing toward the +plume-source side) and its in-plane tangent ``u_hat``, together with the +transverse axis ``v_hat = n_hat x u_hat``. + +``aim_at_reference`` (default, unchanged) + The source sits on an arc about ``C`` and always aims back at it: + + d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * u_hat + source position = C + L * d_hat(alpha) + thruster axis = -d_hat(alpha) (aimed at C) + + ``alpha = 0`` is head-on. This reproduces the pose convention of the + committed sweep-JFH generators + (``case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py``) + exactly, including the binormal choice ``cross(n_hat, u_hat)`` for the + DCM's second column. + +``parallel_to_normal`` (ISS-panel studies) + The source is TRANSLATED parallel to the surface and its axis stays + fixed, so the plume centerline strikes the panel at the requested + panel-local offset instead of always at ``C``: + + source position = C + L * n_hat + u_off * u_hat + v_off * v_hat + thruster axis = -n_hat + + This is deliberately NOT the same experiment as moving the source while + continuously re-aiming it at the panel center. At zero offsets the two + modes coincide (``aim_at_reference`` at ``alpha = 0``), so the new mode + is a strict extension of the old convention rather than a redefinition. + +In both modes the JFH DCM is built with the thruster axis as its first +COLUMN, which is what the strike pipeline reads as the plume normal +(``dcm.T`` rows, with an identity thruster DCM). """ from __future__ import annotations @@ -46,6 +68,7 @@ from pyrpod.mdao.study_config import ( PrescribedFiringSpec, StudyConfigError, + SweepPose, SweepSpec, TargetSpec, validate_n_firings, @@ -56,6 +79,8 @@ "build_case_firings", "build_sweep_firings", "pose_for", + "pose_for_sweep_pose", + "translated_pose_for", "validate_n_firings", "write_jfh_file", ] @@ -86,6 +111,12 @@ class Firing: without a sweep parameterization. pose_index : int or None Index of the firing's pose in the sweep's execution order. + source_offset_u, source_offset_v : float + Panel-local translation of the plume source along the target's + longitudinal and transverse axes. Zero unless the pose came from an + offset sweep. + source_axis_mode : str + Which pose convention built this firing (see the module docstring). """ position: NDArray[np.float64] @@ -96,6 +127,9 @@ class Firing: plate_angle_deg: float | None = None source_distance: float | None = None pose_index: int | None = None + source_offset_u: float = 0.0 + source_offset_v: float = 0.0 + source_axis_mode: str = "aim_at_reference" def pose_for(alpha_deg: float, distance: float, @@ -141,28 +175,119 @@ def pose_for(alpha_deg: float, distance: float, return position, dcm +def translated_pose_for(distance: float, offset_u: float, offset_v: float, + reference_point: Sequence[float] | NDArray[np.float64], + normal: Sequence[float] | NDArray[np.float64], + tangent: Sequence[float] | NDArray[np.float64], + ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Plume-source pose TRANSLATED parallel to the target surface. + + The source stands off by ``distance`` along the target normal and is + then slid along the surface-local axes; its axis stays anti-parallel to + the normal, so the plume centerline intersects the surface at the + panel-local point ``(offset_u, offset_v)`` rather than at the reference + point: + + position = C + L * n_hat + offset_u * u_hat + offset_v * v_hat + axis = -n_hat + + Parameters + ---------- + distance : float + Stand-off distance L from the surface along ``normal``. + offset_u, offset_v : float + Panel-local translations along the longitudinal (``tangent``) and + transverse (``normal x tangent``) axes. + reference_point, normal, tangent : array-like + Target geometry axes (see + :meth:`pyrpod.mdao.study_config.TargetSpec.local_basis`). + + Returns + ------- + (np.ndarray, np.ndarray) + The source position and the 3x3 DCM whose FIRST COLUMN is the + thruster axis. The triad is the same one + :func:`pose_for` builds at ``alpha = 0``, so the two modes agree + exactly when both offsets are zero. + """ + center = np.asarray(reference_point, dtype=float) + n_hat = np.asarray(normal, dtype=float) + u_hat = np.asarray(tangent, dtype=float) + n_hat = n_hat / np.linalg.norm(n_hat) + u_hat = u_hat / np.linalg.norm(u_hat) + v_hat = np.cross(n_hat, u_hat) + v_hat = v_hat / np.linalg.norm(v_hat) + + position = (center + float(distance) * n_hat + + float(offset_u) * u_hat + float(offset_v) * v_hat) + axis = -n_hat # fixed: parallel to -n at every offset + + # Same column convention as pose_for: [axis, binormal, axis x binormal], + # with the binormal cross(n_hat, u_hat) = v_hat. + dcm = np.column_stack([axis, v_hat, np.cross(axis, v_hat)]) + return position, dcm + + +def pose_for_sweep_pose(pose: SweepPose, target: TargetSpec, + ) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Plume-source pose for one :class:`SweepPose`, in its own axis mode. + + The single place the axis mode is turned into geometry, so the per-case + and single-history engines cannot drift apart. + + Raises + ------ + StudyConfigError + If the pose carries an axis mode this module does not implement. + """ + if pose.axis_mode == "parallel_to_normal": + return translated_pose_for(pose.source_distance, pose.source_offset_u, + pose.source_offset_v, + target.reference_point, target.normal, + target.tangent) + if pose.axis_mode == "aim_at_reference": + return pose_for(pose.plate_angle_deg, pose.source_distance, + target.reference_point, target.normal, target.tangent) + raise StudyConfigError( + f"unknown sweep.source_axis_mode {pose.axis_mode!r}") + + def build_case_firings(sweep: SweepSpec, target: TargetSpec, alpha_deg: float, distance: float, pose_index: int = 0, - start_time_s: float = 0.0) -> list[Firing]: + start_time_s: float = 0.0, + pose: SweepPose | None = None) -> list[Firing]: """Build EXACTLY ``sweep.n_firings`` firings for one sweep pose. When the configuration prescribes firings explicitly they are used verbatim (their count is validated against ``n_firings`` when the - configuration is parsed). Otherwise the swept pose is generated from - ``alpha_deg`` / ``distance`` and repeated for ``n_firings`` successive - firing intervals -- one JFH entry per requested firing. + configuration is parsed). Otherwise the swept pose is generated in the + sweep's own axis mode and repeated for ``n_firings`` successive firing + intervals -- one JFH entry per requested firing. ``pose_index`` and ``start_time_s`` place this pose inside a longer sequence; they matter only when many poses share one history (see :func:`build_sweep_firings`). + Parameters + ---------- + pose : SweepPose, optional + The fully parameterized pose to realize, including its panel-local + offsets. When omitted, one is built from ``alpha_deg`` / ``distance`` + with zero offsets in the sweep's axis mode, which is exactly the + historical behavior. When supplied it is authoritative and + ``alpha_deg`` / ``distance`` are ignored. + Raises ------ StudyConfigError If the generated sequence length would differ from ``n_firings``. """ n_firings = validate_n_firings(sweep.n_firings) + if pose is None: + pose = SweepPose(plate_angle_deg=float(alpha_deg), + source_distance=float(distance), + axis_mode=sweep.source_axis_mode) if sweep.firings: specs = sweep.firings @@ -172,22 +297,22 @@ def build_case_firings(sweep: SweepSpec, target: TargetSpec, offset = pose_index * n_firings specs = sweep.firings[offset:offset + n_firings] firings = [ - _from_spec(spec, index, alpha_deg, distance, pose_index, - start_time_s) + _from_spec(spec, index, pose, pose_index, start_time_s) for index, spec in enumerate(specs) ] else: - position, dcm = pose_for(alpha_deg, distance, - target.reference_point, target.normal, - target.tangent) + position, dcm = pose_for_sweep_pose(pose, target) firings = [ Firing(position=position, dcm=dcm, thrusters=sweep.thrusters, duration_s=sweep.firing_duration_s, start_time_s=start_time_s + index * sweep.firing_duration_s, - plate_angle_deg=float(alpha_deg), - source_distance=float(distance), - pose_index=pose_index) + plate_angle_deg=pose.plate_angle_deg, + source_distance=pose.source_distance, + pose_index=pose_index, + source_offset_u=pose.source_offset_u, + source_offset_v=pose.source_offset_v, + source_axis_mode=pose.axis_mode) for index in range(n_firings) ] @@ -202,10 +327,10 @@ def build_sweep_firings(sweep: SweepSpec, target: TargetSpec) -> list[Firing]: """Build the WHOLE sweep as one firing sequence. - Every pose of ``sweep.poses`` contributes ``sweep.n_firings`` entries, in - execution order, with firing times running continuously across the - sequence. The result is the single Jet Firing History of a ``single_jfh`` - study, and its length is exactly ``sweep.total_firings``. + Every pose of ``sweep.sweep_poses`` contributes ``sweep.n_firings`` + entries, in execution order, with firing times running continuously + across the sequence. The result is the single Jet Firing History of a + ``single_jfh`` study, and its length is exactly ``sweep.total_firings``. Raises ------ @@ -214,24 +339,23 @@ def build_sweep_firings(sweep: SweepSpec, """ firings: list[Firing] = [] elapsed = 0.0 - for pose_index, (angle, distance) in enumerate(sweep.poses): - pose_firings = build_case_firings(sweep, target, angle, distance, - pose_index=pose_index, - start_time_s=elapsed) + for pose_index, pose in enumerate(sweep.sweep_poses): + pose_firings = build_case_firings( + sweep, target, pose.plate_angle_deg, pose.source_distance, + pose_index=pose_index, start_time_s=elapsed, pose=pose) firings.extend(pose_firings) elapsed += sum(firing.duration_s for firing in pose_firings) if len(firings) != sweep.total_firings: raise StudyConfigError( - f"sweep of {len(sweep.poses)} poses x n_firings=" + f"sweep of {len(sweep.sweep_poses)} poses x n_firings=" f"{sweep.n_firings} must produce {sweep.total_firings} JFH " f"entries, built {len(firings)}") return firings def _from_spec(spec: PrescribedFiringSpec, index: int, - alpha_deg: float | None = None, - distance: float | None = None, + pose: SweepPose | None = None, pose_index: int | None = None, start_time_s: float = 0.0) -> Firing: return Firing(position=np.asarray(spec.position, dtype=float), @@ -239,11 +363,17 @@ def _from_spec(spec: PrescribedFiringSpec, index: int, thrusters=spec.thrusters, duration_s=spec.duration_s, start_time_s=start_time_s + index * spec.duration_s, - plate_angle_deg=(None if alpha_deg is None - else float(alpha_deg)), - source_distance=(None if distance is None - else float(distance)), - pose_index=pose_index) + plate_angle_deg=(None if pose is None + else pose.plate_angle_deg), + source_distance=(None if pose is None + else pose.source_distance), + pose_index=pose_index, + source_offset_u=(0.0 if pose is None + else pose.source_offset_u), + source_offset_v=(0.0 if pose is None + else pose.source_offset_v), + source_axis_mode=("aim_at_reference" if pose is None + else pose.axis_mode)) def write_jfh_file(path: str | os.PathLike[str], diff --git a/pyrpod/mdao/study_config.py b/pyrpod/mdao/study_config.py index 465dda1..9f41a9a 100644 --- a/pyrpod/mdao/study_config.py +++ b/pyrpod/mdao/study_config.py @@ -12,13 +12,17 @@ The schema is deliberately explicit. Nothing is inferred that the caller did not write down: -* the plume model is recorded by name and must be ``SimplifiedGasKinetics`` - (this branch adds no plume-model registry); +* the plume model is named explicitly and must be one of the collisionless + models in :data:`pyrpod.plume.gas_kinetics_models.PLUME_MODELS`; the name + SELECTS the model that computes the plume field, it is not just metadata; * coefficients are computed only when every normalization input is present (see :class:`Normalization`); otherwise they are reported as unavailable; * ``n_firings`` means the exact number of entries written to the Jet Firing History -- a mismatch against an explicitly prescribed firing list is an - error, never a silent truncation. + error, never a silent truncation; +* the Knudsen number is DERIVED METADATA only (see :class:`KnudsenSpec`): + the mean free path must be supplied, is never inferred from gas + properties, and never changes the analytical plume solution. Example ------- @@ -37,12 +41,36 @@ import yaml from numpy.typing import NDArray +from pyrpod.plume.gas_kinetics_models import ( + DEFAULT_PLUME_MODEL, + PLUME_MODELS, + PlumeModelError, + resolve_model_name, +) from pyrpod.rpod.approach_maneuvers import ( validate_n_firings as _validate_n_firings, ) -#: The single plume model this study workflow supports (hard scope constraint). -SUPPORTED_PLUME_MODEL = "SimplifiedGasKinetics" +#: Plume model assumed when a configuration names none. Every configuration +#: written before model dispatch existed therefore keeps its exact behavior. +SUPPORTED_PLUME_MODEL = DEFAULT_PLUME_MODEL + +#: Collisionless plume models a study may select, by class name. +SUPPORTED_PLUME_MODELS: tuple[str, ...] = tuple(sorted(PLUME_MODELS)) + +#: How the plume axis is oriented at each generated pose. +#: +#: ``aim_at_reference`` +#: The historical (and default) behavior: the source sits on an arc of +#: radius ``source_distance`` about the target reference point and its +#: axis points back at that point, so a swept ``plate_angles_deg`` +#: changes the approach angle. +#: ``parallel_to_normal`` +#: The source is TRANSLATED parallel to the target surface and its axis +#: stays anti-parallel to the target normal, so the plume centerline +#: strikes the surface at the requested panel-local offset. This is the +#: ISS-panel convention; see :class:`SweepSpec`. +SOURCE_AXIS_MODES = ("aim_at_reference", "parallel_to_normal") #: Default unit labels carried into the result metadata. PyRPOD works in SI #: throughout; recording them makes an exported result self-describing. @@ -95,6 +123,26 @@ def _as_float_list(value: Any, context: str) -> list[float]: f"got {value!r}") from exc +def _as_offsets(value: Any, context: str) -> list[float]: + """Parse an optional panel-local offset list, defaulting to ``[0.0]``. + + An omitted (or explicitly null) list means "no offset", which is the + behavior of every configuration written before offsets existed. Non-finite + values are rejected rather than propagated into a pose. + """ + if value is None: + return [0.0] + offsets = _as_float_list(value, context) + if not offsets: + raise StudyConfigError( + f"{context} must not be empty; omit it for the default [0.0]") + for offset in offsets: + if not np.isfinite(offset): + raise StudyConfigError( + f"{context}: offsets must be finite, got {offset!r}") + return offsets + + @dataclass(frozen=True) class ComponentSpec: """One target component: a named subset of the target mesh's faces. @@ -162,6 +210,10 @@ class TargetSpec: places the plume source on ``normal``, positive angles rotate it toward ``tangent``. They are geometry properties, not physics, so a curved target simply supplies the axes its sweep should use. + + The same two vectors also define the SURFACE-LOCAL basis used by the + panel-local offsets, moments, center of pressure and distribution + exports (see :meth:`local_basis`); no separate axis keys are needed. """ geometry_id: str @@ -213,6 +265,26 @@ def from_mapping(cls, data: Mapping[str, Any], components=components, ) + def local_basis(self) -> tuple[NDArray[np.float64], NDArray[np.float64], + NDArray[np.float64]]: + """Right-handed surface-local basis ``(u_hat, v_hat, n_hat)``. + + * ``u_hat`` is the target tangent -- the LONGITUDINAL in-surface + axis, the 22 m dimension of the ISS-representative panel; + * ``v_hat = n_hat x u_hat`` is the TRANSVERSE in-surface axis, the + 12 m dimension; + * ``n_hat`` is the target normal, pointing toward the plume source. + + The triad satisfies ``u x v = n``, and ``v_hat`` is exactly the + binormal :func:`pyrpod.mdao.firing_plan.pose_for` already uses for + the second column of its DCM, so the pose convention and the + panel-local reporting convention are the same basis. + """ + n_hat = self.normal / np.linalg.norm(self.normal) + u_hat = self.tangent / np.linalg.norm(self.tangent) + v_hat = np.cross(n_hat, u_hat) + return u_hat, v_hat / np.linalg.norm(v_hat), n_hat + @dataclass(frozen=True) class PrescribedFiringSpec: @@ -253,6 +325,29 @@ def from_mapping(cls, data: Mapping[str, Any], index: int, SWEEP_MODES = ("per_case", "single_jfh") +@dataclass(frozen=True) +class SweepPose: + """One swept plume-source pose: angle, distance and panel-local offsets. + + The full parameterization of a generated pose, in the order + :meth:`SweepSpec.sweep_poses` enumerates them. ``source_offset_u`` and + ``source_offset_v`` are zero for every configuration written before the + offset sweep existed, so such a study's poses are exactly what they + always were. + """ + + plate_angle_deg: float + source_distance: float + source_offset_u: float = 0.0 + source_offset_v: float = 0.0 + axis_mode: str = "aim_at_reference" + + @property + def key(self) -> tuple[float, float]: + """The legacy ``(angle, distance)`` pose key.""" + return (self.plate_angle_deg, self.source_distance) + + @dataclass(frozen=True) class SweepSpec: """Parameter sweep and firing-count definition. @@ -261,10 +356,20 @@ class SweepSpec: ---------- plate_angles_deg : tuple of float Approach angles swept in the target's (normal, tangent) plane; - 0 deg is head-on along the target normal. + 0 deg is head-on along the target normal. Meaningful only in + ``aim_at_reference`` axis mode. source_distances : tuple of float Plume-source distances from ``TargetSpec.reference_point``, in the case's length units. + source_offsets_u, source_offsets_v : tuple of float + Panel-local translations of the plume source along the target's + longitudinal (``u``) and transverse (``v``) axes (see + :meth:`TargetSpec.local_basis`). Both default to ``(0.0,)``, which + reproduces the on-axis poses exactly. Requires + ``source_axis_mode: parallel_to_normal``. + source_axis_mode : str + ``'aim_at_reference'`` (default) or ``'parallel_to_normal'``; see + :data:`SOURCE_AXIS_MODES`. n_firings : int Number of Jet Firing History entries contributed by EACH pose. In ``per_case`` mode that is the exact length of every case's history; @@ -290,22 +395,50 @@ class SweepSpec: thrusters: tuple[int, ...] firings: tuple[PrescribedFiringSpec, ...] = () mode: str = "per_case" + source_offsets_u: tuple[float, ...] = (0.0,) + source_offsets_v: tuple[float, ...] = (0.0,) + source_axis_mode: str = "aim_at_reference" + + @property + def sweep_poses(self) -> tuple[SweepPose, ...]: + """Fully parameterized poses in execution order. + + Distance-major, matching the committed sweep-JFH generators, then + u offset, then v offset, then approach angle: + + for distance: for u_offset: for v_offset: for angle + + With the default single-element offset lists this collapses to the + historical "all angles at the first distance, then all angles at the + next", so an existing configuration's pose order is unchanged. For + an offset sweep (a single angle, as ``parallel_to_normal`` requires) + the length is exactly + ``n_distances * n_u_offsets * n_v_offsets``. + """ + return tuple( + SweepPose(plate_angle_deg=angle, source_distance=distance, + source_offset_u=u_offset, source_offset_v=v_offset, + axis_mode=self.source_axis_mode) + for distance in self.source_distances + for u_offset in self.source_offsets_u + for v_offset in self.source_offsets_v + for angle in self.plate_angles_deg) @property def poses(self) -> tuple[tuple[float, float], ...]: """(plate angle, source distance) pairs in execution order. - Distance-major, matching the committed sweep-JFH generators: all - angles at the first distance, then all angles at the next. + The legacy projection of :attr:`sweep_poses`, kept unchanged for + callers that only key on the swept angle and distance. It has the + same length and order as ``sweep_poses``; when offsets are swept, + several entries share an ``(angle, distance)`` pair. """ - return tuple((angle, distance) - for distance in self.source_distances - for angle in self.plate_angles_deg) + return tuple(pose.key for pose in self.sweep_poses) @property def total_firings(self) -> int: """Total JFH entries across the whole sweep.""" - return len(self.poses) * self.n_firings + return len(self.sweep_poses) * self.n_firings @classmethod def from_mapping(cls, data: Mapping[str, Any]) -> "SweepSpec": @@ -321,6 +454,35 @@ def from_mapping(cls, data: Mapping[str, Any]) -> "SweepSpec": raise StudyConfigError( "sweep.source_distances must all be positive") + offsets_u = tuple(_as_offsets(data.get("source_offsets_u"), + "sweep.source_offsets_u")) + offsets_v = tuple(_as_offsets(data.get("source_offsets_v"), + "sweep.source_offsets_v")) + + axis_mode = str(data.get("source_axis_mode", "aim_at_reference")) + if axis_mode not in SOURCE_AXIS_MODES: + raise StudyConfigError( + f"sweep.source_axis_mode must be one of " + f"{list(SOURCE_AXIS_MODES)}, got {axis_mode!r}") + + # Pose definitions that mean different things are never silently + # combined: an aimed arc has no panel-local offset, and a fixed + # axis parallel to -n has no approach angle. + offsets_swept = (offsets_u != (0.0,) or offsets_v != (0.0,)) + if axis_mode == "aim_at_reference" and offsets_swept: + raise StudyConfigError( + "sweep.source_offsets_u / source_offsets_v translate the " + "plume source parallel to the target surface, which is only " + "defined for sweep.source_axis_mode: 'parallel_to_normal'; " + f"got {axis_mode!r}") + if axis_mode == "parallel_to_normal" and angles != (0.0,): + raise StudyConfigError( + "sweep.source_axis_mode: 'parallel_to_normal' fixes the " + "plume axis anti-parallel to the target normal, so an " + "approach angle has no meaning; remove " + f"sweep.plate_angles_deg (got {list(angles)}) and sweep " + "source_offsets_u / source_offsets_v instead") + mode = str(data.get("mode", "per_case")) if mode not in SWEEP_MODES: raise StudyConfigError( @@ -341,7 +503,8 @@ def from_mapping(cls, data: Mapping[str, Any]) -> "SweepSpec": firings = tuple( PrescribedFiringSpec.from_mapping(entry, i, thrusters, duration) for i, entry in enumerate(raw_firings)) - n_poses = len(angles) * len(distances) + n_poses = (len(angles) * len(distances) + * len(offsets_u) * len(offsets_v)) expected = n_firings if mode == "per_case" else n_poses * n_firings if len(firings) != expected: where = ("per case" if mode == "per_case" @@ -353,7 +516,9 @@ def from_mapping(cls, data: Mapping[str, Any]) -> "SweepSpec": return cls(plate_angles_deg=angles, source_distances=distances, n_firings=n_firings, firing_duration_s=duration, - thrusters=thrusters, firings=firings, mode=mode) + thrusters=thrusters, firings=firings, mode=mode, + source_offsets_u=offsets_u, source_offsets_v=offsets_v, + source_axis_mode=axis_mode) def validate_n_firings(value: Any) -> int: @@ -427,6 +592,149 @@ def to_dict(self) -> dict[str, float | None]: "reference_heat_flux": self.reference_heat_flux} +#: Reference-length mode names for :class:`KnudsenSpec`. +KNUDSEN_REFERENCE_MODES = ("source_distance", "explicit") + +#: Definition label implied by each reference-length mode when the +#: configuration does not supply one of its own. +_KNUDSEN_DEFAULT_DEFINITIONS = { + "source_distance": "lambda_over_source_distance", + "explicit": "lambda_over_reference_length", +} + + +@dataclass(frozen=True) +class KnudsenSpec: + """Derived Knudsen-number METADATA. Never an input to the physics. + + PyRPOD's plume models are collisionless, and this block does not change + that: no solution, field value or surface load anywhere in the pipeline + depends on Kn. It exists so an analytical case can be LABELLED with the + rarefaction regime it is meant to represent, which is what a later, + entirely separate workflow needs in order to line PyRPOD cases up with + externally generated DSMC runs. + + ``Kn = mean_free_path_m / reference_length``, with the reference length + chosen by exactly one of two mutually exclusive modes: + + * ``reference_length: source_distance`` -- the case's own swept source + distance, so Kn varies across a distance sweep; + * ``reference_length_m: `` -- a fixed length (a nozzle diameter, + a panel chord, ...), so Kn is the same for every case. + + The mean free path is always supplied by the configuration. It is never + inferred from the gas properties in the thruster definition file, because + a free-molecular model carries no collision rate to infer it from. + + Attributes + ---------- + mean_free_path_m : float + Ambient/reference molecular mean free path (m); positive and finite. + reference_mode : str + ``'source_distance'`` or ``'explicit'``. + reference_length_m : float or None + The fixed reference length, in ``'explicit'`` mode only. + definition : str + Free-text label recorded with every result, e.g. + ``lambda_over_nozzle_diameter``. + """ + + mean_free_path_m: float + reference_mode: str + reference_length_m: float | None = None + definition: str = "lambda_over_source_distance" + + @classmethod + def from_mapping(cls, data: Mapping[str, Any] | None + ) -> "KnudsenSpec | None": + """Parse the optional ``knudsen`` block; None when it is absent.""" + if not data: + return None + if not isinstance(data, Mapping): + raise StudyConfigError("'knudsen' section must be a mapping") + + if "mean_free_path_m" not in data or data["mean_free_path_m"] is None: + raise StudyConfigError( + "knudsen.mean_free_path_m is required; PyRPOD never infers a " + "mean free path from gas properties (the plume models are " + "collisionless)") + try: + mean_free_path = float(data["mean_free_path_m"]) + except (TypeError, ValueError) as exc: + raise StudyConfigError( + "knudsen.mean_free_path_m must be a positive finite number, " + f"got {data['mean_free_path_m']!r}") from exc + if not np.isfinite(mean_free_path) or mean_free_path <= 0.0: + raise StudyConfigError( + "knudsen.mean_free_path_m must be a positive finite number, " + f"got {data['mean_free_path_m']!r}") + + symbolic = data.get("reference_length") + explicit = data.get("reference_length_m") + if (symbolic is None) == (explicit is None): + raise StudyConfigError( + "knudsen requires EXACTLY ONE reference-length mode: either " + "reference_length: source_distance (the swept distance) or " + "reference_length_m: (a fixed length); got " + f"reference_length={symbolic!r}, " + f"reference_length_m={explicit!r}") + + if symbolic is not None: + if str(symbolic) != "source_distance": + raise StudyConfigError( + "knudsen.reference_length must be 'source_distance'; for " + "any other reference length use reference_length_m: " + f", got {symbolic!r}") + mode, reference_length = "source_distance", None + else: + try: + reference_length = float(explicit) + except (TypeError, ValueError) as exc: + raise StudyConfigError( + "knudsen.reference_length_m must be a positive finite " + f"number, got {explicit!r}") from exc + if not np.isfinite(reference_length) or reference_length <= 0.0: + raise StudyConfigError( + "knudsen.reference_length_m must be a positive finite " + f"number, got {explicit!r}") + mode = "explicit" + + definition = data.get("definition") + return cls(mean_free_path_m=mean_free_path, reference_mode=mode, + reference_length_m=reference_length, + definition=(str(definition) if definition + else _KNUDSEN_DEFAULT_DEFINITIONS[mode])) + + # ------------------------------------------------------------ evaluation + def reference_length_for(self, source_distance: float) -> float: + """Reference length used for one case, in this spec's mode.""" + if self.reference_mode == "source_distance": + distance = float(source_distance) + if not np.isfinite(distance) or distance <= 0.0: + raise StudyConfigError( + "knudsen.reference_length: source_distance needs a " + f"positive finite source distance, got {source_distance!r}") + return distance + # 'explicit' mode validates reference_length_m at parse time. + return float(self.reference_length_m) # type: ignore[arg-type] + + def knudsen_number(self, source_distance: float) -> float: + """Derived ``Kn = lambda / L_ref`` for one case. Metadata only.""" + return self.mean_free_path_m / self.reference_length_for( + source_distance) + + def to_dict(self) -> dict[str, Any]: + """Plain-data form recorded in the study metadata.""" + return { + "mean_free_path_m": self.mean_free_path_m, + "reference_mode": self.reference_mode, + "reference_length_m": self.reference_length_m, + "definition": self.definition, + "role": "derived metadata only; the plume models are " + "collisionless and no solution depends on Kn", + } + + @dataclass(frozen=True) class LoadsSpec: """Surface-load integration settings.""" @@ -448,7 +756,14 @@ def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoadsSpec": @dataclass(frozen=True) class OutputSpec: - """Output artifact settings (VTK, machine-readable summary, plots).""" + """Output artifact settings (VTK, summary, panel distributions, plots). + + The VTK export stays the PRIMARY full-resolution visualization output and + is enabled by default. The panel-local surface-distribution CSVs are an + ADDITIONAL, opt-in export of the same native per-face values in the + target's own (u, v) coordinates -- convenient for plotting and for a + later comparison workflow, never a replacement for the VTK files. + """ write_vtk: bool = True vtk_subdir: str = "vtk" @@ -456,6 +771,9 @@ class OutputSpec: summary_metadata: str = "study_metadata.json" write_plots: bool = False plots_subdir: str = "plots" + write_surface_distribution: bool = False + surface_distribution_subdir: str = "distributions" + write_distribution_plots: bool = False @classmethod def from_mapping(cls, data: Mapping[str, Any] | None) -> "OutputSpec": @@ -463,6 +781,7 @@ def from_mapping(cls, data: Mapping[str, Any] | None) -> "OutputSpec": vtk = data.get("vtk") or {} summary = data.get("summary") or {} plots = data.get("plots") or {} + distribution = data.get("surface_distribution") or {} return cls( write_vtk=bool(vtk.get("enabled", True)), vtk_subdir=str(vtk.get("subdir", "vtk")), @@ -471,6 +790,14 @@ def from_mapping(cls, data: Mapping[str, Any] | None) -> "OutputSpec": "study_metadata.json")), write_plots=bool(plots.get("enabled", False)), plots_subdir=str(plots.get("subdir", "plots")), + write_surface_distribution=bool(distribution.get("enabled", + False)), + surface_distribution_subdir=str(distribution.get( + "subdir", "distributions")), + # Per-case panel-local pressure maps: only meaningful when the + # distributions they are drawn from are exported. + write_distribution_plots=bool( + plots.get("per_case_distribution", False)), ) @@ -504,7 +831,13 @@ class StudyConfig: output_dir : str Directory the study writes its artifacts to. plume_model : str - Always ``'SimplifiedGasKinetics'`` in this branch. + The collisionless model that COMPUTES the plume field, one of + :data:`SUPPORTED_PLUME_MODELS`. Defaults to + ``'SimplifiedGasKinetics'``. + knudsen : KnudsenSpec or None + Optional derived-Knudsen metadata (see :class:`KnudsenSpec`). None + when the configuration has no ``knudsen`` block, in which case every + Knudsen field is simply omitted from the results. source_path : str Path the configuration was read from (configuration provenance). """ @@ -521,6 +854,7 @@ class StudyConfig: thruster_id: str | None = None plume_model: str = SUPPORTED_PLUME_MODEL plume_model_parameters: dict[str, Any] = field(default_factory=dict) + knudsen: KnudsenSpec | None = None coordinate_system: str = "case global frame" units: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_UNITS)) source_path: str = "" @@ -566,12 +900,11 @@ def from_mapping(cls, data: Mapping[str, Any], output_dir = _resolve_dir(raw_output_dir, base_dir) plume = data.get("plume_model") or {} - model_name = str(plume.get("name", SUPPORTED_PLUME_MODEL)) - if model_name != SUPPORTED_PLUME_MODEL: - raise StudyConfigError( - f"plume_model.name must be {SUPPORTED_PLUME_MODEL!r} " - f"(this study workflow supports exactly one model), got " - f"{model_name!r}") + try: + model_name = resolve_model_name( + plume.get("name", SUPPORTED_PLUME_MODEL)) + except PlumeModelError as exc: + raise StudyConfigError(f"plume_model.name: {exc}") from exc model_parameters = dict(plume.get("parameters") or {}) thruster = data.get("thruster") or {} @@ -598,6 +931,7 @@ def from_mapping(cls, data: Mapping[str, Any], thruster_id=str(thruster_id) if thruster_id else None, plume_model=model_name, plume_model_parameters=model_parameters, + knudsen=KnudsenSpec.from_mapping(data.get("knudsen")), coordinate_system=str(metadata.get("coordinate_system", "case global frame")), units=units, @@ -607,9 +941,13 @@ def from_mapping(cls, data: Mapping[str, Any], # ------------------------------------------------------------- accessors @property def n_cases(self) -> int: - """Number of angle x distance cases in this study.""" - return (len(self.sweep.plate_angles_deg) - * len(self.sweep.source_distances)) + """Number of swept cases: angles x distances x u offsets x v offsets. + + With the default single-element offset lists this is the historical + angle x distance count; for an offset sweep (which fixes the angle) + it is ``n_distances * n_u_offsets * n_v_offsets``. + """ + return len(self.sweep.sweep_poses) def with_output_dir(self, output_dir: str) -> "StudyConfig": """Copy of this configuration writing to a different directory.""" @@ -617,16 +955,20 @@ def with_output_dir(self, output_dir: str) -> "StudyConfig": def provenance(self) -> dict[str, Any]: """Configuration provenance recorded in the study metadata.""" - return { + provenance: dict[str, Any] = { "study_name": self.study_name, "config_path": self.source_path, "case_dir": os.path.abspath(self.case_dir), "output_dir": os.path.abspath(self.output_dir), "plume_model": self.plume_model, "plume_model_parameters": dict(self.plume_model_parameters), + "source_axis_mode": self.sweep.source_axis_mode, "coordinate_system": self.coordinate_system, "units": dict(self.units), } + if self.knudsen is not None: + provenance["knudsen"] = self.knudsen.to_dict() + return provenance def _resolve_dir(path: str, base_dir: str) -> str: diff --git a/tests/mdao/mdao_unit_test_05.py b/tests/mdao/mdao_unit_test_05.py index 3b88da6..70a4537 100644 --- a/tests/mdao/mdao_unit_test_05.py +++ b/tests/mdao/mdao_unit_test_05.py @@ -140,13 +140,28 @@ def test_study_name_is_required(self): with pytest.raises(StudyConfigError): from_mapping(data) - def test_only_the_supported_plume_model_is_accepted(self): + def test_every_supported_plume_model_is_accepted(self): + # Both collisionless Cai variants may be selected by name; the model + # named here is the one that computes the plume field. + for name in ('SimplifiedGasKinetics', 'CollisionlessGasKinetics'): + with self.subTest(model=name): + data = baseline_mapping() + data['plume_model']['name'] = name + self.assertEqual(from_mapping(data).plume_model, name) + + def test_unknown_plume_model_is_rejected(self): data = baseline_mapping() - data['plume_model']['name'] = 'CollisionlessGasKinetics' + data['plume_model']['name'] = 'DSMCGasKinetics' with pytest.raises(StudyConfigError) as excinfo: from_mapping(data) + self.assertIn('DSMCGasKinetics', str(excinfo.value)) self.assertIn(SUPPORTED_PLUME_MODEL, str(excinfo.value)) + def test_omitted_plume_model_keeps_the_historical_default(self): + data = baseline_mapping() + data.pop('plume_model') + self.assertEqual(from_mapping(data).plume_model, SUPPORTED_PLUME_MODEL) + def test_invalid_firing_counts_are_rejected(self): for value in (0, -2, 1.5, 'many'): with self.subTest(value=value): From b3a48a20273db4bf7638d60f57ffc263cb24259d Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:27:09 -0500 Subject: [PATCH 03/10] mdao: report panel-local resultants and Knudsen metadata per case CaseResult gained the fields a panel study reports, all optional and defaulted so older result files and the reference-comparison API keep working unchanged: * source_offset_u / source_offset_v / source_axis_mode -- the pose that produced the record; * normal_force, local_force_u/v, local_moment_u/v/n and center_of_pressure_u/v -- the SAME integrated force and moment vectors re-expressed on the target's surface-local basis, adding no physics; * knudsen_number, mean_free_path, knudsen_reference_length and knudsen_definition -- empty columns, not fabricated values, when the study configures no knudsen block; * model_variant (a derived short label) and surface_distribution_path. surface_loads gained project_to_panel_frame() and panel_local_coordinates() with the sign conventions written down and worked through in the module docstring: normal_force = -F.n is POSITIVE for a load pressing into the panel, and a source displaced toward +u gives a positive local_moment_v. CSV columns stay flat and directly plottable; quantity() exposes the new comparable scalars; no per-face array is embedded in a CSV row. load_case_assets no longer refuses every model but Simplified. It applies the study's plume_model by setting the environment's in-memory [pm] kinetics key -- the one input both strike paths read -- so the selection drives the calculation rather than only the metadata. The case's config.ini on disk is untouched, and a study naming the model its case already configures changes nothing. Kinetics disabled is still an error. Co-Authored-By: Claude Opus 5 --- pyrpod/mdao/study_results.py | 104 ++++++++++++++++++++++++++++- pyrpod/mdao/study_runtime.py | 86 +++++++++++++++++++++--- pyrpod/mdao/surface_loads.py | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 12 deletions(-) diff --git a/pyrpod/mdao/study_results.py b/pyrpod/mdao/study_results.py index aefce4e..9f8c25c 100644 --- a/pyrpod/mdao/study_results.py +++ b/pyrpod/mdao/study_results.py @@ -32,7 +32,7 @@ import numpy as np from numpy.typing import NDArray -from pyrpod.mdao.surface_loads import ComponentLoads +from pyrpod.mdao.surface_loads import ComponentLoads, PanelProjection __all__ = ["CaseResult", "StudyResults", "code_version"] @@ -128,19 +128,85 @@ class CaseResult: coefficients: dict[str, float] = field(default_factory=dict) coefficients_available: bool = False + # --- panel-local pose (offset sweeps) -------------------------------- + # Zero / 'aim_at_reference' for every study that does not sweep offsets, + # so an older result file round-trips unchanged. + source_offset_u: float = 0.0 + source_offset_v: float = 0.0 + source_axis_mode: str = "aim_at_reference" + + # --- panel-local integrated loads ------------------------------------ + # See pyrpod.mdao.surface_loads for the sign conventions: normal_force is + # POSITIVE for a load pressing into the panel (away from the source); + # the moments are the global moment vector projected on (u, v, n) about + # the same reference point; the centers of pressure are measured FROM + # that reference point along u and v. + normal_force: float | None = None + local_force_u: float | None = None + local_force_v: float | None = None + local_moment_u: float | None = None + local_moment_v: float | None = None + local_moment_n: float | None = None + center_of_pressure_u: float | None = None + center_of_pressure_v: float | None = None + + # --- derived Knudsen metadata (never an input to the physics) -------- + knudsen_number: float | None = None + mean_free_path: float | None = None + knudsen_reference_length: float | None = None + knudsen_definition: str | None = None + # --- artifacts and provenance ---------------------------------------- vtk_path: str | None = None jfh_path: str | None = None + surface_distribution_path: str | None = None config_path: str = "" case_dir: str = "" code_version: str = "unknown" generated_at: str = "" + # ------------------------------------------------------------ accessors + @property + def model_variant(self) -> str: + """Short label of the plume model that produced this record. + + ``'Simplified'`` or ``'Collisionless'`` -- the ``GasKinetics`` suffix + of :attr:`plume_model` carries no information and makes a plot legend + or a grouped CSV column needlessly wide. + """ + return self.plume_model.replace("GasKinetics", "") or self.plume_model + # ------------------------------------------------------------ builders @classmethod - def from_loads(cls, loads: ComponentLoads, **metadata: Any) -> "CaseResult": - """Build a result record from integrated loads plus study metadata.""" + def from_loads(cls, loads: ComponentLoads, + panel: PanelProjection | None = None, + **metadata: Any) -> "CaseResult": + """Build a result record from integrated loads plus study metadata. + + Parameters + ---------- + loads : ComponentLoads + Integrated global-frame loads for one component and firing. + panel : PanelProjection, optional + The same resultants projected on the target's surface-local + basis (see :func:`pyrpod.mdao.surface_loads.project_to_panel_frame`). + When omitted every panel-local field stays None, so a caller that + has no surface basis is never given invented numbers. + """ + panel_fields: dict[str, Any] = {} + if panel is not None: + panel_fields = { + "normal_force": panel.normal_force, + "local_force_u": panel.local_force_u, + "local_force_v": panel.local_force_v, + "local_moment_u": panel.local_moment_u, + "local_moment_v": panel.local_moment_v, + "local_moment_n": panel.local_moment_n, + "center_of_pressure_u": panel.center_of_pressure_u, + "center_of_pressure_v": panel.center_of_pressure_v, + } return cls( + **panel_fields, component=loads.component, component_faces=loads.n_faces, component_area=loads.total_area, @@ -192,9 +258,13 @@ def to_row(self) -> dict[str, Any]: "coordinate_system": self.coordinate_system, "plate_angle_deg": self.plate_angle_deg, "source_distance": self.source_distance, + "source_offset_u": self.source_offset_u, + "source_offset_v": self.source_offset_v, + "source_axis_mode": self.source_axis_mode, "firing_duration_s": self.firing_duration_s, "thrusters": " ".join(str(t) for t in self.thrusters), "plume_model": self.plume_model, + "model_variant": self.model_variant, } _expand(row, "plume_source_position", self.plume_source_position) _expand(row, "target_normal", self.target_normal) @@ -213,6 +283,13 @@ def to_row(self) -> dict[str, Any]: row["residual_couple"] = self.residual_couple _expand(row, "pressure_weighted_centroid", self.pressure_weighted_centroid) + # Panel-local resultants: flat, directly plottable columns. Empty + # rather than zero when the study supplied no surface basis. + for name in ("normal_force", "local_force_u", "local_force_v", + "local_moment_u", "local_moment_v", "local_moment_n", + "center_of_pressure_u", "center_of_pressure_v"): + value = getattr(self, name) + row[name] = "" if value is None else float(value) row.update({ "max_pressure": self.max_pressure, "max_shear_stress": self.max_shear_stress, @@ -224,9 +301,17 @@ def to_row(self) -> dict[str, Any]: }) for name, value in sorted(self.coefficients.items()): row[f"coeff_{name}"] = value + # Derived Knudsen metadata; empty columns when the study configured + # no knudsen block, never a fabricated value. + for name in ("knudsen_number", "mean_free_path", + "knudsen_reference_length"): + value = getattr(self, name) + row[name] = "" if value is None else float(value) + row["knudsen_definition"] = self.knudsen_definition or "" row.update({ "vtk_path": self.vtk_path or "", "jfh_path": self.jfh_path or "", + "surface_distribution_path": self.surface_distribution_path or "", "config_path": self.config_path, "case_dir": self.case_dir, "code_version": self.code_version, @@ -255,6 +340,19 @@ def quantity(self, name: str) -> float | list[float] | None: "max_heat_flux": self.max_heat_flux, "total_heat_load": self.total_heat_load, "affected_area": self.affected_area, + # Panel-local scalars; None when no surface basis was supplied, + # which quantity() already reports as "unavailable". + "normal_force": self.normal_force, + "local_force_u": self.local_force_u, + "local_force_v": self.local_force_v, + "local_moment_u": self.local_moment_u, + "local_moment_v": self.local_moment_v, + "local_moment_n": self.local_moment_n, + "center_of_pressure_u": self.center_of_pressure_u, + "center_of_pressure_v": self.center_of_pressure_v, + # Derived metadata, comparable across cases like any other + # scalar; it never participated in producing the loads. + "knudsen_number": self.knudsen_number, } if name in direct: return direct[name] diff --git a/pyrpod/mdao/study_runtime.py b/pyrpod/mdao/study_runtime.py index 0841154..909e83d 100644 --- a/pyrpod/mdao/study_runtime.py +++ b/pyrpod/mdao/study_runtime.py @@ -32,6 +32,7 @@ face_areas, flow_directions, integrate_component_loads, + project_to_panel_frame, select_component_faces, ) from pyrpod.mission import MissionEnvironment @@ -39,6 +40,7 @@ compute_face_centroids, compute_plume_strikes, ) +from pyrpod.plume.gas_kinetics_models import KINETICS_DISABLED, kinetics_key_for from pyrpod.rpod import JetFiringHistory, PlumeStrikeEstimationStudy from pyrpod.util.io.fs import ensure_dir from pyrpod.vehicle import TargetVehicle, VisitingVehicle @@ -107,8 +109,15 @@ def load_case_assets(config: StudyConfig) -> CaseAssets: """Construct the existing PyRPOD case objects for a study. Fails early and specifically when the case does not support the study - workflow: an unsupported plume model, or a configured thruster id that - the case's thruster configuration file does not define. + workflow: gas kinetics disabled, or a configured thruster id that the + case's thruster configuration file does not define. + + The study's ``plume_model`` selects which collisionless model computes + the plume field. It is applied by setting the environment's IN-MEMORY + ``[pm] kinetics`` key, which is the one input both strike paths read, so + the selection reaches the calculation itself rather than only the + metadata. The case's ``config.ini`` on disk is never modified, and a + study naming the model its case already configures changes nothing. """ case_dir = config.case_dir @@ -128,12 +137,21 @@ def load_case_assets(config: StudyConfig) -> CaseAssets: f"thruster configuration file; available: {available}") environment = MissionEnvironment.MissionEnvironment(case_dir) - configured_model = environment.config["pm"]["kinetics"] - if configured_model != "Simplified": + configured = environment.config["pm"]["kinetics"] + if configured == KINETICS_DISABLED: raise ValueError( - f"case {case_dir!r} configures plume kinetics " - f"{configured_model!r}; this study workflow requires the " - "SimplifiedGasKinetics model ([pm] kinetics = Simplified)") + f"case {case_dir!r} sets [pm] kinetics = {KINETICS_DISABLED}, " + "which disables the gas-kinetics surface loads; a validation " + "study needs pressure, shear and heat flux, so configure " + "'Simplified' or 'Collisionless'") + + selected = kinetics_key_for(config.plume_model) + if selected != configured: + logger.info("Study selects plume model %s: [pm] kinetics %r -> %r " + "for this run (case config.ini is not modified)", + config.plume_model, configured, selected) + environment.config["pm"]["kinetics"] = selected + return CaseAssets(target_vehicle=target_vehicle, visiting_vehicle=visiting_vehicle, environment=environment) @@ -142,6 +160,7 @@ def load_case_assets(config: StudyConfig) -> CaseAssets: def study_provenance(config: StudyConfig, geometry: TargetGeometry, **extra: Any) -> dict[str, Any]: """Study-level provenance recorded in the metadata document.""" + u_hat, v_hat, n_hat = config.target.local_basis() provenance = config.provenance() provenance.update({ "code_version": code_version( @@ -153,9 +172,23 @@ def study_provenance(config: StudyConfig, geometry: TargetGeometry, "n_firings_per_pose": config.sweep.n_firings, "total_firings": config.sweep.total_firings, "components": [name for name, _ in geometry.components], + "panel_basis": { + "u": [float(value) for value in u_hat], + "v": [float(value) for value in v_hat], + "n": [float(value) for value in n_hat], + "origin": [float(value) + for value in config.target.reference_point], + "convention": "u = target tangent (longitudinal), " + "v = n x u (transverse), n = target normal " + "(toward the plume source); u x v = n. " + "normal_force = -F.n is positive INTO the panel.", + }, "known_limitations": [ "plume shadowing, occlusion and back-facing geometry are not " "modeled (existing pipeline face-selection behavior)", + "the plume models are collisionless: any configured Knudsen " + "number is derived metadata and never enters the solution", + "no DSMC data is read, written or compared here", ], }) provenance.update(extra) @@ -238,15 +271,44 @@ def compute_strikes(config: StudyConfig, assets: CaseAssets, return firing_data, vtk_paths +def knudsen_metadata(config: StudyConfig, + source_distance: float) -> dict[str, Any]: + """Derived Knudsen fields for one case, or empty when unconfigured. + + Kn is METADATA: it is computed from the mean free path the configuration + supplied and the reference length it selected, and no part of the + analytical plume solution reads it back. When the study has no + ``knudsen`` block every Knudsen field is simply left unset. + """ + spec = config.knudsen + if spec is None: + return {} + return { + "knudsen_number": spec.knudsen_number(source_distance), + "mean_free_path": spec.mean_free_path_m, + "knudsen_reference_length": spec.reference_length_for(source_distance), + "knudsen_definition": spec.definition, + } + + def build_case_results(config: StudyConfig, geometry: TargetGeometry, firing: Firing, per_face: dict[str, Any], *, case_id: str, firing_id: int, plate_angle_deg: float, source_distance: float, jfh_path: str, vtk_path: str | None, code_version_id: str, timestamp: str, + surface_distribution_paths: dict[str, str] | None = None, ) -> list[CaseResult]: - """Integrate one firing's per-face fields into one record per component.""" + """Integrate one firing's per-face fields into one record per component. + + Each record also carries the resultants projected on the target's + surface-local basis (normal force, local moments, panel-local center of + pressure) and, when configured, the derived Knudsen metadata. + """ flow = flow_directions(geometry.centroids, firing.position) + u_hat, v_hat, n_hat = config.target.local_basis() + knudsen = knudsen_metadata(config, source_distance) + distributions = surface_distribution_paths or {} records: list[CaseResult] = [] for component_name, face_indices in geometry.components: loads = integrate_component_loads( @@ -264,6 +326,7 @@ def build_case_results(config: StudyConfig, geometry: TargetGeometry, records.append(CaseResult.from_loads( loads, + panel=project_to_panel_frame(loads, u_hat, v_hat, n_hat), study_name=config.study_name, case_id=case_id, firing_id=firing_id, @@ -280,16 +343,21 @@ def build_case_results(config: StudyConfig, geometry: TargetGeometry, float(v) for v in config.target.reference_point], plate_angle_deg=float(plate_angle_deg), source_distance=float(source_distance), + source_offset_u=float(firing.source_offset_u), + source_offset_v=float(firing.source_offset_v), + source_axis_mode=firing.source_axis_mode, firing_duration_s=float(firing.duration_s), thrusters=[int(t) for t in firing.thrusters], plume_model=config.plume_model, plume_model_parameters=dict(config.plume_model_parameters), vtk_path=vtk_path, jfh_path=jfh_path, + surface_distribution_path=distributions.get(component_name), config_path=config.source_path, case_dir=os.path.abspath(config.case_dir), code_version=code_version_id, - generated_at=timestamp)) + generated_at=timestamp, + **knudsen)) return records diff --git a/pyrpod/mdao/surface_loads.py b/pyrpod/mdao/surface_loads.py index 0d7767c..0715992 100644 --- a/pyrpod/mdao/surface_loads.py +++ b/pyrpod/mdao/surface_loads.py @@ -51,6 +51,32 @@ ``sum(p_i A_i r_i) / sum(p_i A_i)``, is always defined for a non-zero pressure load and coincides with the classical center of pressure for a planar component under unidirectional pressure. + +Panel-local reporting +--------------------- +The global-frame resultants above are also reported on the target's own +surface-local basis ``(u_hat, v_hat, n_hat)`` +(:meth:`pyrpod.mdao.study_config.TargetSpec.local_basis`), which is what a +panel study reads: a longitudinal axis ``u``, a transverse axis ``v``, and +the normal ``n``, with ``u x v = n``. :func:`project_to_panel_frame` builds +that projection, with these SIGN CONVENTIONS: + +* ``normal_force = -F . n_hat``. The target normal points TOWARD the plume + source, and the plume pushes into the surface, so a compressive + impingement load is POSITIVE. A positive normal force therefore always + means "pressed away from the source"; a negative one would mean the + resultant pulls the panel toward the source. +* ``local_moment_u/v/n = M_ref . u_hat / v_hat / n_hat``, the components of + the SAME moment vector the global-frame fields report, about the same + reference point. Positive follows the right-hand rule about that axis. + Worked sign: a pressure patch centred at ``+u`` pushes along ``-n``, so + ``M = (u_off * u_hat) x (-F_n * n_hat) = +u_off * F_n * v_hat`` (using + ``u x n = -v`` and ``F_n < 0`` along n). A source displaced toward ``+u`` + therefore produces a POSITIVE ``local_moment_v``, growing with the offset + until the patch starts leaving the panel. +* ``center_of_pressure_u/v = (r_cop - r_ref) . u_hat / v_hat``, the + panel-local coordinates of the center of pressure MEASURED FROM the + moment reference point (the panel center for the ISS-panel studies). """ from __future__ import annotations @@ -65,9 +91,12 @@ __all__ = [ "ComponentLoads", + "PanelProjection", "face_areas", "flow_directions", "integrate_component_loads", + "panel_local_coordinates", + "project_to_panel_frame", "select_component_faces", ] @@ -320,6 +349,102 @@ def integrate_component_loads( return _with_coefficients(loads, normalization) +@dataclass(frozen=True) +class PanelProjection: + """Integrated loads projected onto a target's surface-local basis. + + See the module docstring for the sign conventions. Every field is a + plain float (or None when the underlying quantity does not exist), so a + result record carries them as flat, directly plottable columns. + """ + + normal_force: float + local_force_u: float + local_force_v: float + local_moment_u: float + local_moment_v: float + local_moment_n: float + center_of_pressure_u: float | None + center_of_pressure_v: float | None + + +def panel_local_coordinates( + points: NDArray[np.float64], + reference_point: Sequence[float] | NDArray[np.float64], + u_hat: Sequence[float] | NDArray[np.float64], + v_hat: Sequence[float] | NDArray[np.float64], +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Panel-local ``(u, v)`` coordinates of points, measured from a reference. + + A pure projection of ``points - reference_point`` onto the two in-surface + axes; nothing is interpolated, fitted or flattened. For a planar target + whose points lie in the panel, ``(u, v)`` recovers the panel coordinates + exactly. + + Parameters + ---------- + points : np.ndarray + (N, 3) points in the case's global frame (face centroids, typically). + reference_point : array-like + Panel-local origin, normally the target reference point. + u_hat, v_hat : array-like + The in-surface axes (see + :meth:`pyrpod.mdao.study_config.TargetSpec.local_basis`). + + Returns + ------- + (np.ndarray, np.ndarray) + The ``u`` and ``v`` coordinate arrays, each of length N. + """ + relative = (np.asarray(points, dtype=float) + - np.asarray(reference_point, dtype=float).reshape(3)) + return (relative @ np.asarray(u_hat, dtype=float).reshape(3), + relative @ np.asarray(v_hat, dtype=float).reshape(3)) + + +def project_to_panel_frame( + loads: ComponentLoads, + u_hat: Sequence[float] | NDArray[np.float64], + v_hat: Sequence[float] | NDArray[np.float64], + n_hat: Sequence[float] | NDArray[np.float64], +) -> PanelProjection: + """Project one component's resultants onto the surface-local basis. + + The projection is exact and adds no physics: it re-expresses the force + and moment vectors this module already integrated, in the basis a panel + study reports. See the module docstring for the sign conventions, in + particular that ``normal_force`` is positive for a load pressing INTO + the panel (away from the plume source). + """ + u = np.asarray(u_hat, dtype=float).reshape(3) + v = np.asarray(v_hat, dtype=float).reshape(3) + n = np.asarray(n_hat, dtype=float).reshape(3) + + force = np.asarray(loads.force, dtype=float).reshape(3) + moment = np.asarray(loads.moment, dtype=float).reshape(3) + + cop_u: float | None = None + cop_v: float | None = None + if loads.center_of_pressure is not None: + arm = (np.asarray(loads.center_of_pressure, dtype=float).reshape(3) + - np.asarray(loads.moment_reference_point, + dtype=float).reshape(3)) + cop_u = float(arm @ u) + cop_v = float(arm @ v) + + return PanelProjection( + # Positive into the panel: the normal points at the plume source. + normal_force=float(-(force @ n)), + local_force_u=float(force @ u), + local_force_v=float(force @ v), + local_moment_u=float(moment @ u), + local_moment_v=float(moment @ v), + local_moment_n=float(moment @ n), + center_of_pressure_u=cop_u, + center_of_pressure_v=cop_v, + ) + + def _center_of_pressure( force: NDArray[np.float64], moment: NDArray[np.float64], reference: NDArray[np.float64], force_magnitude: float, From 91eda0e87fade2630bf8da79c070da12eae3a809 Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:31:12 -0500 Subject: [PATCH 04/10] mdao: export panel-local surface distributions per case Adds an opt-in second view of the per-face fields the VTK files already carry: one CSV per case and component with face_index, global centroid, panel-local (u, v), area, pressure, shear stress, heat flux and strike count. The VTK export stays the primary full-resolution output and is untouched; this is for plotting scripts, spreadsheets and a later comparison workflow that should not need a VTK reader. Values are copied through unchanged -- no interpolation, no resampling, no structured-grid projection, and no common-grid projection onto any external mesh. Every row is one native mesh face, traceable back to the mesh and the VTK file by its preserved face_index. A sidecar JSON records the column units, the panel basis, the pose, the plume model and any derived Knudsen metadata, so a distribution file is self-describing. Enabled with output.surface_distribution.enabled (default false); the path lands in CaseResult.surface_distribution_path. Both engines export it, into the per-case directory for per_case mode and the study root for single_jfh. case_id_for() now names what actually varies: aim_at_reference keeps the historical case000_alpha0p0_d4 form byte-for-byte, while parallel_to_normal uses case000_modelCollisionless_L4_u0_v0, since the approach angle is fixed in that mode. The per-case engine iterates the full SweepPose grid. A firing with no swept distance (an explicitly prescribed pose in single_jfh mode) records the mean free path and definition but leaves the distance-referenced Kn unset rather than inventing one. Co-Authored-By: Claude Opus 5 --- pyrpod/mdao/parameter_sweep.py | 18 ++- pyrpod/mdao/plume_validation.py | 71 +++++++--- pyrpod/mdao/study_runtime.py | 82 ++++++++++- pyrpod/mdao/surface_distribution.py | 211 ++++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 pyrpod/mdao/surface_distribution.py diff --git a/pyrpod/mdao/parameter_sweep.py b/pyrpod/mdao/parameter_sweep.py index 40fe43b..155208b 100644 --- a/pyrpod/mdao/parameter_sweep.py +++ b/pyrpod/mdao/parameter_sweep.py @@ -148,16 +148,28 @@ def run(self, write_outputs: bool = True) -> StudyResults: timestamp = study_runtime.utc_timestamp() version = str(results.provenance.get("code_version", "unknown")) + distribution_dir = os.path.join( + config.output_dir, config.output.surface_distribution_subdir) + for firing_index, firing in enumerate(firings): per_face = firing_data[str(firing_index + 1)] + plate_angle_deg = _pose_value(firing.plate_angle_deg) + source_distance = _pose_value(firing.source_distance) + distributions = study_runtime.export_surface_distributions( + config, geometry, firing, per_face, case_id=self.case_id, + firing_id=firing_index + 1, + plate_angle_deg=plate_angle_deg, + source_distance=source_distance, + output_dir=distribution_dir) results.cases.extend(study_runtime.build_case_results( config, geometry, firing, per_face, case_id=self.case_id, firing_id=firing_index + 1, - plate_angle_deg=_pose_value(firing.plate_angle_deg), - source_distance=_pose_value(firing.source_distance), + plate_angle_deg=plate_angle_deg, + source_distance=source_distance, jfh_path=jfh_path, vtk_path=vtk_paths[firing_index] if vtk_paths else None, - code_version_id=version, timestamp=timestamp)) + code_version_id=version, timestamp=timestamp, + surface_distribution_paths=distributions)) # 4. The sweep envelope, which only a shared history can produce. last = firing_data[str(len(firings))] diff --git a/pyrpod/mdao/plume_validation.py b/pyrpod/mdao/plume_validation.py index 81c6e76..54711d8 100644 --- a/pyrpod/mdao/plume_validation.py +++ b/pyrpod/mdao/plume_validation.py @@ -51,7 +51,7 @@ compare_results, load_reference_dataset, ) -from pyrpod.mdao.study_config import StudyConfig +from pyrpod.mdao.study_config import StudyConfig, SweepPose from pyrpod.mdao.study_results import StudyResults from pyrpod.mdao.study_runtime import CaseAssets, TargetGeometry from pyrpod.util.io.fs import ensure_dir @@ -61,12 +61,34 @@ __all__ = ["PlumeValidationStudy", "case_id_for"] -def case_id_for(index: int, plate_angle_deg: float, - source_distance: float) -> str: - """Stable, filesystem-safe case identifier.""" - angle = f"{plate_angle_deg:.1f}".replace("-", "m").replace(".", "p") - distance = f"{source_distance:.4g}".replace("-", "m").replace(".", "p") - return f"case{index:03d}_alpha{angle}_d{distance}" +def _token(value: float, spec: str = ".4g") -> str: + """Filesystem-safe number token: '-' -> 'm', '.' -> 'p'.""" + return format(value, spec).replace("-", "m").replace(".", "p") + + +def case_id_for(index: int, plate_angle_deg: float, source_distance: float, + pose: SweepPose | None = None, + model_variant: str | None = None) -> str: + """Stable, filesystem-safe case identifier. + + The identifier names the parameters that actually vary in the study's + axis mode, so a case directory is self-describing: + + * ``aim_at_reference`` (default) keeps the historical + ``case000_alpha0p0_d4`` form, byte-for-byte, so existing studies write + to exactly the paths they always did; + * ``parallel_to_normal`` names the model, stand-off and panel-local + offsets instead, e.g. ``case000_modelCollisionless_L4_u0_v0`` -- the + approach angle is fixed in that mode and would carry no information. + """ + if pose is not None and pose.axis_mode == "parallel_to_normal": + model = f"_model{model_variant}" if model_variant else "" + return (f"case{index:03d}{model}" + f"_L{_token(pose.source_distance)}" + f"_u{_token(pose.source_offset_u)}" + f"_v{_token(pose.source_offset_v)}") + return (f"case{index:03d}_alpha{_token(plate_angle_deg, '.1f')}" + f"_d{_token(source_distance)}") class PlumeValidationStudy: @@ -119,11 +141,14 @@ def run(self, write_outputs: bool = True) -> StudyResults: config.sweep.n_firings, len(geometry.components), geometry.n_faces) - for index, (angle, distance) in enumerate(config.sweep.poses): - self._run_case(case_id=case_id_for(index, angle, distance), - pose_index=index, plate_angle_deg=angle, - source_distance=distance, results=results, - assets=assets, geometry=geometry) + model_variant = config.plume_model.replace("GasKinetics", "") + for index, pose in enumerate(config.sweep.sweep_poses): + self._run_case( + case_id=case_id_for(index, pose.plate_angle_deg, + pose.source_distance, pose=pose, + model_variant=model_variant), + pose_index=index, pose=pose, results=results, + assets=assets, geometry=geometry) if write_outputs: self.write_outputs(results) @@ -144,14 +169,15 @@ def run(self, write_outputs: bool = True) -> StudyResults: return results # ------------------------------------------------------------ one case - def _run_case(self, *, case_id: str, pose_index: int, - plate_angle_deg: float, source_distance: float, + def _run_case(self, *, case_id: str, pose_index: int, pose: SweepPose, results: StudyResults, assets: CaseAssets, geometry: TargetGeometry) -> None: config = self.config + plate_angle_deg = pose.plate_angle_deg + source_distance = pose.source_distance firings = firing_plan.build_case_firings( config.sweep, config.target, plate_angle_deg, source_distance, - pose_index=pose_index) + pose_index=pose_index, pose=pose) jfh_path = os.path.join(config.output_dir, "jfh", f"{case_id}.A") n_written = firing_plan.write_jfh_file(jfh_path, firings) @@ -173,14 +199,25 @@ def _run_case(self, *, case_id: str, pose_index: int, timestamp = study_runtime.utc_timestamp() version = str(results.provenance.get("code_version", "unknown")) + distribution_dir = os.path.join( + config.output_dir, "cases", case_id, + config.output.surface_distribution_subdir) + for firing_index, firing in enumerate(firings): + per_face = firing_data[str(firing_index + 1)] + distributions = study_runtime.export_surface_distributions( + config, geometry, firing, per_face, case_id=case_id, + firing_id=firing_index + 1, plate_angle_deg=plate_angle_deg, + source_distance=source_distance, + output_dir=distribution_dir) results.cases.extend(study_runtime.build_case_results( - config, geometry, firing, firing_data[str(firing_index + 1)], + config, geometry, firing, per_face, case_id=case_id, firing_id=firing_index + 1, plate_angle_deg=plate_angle_deg, source_distance=source_distance, jfh_path=jfh_path, vtk_path=vtk_paths[firing_index] if vtk_paths else None, - code_version_id=version, timestamp=timestamp)) + code_version_id=version, timestamp=timestamp, + surface_distribution_paths=distributions)) # -------------------------------------------------------------- outputs def write_outputs(self, results: StudyResults) -> None: diff --git a/pyrpod/mdao/study_runtime.py b/pyrpod/mdao/study_runtime.py index 909e83d..00fc2dd 100644 --- a/pyrpod/mdao/study_runtime.py +++ b/pyrpod/mdao/study_runtime.py @@ -28,6 +28,10 @@ from pyrpod.mdao.firing_plan import Firing from pyrpod.mdao.study_config import StudyConfig from pyrpod.mdao.study_results import CaseResult, code_version +from pyrpod.mdao.surface_distribution import ( + distribution_rows, + write_surface_distribution, +) from pyrpod.mdao.surface_loads import ( face_areas, flow_directions, @@ -53,6 +57,8 @@ "build_case_results", "component_envelope", "compute_strikes", + "export_surface_distributions", + "knudsen_metadata", "load_case_assets", "read_generated_jfh", "study_provenance", @@ -283,12 +289,82 @@ def knudsen_metadata(config: StudyConfig, spec = config.knudsen if spec is None: return {} - return { - "knudsen_number": spec.knudsen_number(source_distance), + metadata: dict[str, Any] = { "mean_free_path": spec.mean_free_path_m, - "knudsen_reference_length": spec.reference_length_for(source_distance), "knudsen_definition": spec.definition, } + if (spec.reference_mode == "source_distance" + and not np.isfinite(source_distance)): + # An explicitly prescribed firing carries no swept distance (recorded + # as NaN), so a distance-referenced Kn does not exist for it. The + # mean free path and the definition still describe the study; the + # number itself is left unset rather than invented. + logger.debug("Knudsen number omitted for a firing with no swept " + "source distance (reference_length: source_distance)") + return metadata + metadata["knudsen_number"] = spec.knudsen_number(source_distance) + metadata["knudsen_reference_length"] = spec.reference_length_for( + source_distance) + return metadata + + +def export_surface_distributions(config: StudyConfig, + geometry: TargetGeometry, firing: Firing, + per_face: dict[str, Any], *, case_id: str, + firing_id: int, plate_angle_deg: float, + source_distance: float, + output_dir: str) -> dict[str, str]: + """Write one panel-local distribution CSV per component, if enabled. + + An ADDITION to the VTK export, never a replacement: the same native + per-face values, in the target's own (u, v) coordinates, with no + interpolation. Returns ``{component name: csv path}``, empty when + ``output.surface_distribution.enabled`` is false. + """ + if not config.output.write_surface_distribution: + return {} + + u_hat, v_hat, n_hat = config.target.local_basis() + reference = config.target.reference_point + knudsen = knudsen_metadata(config, source_distance) + ensure_dir(output_dir) + + paths: dict[str, str] = {} + for component_name, face_indices in geometry.components: + rows = distribution_rows( + face_indices, geometry.centroids, geometry.areas, + per_face["pressures"], per_face["shear_stress"], + per_face["heat_flux_rate"], per_face.get("strikes"), + reference_point=reference, u_hat=u_hat, v_hat=v_hat) + metadata: dict[str, Any] = { + "study_name": config.study_name, + "case_id": case_id, + "component": component_name, + "firing_id": firing_id, + "geometry_id": config.target.geometry_id, + "coordinate_system": config.coordinate_system, + "panel_basis": { + "origin": [float(value) for value in reference], + "u": [float(value) for value in u_hat], + "v": [float(value) for value in v_hat], + "n": [float(value) for value in n_hat], + }, + "plate_angle_deg": float(plate_angle_deg), + "source_distance": float(source_distance), + "source_offset_u": float(firing.source_offset_u), + "source_offset_v": float(firing.source_offset_v), + "source_axis_mode": firing.source_axis_mode, + "plume_source_position": [float(v) for v in firing.position], + "plume_model": config.plume_model, + "plume_model_parameters": dict(config.plume_model_parameters), + "firing_duration_s": float(firing.duration_s), + } + metadata.update(knudsen) + + name = f"{case_id}_{component_name}_firing{firing_id:03d}.csv" + paths[component_name] = write_surface_distribution( + os.path.join(output_dir, name), rows, metadata) + return paths def build_case_results(config: StudyConfig, geometry: TargetGeometry, diff --git a/pyrpod/mdao/surface_distribution.py b/pyrpod/mdao/surface_distribution.py new file mode 100644 index 0000000..070bc6f --- /dev/null +++ b/pyrpod/mdao/surface_distribution.py @@ -0,0 +1,211 @@ +""" +Panel-local surface-distribution export for plume/target validation studies. + +The per-firing VTK files remain the PRIMARY full-resolution visualization +output of a study; nothing here replaces or reformats them. This module adds +a second, flat view of exactly the same numbers: one CSV per case and +component holding every face of that component with its panel-local ``(u, v)`` +coordinates alongside the native pressure, shear stress, heat flux and strike +count. + +Why a second export +------------------- +A ``.vtu`` file is the right artifact for ParaView and the wrong one for a +plotting script, a spreadsheet, or a later comparison workflow that needs to +line PyRPOD faces up with an externally generated dataset. The CSV carries +the same values in the target's own coordinates, so a panel study can be +plotted and compared without a VTK reader. + +What it is NOT +-------------- +* No interpolation, smoothing, resampling or structured-grid projection. + Every row is one native mesh face, with the value the strike pipeline + computed for it. A flat-plate mesh is unstructured triangles and is + exported as unstructured triangles. +* No common-grid projection onto any external mesh, and nothing DSMC-aware. + Producing a shared grid is a separate workflow's job. + +Units and provenance +-------------------- +The CSV holds numbers only. A sidecar JSON written beside it records the +units of every column, the panel basis the coordinates were taken on, the +case's pose, the plume model and the derived Knudsen metadata, so a +distribution file is self-describing without the study metadata document. +""" + +from __future__ import annotations + +import csv +import json +import os +from typing import Any, Mapping, Sequence + +import numpy as np +from numpy.typing import NDArray + +from pyrpod.mdao.surface_loads import panel_local_coordinates + +__all__ = [ + "DISTRIBUTION_COLUMNS", + "DISTRIBUTION_UNITS", + "distribution_rows", + "write_surface_distribution", +] + +#: Column order of an exported distribution CSV. Flat and stable: a reader +#: may rely on these names existing, in this order. +DISTRIBUTION_COLUMNS: tuple[str, ...] = ( + "face_index", + "centroid_x", + "centroid_y", + "centroid_z", + "local_u", + "local_v", + "area", + "pressure", + "shear_stress", + "heat_flux", + "strike_count", +) + +#: SI units of every exported column, recorded in the sidecar JSON. +DISTRIBUTION_UNITS: dict[str, str] = { + "face_index": "-", + "centroid_x": "m", + "centroid_y": "m", + "centroid_z": "m", + "local_u": "m", + "local_v": "m", + "area": "m^2", + "pressure": "Pa", + "shear_stress": "Pa", + "heat_flux": "W/m^2", + "strike_count": "-", +} + + +def distribution_rows( + face_indices: Sequence[int] | NDArray[np.int64], + centroids: NDArray[np.float64], + areas: NDArray[np.float64], + pressures: NDArray[np.float64], + shear_stresses: NDArray[np.float64], + heat_fluxes: NDArray[np.float64], + strikes: NDArray[np.float64] | None, + *, + reference_point: Sequence[float] | NDArray[np.float64], + u_hat: Sequence[float] | NDArray[np.float64], + v_hat: Sequence[float] | NDArray[np.float64], +) -> list[dict[str, float]]: + """Build one distribution row per face of a component. + + The arrays are the FULL target mesh's per-face fields; ``face_indices`` + selects the component's faces and is preserved in the ``face_index`` + column, so a row can always be traced back to the mesh and to the VTK + file. Values are copied through unchanged. + + Parameters + ---------- + face_indices : array-like of int + Component face indices into the full-mesh arrays. + centroids, areas : np.ndarray + Full-mesh face centroids (N, 3) and areas (N,). + pressures, shear_stresses, heat_fluxes : np.ndarray + Full-mesh per-face fields for one firing (Pa, Pa, W/m^2). + strikes : np.ndarray or None + Full-mesh per-face strike counts; zeros are recorded when None. + reference_point, u_hat, v_hat : array-like + Panel-local origin and in-surface axes (see + :meth:`pyrpod.mdao.study_config.TargetSpec.local_basis`). + + Returns + ------- + list of dict + One dictionary per face, keyed by :data:`DISTRIBUTION_COLUMNS`. + """ + indices = np.asarray(face_indices, dtype=np.int64) + centroid = np.asarray(centroids, dtype=float)[indices] + area = np.asarray(areas, dtype=float)[indices] + pressure = np.asarray(pressures, dtype=float)[indices] + shear = np.asarray(shear_stresses, dtype=float)[indices] + heat_flux = np.asarray(heat_fluxes, dtype=float)[indices] + strike = (np.zeros(indices.size) if strikes is None + else np.asarray(strikes, dtype=float)[indices]) + + local_u, local_v = panel_local_coordinates(centroid, reference_point, + u_hat, v_hat) + + return [ + { + "face_index": int(indices[i]), + "centroid_x": float(centroid[i, 0]), + "centroid_y": float(centroid[i, 1]), + "centroid_z": float(centroid[i, 2]), + "local_u": float(local_u[i]), + "local_v": float(local_v[i]), + "area": float(area[i]), + "pressure": float(pressure[i]), + "shear_stress": float(shear[i]), + "heat_flux": float(heat_flux[i]), + "strike_count": float(strike[i]), + } + for i in range(indices.size) + ] + + +def write_surface_distribution(path: str | os.PathLike[str], + rows: Sequence[Mapping[str, Any]], + metadata: Mapping[str, Any] | None = None, + ) -> str: + """Write one component's distribution CSV and its sidecar JSON. + + Parameters + ---------- + path : str or path-like + Destination CSV path; parent directories are created. + rows : sequence of mapping + Rows from :func:`distribution_rows`. + metadata : mapping, optional + Case metadata (pose, plume model, Knudsen, panel basis, ...) written + to ``.meta.json`` together with the column units. When + omitted only the units and column order are recorded. + + Returns + ------- + str + The CSV path written. + + Raises + ------ + ValueError + If ``rows`` is empty -- an empty distribution is a bug in the caller, + not a valid artifact. + """ + if not rows: + raise ValueError( + "refusing to write an empty surface distribution; the component " + "selects no faces") + + path = os.fspath(path) + parent = os.path.dirname(os.path.abspath(path)) + os.makedirs(parent, exist_ok=True) + + with open(path, "w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(DISTRIBUTION_COLUMNS)) + writer.writeheader() + writer.writerows(rows) + + sidecar = f"{os.path.splitext(path)[0]}.meta.json" + document: dict[str, Any] = { + "schema": "pyrpod.surface_distribution/1", + "columns": list(DISTRIBUTION_COLUMNS), + "units": dict(DISTRIBUTION_UNITS), + "n_faces": len(rows), + "interpolation": "none; every row is one native mesh face", + } + if metadata: + document.update(dict(metadata)) + with open(sidecar, "w", encoding="utf-8", newline="\n") as handle: + json.dump(document, handle, indent=2, sort_keys=False) + handle.write("\n") + return path From 586a7fefee41fb44c8b053ac7d156670f11073c3 Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:34:34 -0500 Subject: [PATCH 05/10] mdao: add panel-local figures for offset sweeps study_plots covers the angle sweep; panel_plots covers the offset sweep, where the independent variable is the panel-local source offset: * plot_panel_pressure() draws one case's panel-local pressure field from its exported distribution CSV, with the panel edges and the plume centerline marked. A flat plate is an unstructured triangle mesh, so no structured grid is fabricated -- it is drawn as a Delaunay triangulation of the face centroids, falling back to a face-coloured scatter when the face count is too small for contours to be honest. * plot_offset_sweep_trends() writes normal force, moment about v, peak pressure and center-of-pressure u against the u offset, plus normal force against stand-off distance. Every series is grouped by distance AND plume model, so merged Simplified and Collisionless results stay separate rather than being averaged. Transverse figures appear only when v is actually swept. study_runtime.study_plots_for() decides which families a given study wants and is called by both engines, so the choice is not duplicated and does not leak into the TradeStudy facade. Both plotting modules are imported lazily, so a study that asks for no plots never imports matplotlib; the Agg backend and the existing palette are reused, and no new dependency is added. Co-Authored-By: Claude Opus 5 --- pyrpod/mdao/panel_plots.py | 341 ++++++++++++++++++++++++++++++++ pyrpod/mdao/parameter_sweep.py | 6 +- pyrpod/mdao/plume_validation.py | 18 +- pyrpod/mdao/study_runtime.py | 53 +++++ 4 files changed, 406 insertions(+), 12 deletions(-) create mode 100644 pyrpod/mdao/panel_plots.py diff --git a/pyrpod/mdao/panel_plots.py b/pyrpod/mdao/panel_plots.py new file mode 100644 index 0000000..78316fe --- /dev/null +++ b/pyrpod/mdao/panel_plots.py @@ -0,0 +1,341 @@ +""" +Panel-local figures for offset-sweep (ISS-representative) plume studies. + +The sibling :mod:`pyrpod.mdao.study_plots` covers the ANGLE sweep: force, +moment and heat flux against approach angle and stand-off distance. This +module covers the OFFSET sweep, in which the plume source is translated +parallel to a flat panel (``sweep.source_axis_mode: parallel_to_normal``) +and the interesting independent variable is the panel-local source offset. + +Two families of figure are produced, both optional and both written only +when the study asks for them: + +*per-case pressure distribution* + ``panel_pressure_.png`` -- the panel-local pressure field of + one case, drawn from the exported distribution CSV. + +*sweep trends* + ``normal_force_vs_offset_u.png``, ``moment_v_vs_offset_u.png``, + ``peak_pressure_vs_offset_u.png``, ``cop_u_vs_offset_u.png`` and + ``normal_force_vs_distance.png`` -- each grouped by stand-off distance + and plume model, so several models plotted from merged results stay + visually distinct. + +Plotting conventions follow the existing :mod:`pyrpod.mdao.study_plots`: +matplotlib's non-interactive Agg backend is selected on import so a headless +or CI run never opens a window, the same categorical palette is reused, and +no plotting dependency beyond matplotlib is introduced. + +A note on the distribution figure +--------------------------------- +A flat-plate target is an UNSTRUCTURED triangle mesh, so no structured grid +is fabricated for it. The pressure field is drawn either as a Delaunay +triangulation of the face centroids (filled contours) or, when the face +count is small enough that contours would be misleading, as a face-coloured +scatter of the centroids themselves. Both show the values the strike +pipeline actually computed. +""" + +from __future__ import annotations + +import csv +import os +from collections import defaultdict +from typing import Iterable, Sequence + +import matplotlib +import numpy as np + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 (backend must be set first) +import matplotlib.tri as mtri # noqa: E402 + +from pyrpod.mdao.study_plots import PALETTE # noqa: E402 +from pyrpod.mdao.study_results import CaseResult, StudyResults # noqa: E402 + +__all__ = [ + "plot_offset_sweep_trends", + "plot_panel_pressure", + "plot_panel_pressure_for_case", +] + +#: Below this face count a filled-contour plot interpolates more than it +#: reveals, so the faces are drawn individually instead. +MIN_FACES_FOR_CONTOURS = 64 + +#: Distinct line styles, so two models at the same distance stay separable +#: for a reader who cannot rely on colour alone. +MODEL_STYLES = ("-o", "--s", ":^", "-.d") + + +# -------------------------------------------------------------------------- +# per-case panel-local pressure distribution +# -------------------------------------------------------------------------- +def plot_panel_pressure(local_u: Sequence[float], local_v: Sequence[float], + pressure: Sequence[float], path: str, *, + title: str = "Panel-local pressure distribution", + panel_half_u: float | None = None, + panel_half_v: float | None = None, + centerline_u: float | None = None, + centerline_v: float | None = None) -> str: + """Draw a panel-local pressure field from per-face values. + + Parameters + ---------- + local_u, local_v : sequence of float + Panel-local coordinates of the face centroids (m). + pressure : sequence of float + Per-face pressure (Pa), in the same order. + path : str + Destination PNG path; parent directories are created. + title : str, optional + Figure title. + panel_half_u, panel_half_v : float, optional + Panel semi-dimensions; when both are given the panel edges are drawn. + Defaults to the extent of the supplied coordinates. + centerline_u, centerline_v : float, optional + Panel-local point where the plume centerline meets the panel, marked + when supplied. + + Returns + ------- + str + The path written. + """ + u = np.asarray(local_u, dtype=float) + v = np.asarray(local_v, dtype=float) + p = np.asarray(pressure, dtype=float) + + os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) + figure, axes = plt.subplots(figsize=(8, 5)) + + # A degenerate field (every face identical, or too few faces) would make + # contouring meaningless, so fall back to drawing the faces themselves. + use_contours = (u.size >= MIN_FACES_FOR_CONTOURS + and float(np.ptp(u)) > 0.0 and float(np.ptp(v)) > 0.0 + and float(np.ptp(p)) > 0.0) + if use_contours: + triangulation = mtri.Triangulation(u, v) + mappable = axes.tricontourf(triangulation, p, levels=24, + cmap="viridis") + else: + mappable = axes.scatter(u, v, c=p, s=28, cmap="viridis", + edgecolors="none") + bar = figure.colorbar(mappable, ax=axes) + bar.set_label("pressure (Pa)") + + half_u = panel_half_u if panel_half_u is not None else float(np.max(np.abs(u))) + half_v = panel_half_v if panel_half_v is not None else float(np.max(np.abs(v))) + axes.plot([-half_u, half_u, half_u, -half_u, -half_u], + [-half_v, -half_v, half_v, half_v, -half_v], + color="0.25", lw=1.2, label="panel edge") + + if centerline_u is not None and centerline_v is not None: + axes.plot([centerline_u], [centerline_v], marker="x", ms=11, mew=2.2, + color="#e8382a", linestyle="none", + label="plume centerline") + + axes.set_xlabel("panel-local u (m), longitudinal") + axes.set_ylabel("panel-local v (m), transverse") + axes.set_title(title, fontsize=10) + axes.set_aspect("equal", adjustable="box") + axes.legend(fontsize=8, loc="upper right") + figure.savefig(path, dpi=200, bbox_inches="tight") + plt.close(figure) + return path + + +def plot_panel_pressure_for_case(case: CaseResult, out_dir: str, + panel_half_u: float | None = None, + panel_half_v: float | None = None, + ) -> str | None: + """Draw one case's pressure distribution from its exported CSV. + + Returns None (rather than raising) when the case carries no distribution + export, so a study with distributions disabled simply produces no + per-case figures. + """ + path = case.surface_distribution_path + if not path or not os.path.isfile(path): + return None + + local_u: list[float] = [] + local_v: list[float] = [] + pressure: list[float] = [] + with open(path, "r", encoding="utf-8", newline="") as handle: + for row in csv.DictReader(handle): + local_u.append(float(row["local_u"])) + local_v.append(float(row["local_v"])) + pressure.append(float(row["pressure"])) + if not pressure: + return None + + # In parallel_to_normal mode the plume axis is anti-parallel to the panel + # normal, so the centerline meets the panel exactly at the source offset. + centerline = (case.source_offset_u, case.source_offset_v) \ + if case.source_axis_mode == "parallel_to_normal" else (None, None) + + title = (f"Panel-local pressure -- {case.case_id} " + f"({case.model_variant}, L = {case.source_distance:g} m, " + f"u = {case.source_offset_u:g} m, v = {case.source_offset_v:g} m)") + return plot_panel_pressure( + local_u, local_v, pressure, + os.path.join(out_dir, f"panel_pressure_{case.case_id}.png"), + title=title, panel_half_u=panel_half_u, panel_half_v=panel_half_v, + centerline_u=centerline[0], centerline_v=centerline[1]) + + +# -------------------------------------------------------------------------- +# offset-sweep trends +# -------------------------------------------------------------------------- +def _finite(cases: Iterable[CaseResult], quantity: str + ) -> list[tuple[CaseResult, float]]: + """Cases whose quantity is present and finite, with that value.""" + selected = [] + for case in cases: + value = getattr(case, quantity, None) + if value is None: + continue + number = float(value) + if np.isfinite(number): + selected.append((case, number)) + return selected + + +def _grouped_series(cases: Sequence[CaseResult], quantity: str, + variable: str, group_by: Sequence[str], + ) -> list[tuple[str, list[float], list[float]]]: + """Series of (label, xs, ys), one per distinct combination of group_by.""" + groups: dict[tuple, list[tuple[float, float]]] = defaultdict(list) + for case, value in _finite(cases, quantity): + key = tuple(getattr(case, name) for name in group_by) + groups[key].append((float(getattr(case, variable)), value)) + + series: list[tuple[str, list[float], list[float]]] = [] + for key in sorted(groups, key=lambda k: tuple(str(part) for part in k)): + points = sorted(groups[key]) + label = ", ".join( + f"{_label_for(name)}{part:g}" if isinstance(part, (int, float)) + else str(part) + for name, part in zip(group_by, key)) + series.append((label, [x for x, _ in points], [y for _, y in points])) + return series + + +def _label_for(field: str) -> str: + return {"source_distance": "L = ", "source_offset_u": "u = ", + "source_offset_v": "v = "}.get(field, f"{field} = ") + + +def _trend_figure(path: str, title: str, xlabel: str, ylabel: str, + series: Sequence[tuple[str, list[float], list[float]]], + ) -> str | None: + """Write one grouped trend figure; None when there is nothing to draw.""" + if not any(xs for _, xs, _ in series): + return None + os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) + figure, axes = plt.subplots(figsize=(7, 4.5)) + for index, (label, xs, ys) in enumerate(series): + axes.plot(xs, ys, MODEL_STYLES[index % len(MODEL_STYLES)], + color=PALETTE[index % len(PALETTE)], lw=2, ms=4, label=label) + axes.set_xlabel(xlabel) + axes.set_ylabel(ylabel) + axes.set_title(title, fontsize=10) + axes.grid(True, color="0.9", lw=0.8) + axes.legend(fontsize=8) + figure.savefig(path, dpi=200, bbox_inches="tight") + plt.close(figure) + return path + + +def plot_offset_sweep_trends(results: StudyResults, out_dir: str, + component: str | None = None) -> list[str]: + """Write the offset-sweep trend figures; returns the paths written. + + Each figure groups by stand-off distance AND plume model, so results + merged from a Simplified run and a Collisionless run of the same + geometry plot as separate, labelled series rather than being averaged + together. + + Parameters + ---------- + results : StudyResults + Executed study results. + out_dir : str + Directory to write the figures to (created when missing). + component : str, optional + Restrict to one target component; defaults to the first present. + """ + cases = list(results.cases) + if not cases: + return [] + component = component or cases[0].component + cases = [case for case in cases if case.component == component] + # One point per pose: keep the first firing of each. + seen: set[tuple[float, float, float, float, str]] = set() + unique: list[CaseResult] = [] + for case in cases: + key = (case.plate_angle_deg, case.source_distance, + case.source_offset_u, case.source_offset_v, case.plume_model) + if key in seen: + continue + seen.add(key) + unique.append(case) + cases = unique + if not cases: + return [] + + os.makedirs(out_dir, exist_ok=True) + written: list[str] = [] + group = ("source_distance", "model_variant") + + for quantity, filename, ylabel, title in ( + ("normal_force", "normal_force_vs_offset_u.png", + "normal force (N), + into panel", + "Normal force vs source u offset"), + ("local_moment_v", "moment_v_vs_offset_u.png", + "local moment about v (N*m)", + "Panel moment about v vs source u offset"), + ("max_pressure", "peak_pressure_vs_offset_u.png", + "peak pressure (Pa)", "Peak pressure vs source u offset"), + ("center_of_pressure_u", "cop_u_vs_offset_u.png", + "center of pressure u (m)", + "Center-of-pressure u vs source u offset")): + path = _trend_figure( + os.path.join(out_dir, filename), f"{title} ({component})", + "source u offset (m)", ylabel, + _grouped_series(cases, quantity, "source_offset_u", group)) + if path: + written.append(path) + + # Normal force against stand-off, one series per (u offset, model). + path = _trend_figure( + os.path.join(out_dir, "normal_force_vs_distance.png"), + f"Normal force vs source distance ({component})", + "source distance L (m)", "normal force (N), + into panel", + _grouped_series(cases, "normal_force", "source_distance", + ("source_offset_u", "model_variant"))) + if path: + written.append(path) + + # The transverse offset gets its own figures only when it is swept. + if len({case.source_offset_v for case in cases}) > 1: + for quantity, filename, ylabel, title in ( + ("normal_force", "normal_force_vs_offset_v.png", + "normal force (N), + into panel", + "Normal force vs source v offset"), + ("local_moment_u", "moment_u_vs_offset_v.png", + "local moment about u (N*m)", + "Panel moment about u vs source v offset"), + ("center_of_pressure_v", "cop_v_vs_offset_v.png", + "center of pressure v (m)", + "Center-of-pressure v vs source v offset")): + path = _trend_figure( + os.path.join(out_dir, filename), f"{title} ({component})", + "source v offset (m)", ylabel, + _grouped_series(cases, quantity, "source_offset_v", group)) + if path: + written.append(path) + + return written diff --git a/pyrpod/mdao/parameter_sweep.py b/pyrpod/mdao/parameter_sweep.py index 155208b..a8df5a2 100644 --- a/pyrpod/mdao/parameter_sweep.py +++ b/pyrpod/mdao/parameter_sweep.py @@ -228,15 +228,13 @@ def compare(self, dataset: ReferenceDataset) -> ComparisonReport: # ---------------------------------------------------------------- plots def plot(self, results: StudyResults | None = None) -> list[str]: - """Generate the optional parameter-sweep trend plots.""" - from pyrpod.mdao import study_plots - + """Generate the optional trend plots for this sweep.""" results = results or self.results if results is None: raise RuntimeError("run() the study before plotting results") plot_dir = os.path.join(self.config.output_dir, self.config.output.plots_subdir) - return study_plots.plot_sweep_trends(results, plot_dir, + return study_runtime.study_plots_for(self.config, results, plot_dir, comparison=self.comparison) diff --git a/pyrpod/mdao/plume_validation.py b/pyrpod/mdao/plume_validation.py index 54711d8..395d3af 100644 --- a/pyrpod/mdao/plume_validation.py +++ b/pyrpod/mdao/plume_validation.py @@ -53,7 +53,11 @@ ) from pyrpod.mdao.study_config import StudyConfig, SweepPose from pyrpod.mdao.study_results import StudyResults -from pyrpod.mdao.study_runtime import CaseAssets, TargetGeometry +from pyrpod.mdao.study_runtime import ( + CaseAssets, + TargetGeometry, + study_plots_for, +) from pyrpod.util.io.fs import ensure_dir logger = logging.getLogger(__name__) @@ -247,18 +251,16 @@ def compare(self, dataset: ReferenceDataset) -> ComparisonReport: # ---------------------------------------------------------------- plots def plot(self, results: StudyResults | None = None) -> list[str]: - """Generate the optional parameter-sweep trend plots. + """Generate the optional trend plots for this study. Plot generation is entirely optional -- automated tests never require - it -- and is imported lazily so a headless run pays no matplotlib - cost unless plots were asked for. + it -- and the plotting modules are imported lazily so a headless run + pays no matplotlib cost unless plots were asked for. """ - from pyrpod.mdao import study_plots - results = results or self.results if results is None: raise RuntimeError("run() the study before plotting results") plot_dir = os.path.join(self.config.output_dir, self.config.output.plots_subdir) - return study_plots.plot_sweep_trends(results, plot_dir, - comparison=self.comparison) + return study_plots_for(self.config, results, plot_dir, + comparison=self.comparison) diff --git a/pyrpod/mdao/study_runtime.py b/pyrpod/mdao/study_runtime.py index 00fc2dd..c22f31d 100644 --- a/pyrpod/mdao/study_runtime.py +++ b/pyrpod/mdao/study_runtime.py @@ -61,6 +61,7 @@ "knudsen_metadata", "load_case_assets", "read_generated_jfh", + "study_plots_for", "study_provenance", "utc_timestamp", ] @@ -437,6 +438,58 @@ def build_case_results(config: StudyConfig, geometry: TargetGeometry, return records +def study_plots_for(config: StudyConfig, results: Any, out_dir: str, + comparison: Any = None) -> list[str]: + """Generate whichever optional figures this study's sweep calls for. + + Both engines plot identically, so the choice lives here rather than in + either of them (and never in the :class:`TradeStudy` façade): + + * the angle-sweep trends of :mod:`pyrpod.mdao.study_plots` are always + produced, plus the study-vs-reference figure when a comparison exists; + * an OFFSET sweep additionally gets the panel-local trends of + :mod:`pyrpod.mdao.panel_plots`; + * per-case panel pressure maps are drawn when + ``output.plots.per_case_distribution`` is set AND the distributions + they read were exported. + + Both plotting modules are imported lazily, so a study that asks for no + plots never imports matplotlib. + """ + from pyrpod.mdao import study_plots + + written = list(study_plots.plot_sweep_trends(results, out_dir, + comparison=comparison)) + + sweeps_offsets = (config.sweep.source_axis_mode == "parallel_to_normal" + or len(config.sweep.source_offsets_u) > 1 + or len(config.sweep.source_offsets_v) > 1) + if not sweeps_offsets: + return written + + from pyrpod.mdao import panel_plots + + written.extend(panel_plots.plot_offset_sweep_trends(results, out_dir)) + + if config.output.write_distribution_plots: + if not config.output.write_surface_distribution: + logger.warning( + "output.plots.per_case_distribution is set but " + "output.surface_distribution.enabled is not; the per-case " + "pressure maps are drawn from the exported distributions, " + "so none were produced") + return written + seen: set[str] = set() + for case in results.cases: + if case.case_id in seen: + continue + seen.add(case.case_id) + path = panel_plots.plot_panel_pressure_for_case(case, out_dir) + if path: + written.append(path) + return written + + def component_envelope(geometry: TargetGeometry, cumulative: dict[str, Any], ) -> dict[str, dict[str, float]]: From 1e79ca59890286b00d8bea0c3f6d198acfa51537 Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:41:05 -0500 Subject: [PATCH 06/10] case: add the ISS-representative solar-panel study case An idealized 22 m x 12 m flat panel standing in for one ISS solar-array wing, centered at the origin with its normal along +Z and struck by the verified Cai 2016 argon round jet (D = 1 m, S0 = 2.0, T0 = 200 K, Tw = 300 K, fully diffuse), with the source TRANSLATED parallel to the panel rather than re-aimed at its center. config.ini case assets and gating geometry stl/generate_panel.py parameterized plate mesher stl/iss_panel.stl committed 44 x 24 quads, 2112 faces tcd/tcf_1_argon.txt, tcd/tdf.csv single head-on argon thruster study/iss_panel_baseline_simplified.yaml study/iss_panel_baseline_full_cai.yaml study/iss_panel_offset_distance_sweep.yaml run.py runner for one study or all README.md conventions, commands, artifacts The two baselines differ in exactly one line -- plume_model.name -- so the pair isolates the far-field simplification: 2.824 N vs 2.802 N centered normal force, 0.3317 Pa vs 0.3136 Pa peak pressure. That the numbers differ at all is the evidence that model selection reaches the calculation. The sweep is 3 distances x 5 u offsets = 15 per_case cases, with distribution export, plots and the Kn metadata block enabled; it runs in about 20 s. Verified symmetric: zero moment and zero CoP offset at u = 0, equal-and-opposite moments at +/-u, and normal force falling at the edges as the footprint spills off the panel. README.md documents expanding the arrays and running one study per Kn label of 100, 10, 1, 0.1, 0.01. The mesher uses surfmesh (github.com/plume-kit/surfmesh) for the quad grid and splits each quad into two triangles. Since the STL is committed, that is a build-time dependency of the script alone -- neither PyRPOD nor the tests import it. Also moves the panel-pressure figure title to the figure so a drawn-to-scale wide panel does not run its title under the colorbar. Co-Authored-By: Claude Opus 5 --- case/plume/iss_panel_thesis/README.md | 215 ++++++++++++++++++ case/plume/iss_panel_thesis/config.ini | 88 +++++++ case/plume/iss_panel_thesis/run.py | 119 ++++++++++ .../iss_panel_thesis/stl/generate_panel.py | 127 +++++++++++ case/plume/iss_panel_thesis/stl/iss_panel.stl | Bin 0 -> 105684 bytes .../study/iss_panel_baseline_full_cai.yaml | 104 +++++++++ .../study/iss_panel_baseline_simplified.yaml | 103 +++++++++ .../iss_panel_offset_distance_sweep.yaml | 99 ++++++++ .../iss_panel_thesis/tcd/tcf_1_argon.txt | 6 + case/plume/iss_panel_thesis/tcd/tdf.csv | 2 + pyrpod/mdao/panel_plots.py | 11 +- 11 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 case/plume/iss_panel_thesis/README.md create mode 100644 case/plume/iss_panel_thesis/config.ini create mode 100644 case/plume/iss_panel_thesis/run.py create mode 100644 case/plume/iss_panel_thesis/stl/generate_panel.py create mode 100644 case/plume/iss_panel_thesis/stl/iss_panel.stl create mode 100644 case/plume/iss_panel_thesis/study/iss_panel_baseline_full_cai.yaml create mode 100644 case/plume/iss_panel_thesis/study/iss_panel_baseline_simplified.yaml create mode 100644 case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml create mode 100644 case/plume/iss_panel_thesis/tcd/tcf_1_argon.txt create mode 100644 case/plume/iss_panel_thesis/tcd/tdf.csv diff --git a/case/plume/iss_panel_thesis/README.md b/case/plume/iss_panel_thesis/README.md new file mode 100644 index 0000000..1cdfd51 --- /dev/null +++ b/case/plume/iss_panel_thesis/README.md @@ -0,0 +1,215 @@ +# ISS-representative solar-panel plume impingement + +An idealized **22 m x 12 m flat panel**, standing in for one ISS solar-array +wing, struck by a single argon round jet whose source is **translated +parallel to the panel**. The case generates reproducible analytical PyRPOD +datasets for the collisionless Cai plume models. + +> **No DSMC.** These studies produce analytical PyRPOD data only. Nothing in +> this case launches OpenFOAM, reads or writes DSMC fields, or applies a +> collisional correction. Comparing these datasets with externally generated +> DSMC results is a **separate workflow**. + +## Geometry and conventions + +| | | +|---|---| +| Panel | 22 m (u) x 12 m (v), geometric center at the origin | +| Panel normal `n` | global **+Z**, pointing toward the plume source | +| Longitudinal `u` | global **+X**, the 22 m dimension (`target.tangent`) | +| Transverse `v` | global **+Y**, the 12 m dimension (`n x u`, derived) | +| Moment reference | the panel center, `[0, 0, 0]` | +| Thruster | argon round jet, D = 1 m, S0 = 2.0, T0 = 200 K, Tw = 300 K, fully diffuse | +| Committed mesh | 44 x 24 quads -> **2112 triangles**, 0.5 m elements | + +The plume source is placed by + +``` +source_position = panel_center + L*n + u_offset*u + v_offset*v +plume axis = -n (fixed, never re-aimed) +``` + +so the plume centerline meets the panel exactly at `(u_offset, v_offset)`. +This is `sweep.source_axis_mode: parallel_to_normal`. It is **not** the same +experiment as moving the source while continuously aiming it at the panel +center — that is the default `aim_at_reference` mode, which the existing +flat-plate studies use. + +**Sign conventions.** `normal_force = -F.n` is **positive when the load +presses into the panel** (away from the source). `local_moment_u/v/n` are the +global moment vector projected on `(u, v, n)` about the panel center; a +source displaced toward `+u` gives a **positive** `local_moment_v`. +`center_of_pressure_u/v` are measured **from the panel center**. + +## Studies + +| Command | Config | What it is | +|---|---|---| +| `baseline-simplified` | `study/iss_panel_baseline_simplified.yaml` | Centered source, L = 4 m, `SimplifiedGasKinetics` | +| `baseline-full-cai` | `study/iss_panel_baseline_full_cai.yaml` | The same case with `CollisionlessGasKinetics` | +| `sweep` | `study/iss_panel_offset_distance_sweep.yaml` | 3 distances x 5 u offsets = 15 cases | + +The two baselines differ in **exactly one line** — `plume_model.name` — so +the pair isolates the far-field simplification. On the committed mesh they +give a centered normal force of 2.824 N (Simplified) against 2.802 N (full +Cai), with peak pressures of 0.3317 Pa and 0.3136 Pa. + +## Exact commands + +Run from the repository root. Each command writes into the study's own +configured output directory under `results/` (gitignored). + +```bash +# List the available studies +python case/plume/iss_panel_thesis/run.py + +# 1. Simplified baseline (seconds) +python case/plume/iss_panel_thesis/run.py baseline-simplified + +# 2. Full collisionless Cai baseline (~20 s on the committed mesh) +python case/plume/iss_panel_thesis/run.py baseline-full-cai + +# 3. Distance/offset sweep, 15 cases (~20 s) +python case/plume/iss_panel_thesis/run.py sweep + +# Everything, in that order +python case/plume/iss_panel_thesis/run.py all + +# Run into a scratch directory instead, and watch progress +python case/plume/iss_panel_thesis/run.py sweep \ + --output-dir /tmp/iss_panel --verbose +``` + +Plots are generated by default (both YAML files set `output.plots.enabled` +and `per_case_distribution`). To run without them: + +```bash +python case/plume/iss_panel_thesis/run.py sweep --no-plots +``` + +To generate plots **separately** from an already-run study — or from Python +generally: + +```python +from pyrpod.mdao.TradeStudy import TradeStudy + +study = TradeStudy.from_config( + 'case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml') +results = study.run() +paths = study.plot() # returns every figure path written +``` + +## Where the outputs land + +For the sweep (`results/studies/offset_distance_sweep/`): + +``` +jfh/case000_modelSimplified_L2_um10_v0.A prescribed JFH, one per case +cases/case000_modelSimplified_L2_um10_v0/ + results/strikes/firing-0.vtu per-face VTK (PRIMARY output) + distributions/case000_..._panel_firing001.csv panel-local distribution CSV + distributions/case000_..._panel_firing001.meta.json units + case metadata +sweep_results.csv one flat row per case x component x firing +sweep_metadata.json provenance + nested case records +plots/normal_force_vs_offset_u.png sweep trends +plots/moment_v_vs_offset_u.png +plots/peak_pressure_vs_offset_u.png +plots/cop_u_vs_offset_u.png +plots/normal_force_vs_distance.png +plots/panel_pressure_case000_....png per-case pressure map +``` + +The baselines use the same layout with `case_results.csv` / +`study_metadata.json`. `CaseResult.vtk_path` and +`CaseResult.surface_distribution_path` record the per-case artifact paths, so +the summary CSV points at everything else. + +**The VTK files remain the primary full-resolution output.** The +distribution CSVs carry the *same* native per-face values in panel-local +`(u, v)` coordinates — no interpolation, no resampling, no structured grid — +for plotting and for a later comparison workflow that should not need a VTK +reader. Columns: `face_index, centroid_x/y/z, local_u, local_v, area, +pressure, shear_stress, heat_flux, strike_count`; units and case metadata are +in the sidecar `.meta.json`. + +## Regenerating the mesh + +The STL is committed, so nothing at run time needs a mesher. To change the +resolution: + +```bash +cd case/plume/iss_panel_thesis/stl +python generate_panel.py # committed default, 2112 faces +python generate_panel.py --n-u 88 --n-v 48 # 0.25 m elements, 8448 faces +``` + +The generator uses [`surfmesh`](https://github.com/plume-kit/surfmesh) for the +quad grid (`pip install surfmesh`). It is a **build-time dependency of that +script only** — PyRPOD and the automated tests never import it. + +Raising the resolution costs linearly in struck faces. The full Cai model is +roughly 600x more expensive **per struck face** than the simplified one +(quadrature over the nozzle exit disk versus a closed form), so a +research-grade full-Cai sweep is the combination to size carefully. + +## Expanding the sweep + +The committed sweep is deliberately small enough to run locally. To grow it, +edit `study/iss_panel_offset_distance_sweep.yaml`: + +```yaml +sweep: + source_distances: [1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0] + source_offsets_u: [-10.0, -8.0, -6.0, -4.0, -2.0, 0.0, + 2.0, 4.0, 6.0, 8.0, 10.0] + source_offsets_v: [0.0, 2.0, 4.0] # transverse offsets too +``` + +The case count is exactly `n_distances x n_u_offsets x n_v_offsets`. Swept +`v` offsets automatically add the transverse trend figures. + +To compare both models over the same grid, copy the file, change +`plume_model.name` and `study.name`, run both, and concatenate the two +summary CSVs — every trend figure groups its series by distance **and** +model. + +### Knudsen labels + +**Kn in PyRPOD is derived metadata, not a collisional model input.** The +plume models are collisionless; nothing in the solution reads Kn back. The +block exists so an analytical case can be *labelled* with the rarefaction +regime it is meant to represent, for a later, separate comparison workflow. + +The committed sweep uses the swept stand-off as the reference length: + +```yaml +knudsen: + mean_free_path_m: 1.0 + reference_length: source_distance + definition: lambda_over_source_distance +``` + +so `Kn = 1/L` varies across the sweep (0.5, 0.25, 0.1667 at L = 2, 4, 6 m). + +For a fixed reference length — one study **per Kn label** — use the explicit +mode instead. With the nozzle diameter D = 1 m as the reference, copy the +sweep YAML five times and set `mean_free_path_m` to `100`, `10`, `1`, `0.1` +and `0.01` to obtain **Kn = 100, 10, 1, 0.1, 0.01**: + +```yaml +knudsen: + mean_free_path_m: 0.1 # -> Kn = 0.1 + reference_length_m: 1.0 # nozzle diameter D + definition: lambda_over_nozzle_diameter +``` + +Give each copy its own `study.name` and `output_dir`. Exactly one +reference-length mode may be given; supplying both, or neither, is a +configuration error. + +## See also + +- `docs/plume_validation_study.md` — the study framework, the YAML schema, + the axis modes, the Knudsen block and the result schema. +- `case/plume/plume_flat_plate_sweep/` — the Cai 2016 verification case whose + thruster conditions this case reuses, and the `aim_at_reference` studies. diff --git a/case/plume/iss_panel_thesis/config.ini b/case/plume/iss_panel_thesis/config.ini new file mode 100644 index 0000000..9c59bf1 --- /dev/null +++ b/case/plume/iss_panel_thesis/config.ini @@ -0,0 +1,88 @@ +# ISS-representative solar-panel plume-impingement case. +# +# An idealized 22 m x 12 m flat panel standing in for one ISS solar-array +# wing, centered at the origin in the X-Y plane with its normal along +Z. +# A single argon round jet (D = 1 m, S0 = 2.0, T0 = 200 K, Tw = 300 K, fully +# diffuse -- the Cai 2016 Section-4 conditions, reused so the thruster is a +# verified one) stands off on the +Z side and is TRANSLATED parallel to the +# panel by the study layer. +# +# Panel-local axes used throughout the studies in study/: +# u = global +X, longitudinal, the 22 m dimension +# v = global +Y, transverse, the 12 m dimension +# n = global +Z, panel normal, pointing toward the plume source +# +# Assets are generated by stl/generate_panel.py; the study YAML files in +# study/ add the sweep, the plume-model selection and the Knudsen metadata +# on top of this file, which keeps owning every case asset. +# +# See case/plume/iss_panel_thesis/README.md and +# docs/plume_validation_study.md. + +# Visiting Vehicle for RPOD analysis +[vv] +stl_lm = cylinder_transformed.stl +stl_thruster = mold_funnel_transformed.stl + +# Target Vehicle for RPOD analysis +[tv] +stl = iss_panel.stl + +# surface wall temperature (Kelvin): Tw/T0 = 1.5 +surface_temp = 300 + +# proportion of diffuse particle reflections [0, 1] +sigma = 1 + +# check plume constraints? 0 or 1 +check_constraints = 0 + +# max heat flux integral (J/m^2) +heat_flux_load = inf +heat_flux_window_size = 1 + +# max heat flux rate (W/m^2) +heat_flux = inf + +# max pressure load +normal_pressure_load = inf +normal_pressure_window_size = inf + +# max normal pressure (N/m^2) +normal_pressure = inf + +# max shear pressure (N/m^2) +shear_pressure = inf + +# Plume kinetics and interactions models +[pm] +# Gas kinetics model. 'Simplified' -> SimplifiedGasKinetics, +# 'Collisionless' -> CollisionlessGasKinetics (the full Cai & Wang 2012 +# model). A study's plume_model.name selects the model for its own run +# without modifying this file; this value is the case default. +kinetics = Simplified + +# Gas-surface interaction model +surface_interaction = Maxwellian + +# Jet Firing History +[jfh] +# Placeholder: every study in study/ GENERATES its own prescribed firing +# history into its own output directory. +jfh = generated_by_study.A + +# Thruster configuration data. +[tcd] +# Thruster Configuration File - single head-on thruster, no cant. +tcf = tcf_1_argon.txt + +# Thruster Definition File - argon at the Cai 2016 Section-4 conditions. +tdf = tdf.csv + +# Parameters for scaling plume geometry. +[plume] +# Gating geometry, sized so the wedge and radius never clip the panel at any +# swept pose. The farthest panel corner from the most offset source +# (L = 2 m, u = -9 m) sits ~21 m away and ~84 deg off-axis, so: +radius = 40 +wedge_theta = 1.55 diff --git a/case/plume/iss_panel_thesis/run.py b/case/plume/iss_panel_thesis/run.py new file mode 100644 index 0000000..37940b0 --- /dev/null +++ b/case/plume/iss_panel_thesis/run.py @@ -0,0 +1,119 @@ +"""Run the ISS-representative solar-panel plume-impingement studies. + +Each study is a YAML file in ``study/`` layered on this case's +``config.ini``; running one is the ordinary package-level call + + TradeStudy.from_config().run() + +so this script only picks the file, optionally redirects the output, and +prints where the artifacts landed. + +Usage +----- + python run.py # list the available studies + python run.py baseline-simplified # centered source, Simplified model + python run.py baseline-full-cai # centered source, full Cai model + python run.py sweep # 3 distances x 5 u offsets + python run.py all # every study, in that order + + python run.py sweep --output-dir /tmp/scratch # run elsewhere + python run.py sweep --no-plots # skip the figures + +Nothing here handles DSMC: these studies produce ANALYTICAL PyRPOD datasets +only. Comparing them with externally generated DSMC results is a separate +workflow. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +CASE_DIR = Path(__file__).resolve().parent +# Allow `python run.py` from anywhere without installing the package. +sys.path.insert(0, str(CASE_DIR.parents[2])) + +from pyrpod.mdao.TradeStudy import TradeStudy # noqa: E402 + +#: Study name -> configuration file, in the order `all` runs them. +STUDIES: dict[str, str] = { + "baseline-simplified": "iss_panel_baseline_simplified.yaml", + "baseline-full-cai": "iss_panel_baseline_full_cai.yaml", + "sweep": "iss_panel_offset_distance_sweep.yaml", +} + + +def run_study(name: str, output_dir: str | None = None, + plots: bool = True) -> None: + """Run one named study and report its artifacts.""" + config_path = CASE_DIR / "study" / STUDIES[name] + print(f"\n=== {name}: {config_path.name} ===") + + study = TradeStudy.from_config(config_path, output_dir=output_dir) + results = study.run() + + print(f"cases : {len(results)} records " + f"({study.study_config.n_cases} swept cases)") + print(f"summary CSV : {results.summary_csv_path}") + print(f"metadata JSON: {results.metadata_path}") + + first = results.cases[0] + print(f"model : {first.plume_model}") + print(f"normal force : {first.normal_force:.4g} N " + "(positive = pressed into the panel)") + print(f"peak pressure: {first.max_pressure:.4g} Pa") + if first.knudsen_number is not None: + print(f"Kn (metadata): {first.knudsen_number:.4g} " + f"[{first.knudsen_definition}]") + if first.surface_distribution_path: + print(f"distribution : {first.surface_distribution_path}") + if first.vtk_path: + print(f"VTK : {first.vtk_path}") + + if plots: + written = study.plot() + print(f"plots : {len(written)} figures") + for path in written[:6]: + print(f" {path}") + if len(written) > 6: + print(f" ... and {len(written) - 6} more") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("study", nargs="?", default=None, + choices=[*STUDIES, "all"], + help="which study to run; omit to list them") + parser.add_argument("--output-dir", default=None, + help="override the configured output directory") + parser.add_argument("--no-plots", action="store_true", + help="skip figure generation") + parser.add_argument("--verbose", action="store_true", + help="log study progress at INFO level") + args = parser.parse_args(argv) + + if args.verbose: + logging.basicConfig(level=logging.INFO, + format="%(levelname)s %(name)s: %(message)s") + + if args.study is None: + print(__doc__) + print("Available studies:") + for name, filename in STUDIES.items(): + print(f" {name:20s} study/{filename}") + return 0 + + names = list(STUDIES) if args.study == "all" else [args.study] + for name in names: + # With several studies and one --output-dir, keep them apart. + output_dir = (f"{args.output_dir}/{name}" + if args.output_dir and len(names) > 1 + else args.output_dir) + run_study(name, output_dir=output_dir, plots=not args.no_plots) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/case/plume/iss_panel_thesis/stl/generate_panel.py b/case/plume/iss_panel_thesis/stl/generate_panel.py new file mode 100644 index 0000000..6598333 --- /dev/null +++ b/case/plume/iss_panel_thesis/stl/generate_panel.py @@ -0,0 +1,127 @@ +"""Generate the ISS-representative solar-panel target mesh. + +Builds a single-sided, uniformly triangulated rectangular plate standing in +for one ISS solar-array wing: + + length 22 m along the panel-local u (longitudinal) axis -> global X + width 12 m along the panel-local v (transverse) axis -> global Y + normal +Z, geometric center at the origin + +The plume source stands off on the +Z side and, in the study's +``parallel_to_normal`` axis mode, is translated parallel to the panel, so +every face normal must point back toward the source (+Z). The strike +pipeline's facing test (``surface_dot_plume < 0``) requires exactly that; +the script asserts it before saving. + +Meshing +------- +The quad grid comes from `surfmesh `_ +(``quad_faces_from_edges`` + ``convert_2d_face_to_3d``), and each quad is +split into the two triangles an STL needs. surfmesh is a build-time +dependency of THIS SCRIPT only: the generated STL is committed, so neither +PyRPOD nor the automated tests import it. + +Resolution is parametrized. The committed default is 44 x 24 quads +(0.5 m elements, 2112 triangles) -- fine enough to resolve the impingement +footprint of a plume standing off a few metres, coarse enough that a full +study runs locally in seconds to a couple of minutes. Research-grade runs +should raise ``--n-u`` / ``--n-v`` and regenerate. + +Run from this directory: python generate_panel.py +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +from stl import mesh + +#: ISS-representative solar-array wing dimensions (m). +PANEL_LENGTH_U = 22.0 +PANEL_WIDTH_V = 12.0 + +#: Committed mesh resolution: quads per axis (triangles = 2 * n_u * n_v). +DEFAULT_N_U = 44 +DEFAULT_N_V = 24 + + +def build_panel_mesh(length_u: float = PANEL_LENGTH_U, + width_v: float = PANEL_WIDTH_V, + n_u: int = DEFAULT_N_U, n_v: int = DEFAULT_N_V, + center: tuple[float, float, float] = (0.0, 0.0, 0.0), + ) -> mesh.Mesh: + """Return a numpy-stl Mesh of the flat rectangular panel. + + Parameters + ---------- + length_u : float + Panel length along the panel-local u axis (global X), in m. + width_v : float + Panel width along the panel-local v axis (global Y), in m. + n_u, n_v : int + Quad divisions per axis; the mesh holds ``2 * n_u * n_v`` triangles. + center : tuple of float + Panel geometric center in the global frame. + + Returns + ------- + stl.mesh.Mesh + Triangulated panel whose every face normal is +Z. + """ + if n_u < 1 or n_v < 1: + raise ValueError(f"n_u and n_v must be >= 1, got {n_u}, {n_v}") + if length_u <= 0.0 or width_v <= 0.0: + raise ValueError( + f"panel dimensions must be positive, got {length_u} x {width_v}") + + from surfmesh import convert_2d_face_to_3d, quad_faces_from_edges + + center = np.asarray(center, dtype=float) + u_edges = np.linspace(-length_u / 2.0, length_u / 2.0, n_u + 1) + v_edges = np.linspace(-width_v / 2.0, width_v / 2.0, n_v + 1) + + # surfmesh emits counter-clockwise quads in the (u, v) plane; lifting + # them to Z = center_z keeps that winding, so the triangle normals come + # out along +Z without any post-hoc flipping. + quads = convert_2d_face_to_3d(quad_faces_from_edges(u_edges, v_edges), + axis=2, offset=float(center[2])) + quads[:, :, 0] += center[0] + quads[:, :, 1] += center[1] + + data = np.zeros(2 * len(quads), dtype=mesh.Mesh.dtype) + # Split each counter-clockwise quad (p0, p1, p2, p3) into the triangles + # (p0, p1, p2) and (p0, p2, p3), preserving the winding. + data['vectors'][0::2] = quads[:, [0, 1, 2], :] + data['vectors'][1::2] = quads[:, [0, 2, 3], :] + + panel = mesh.Mesh(data) + panel.update_normals() + unit_normals = panel.get_unit_normals() + assert np.allclose(unit_normals, [0.0, 0.0, 1.0], atol=1e-9), ( + 'face normals must all point toward the plume source (+Z); the ' + 'strike pipeline only loads faces whose normal opposes the plume') + return panel + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument('--length-u', type=float, default=PANEL_LENGTH_U, + help='panel length along u / global X (m)') + parser.add_argument('--width-v', type=float, default=PANEL_WIDTH_V, + help='panel width along v / global Y (m)') + parser.add_argument('--n-u', type=int, default=DEFAULT_N_U, + help='quad divisions along u') + parser.add_argument('--n-v', type=int, default=DEFAULT_N_V, + help='quad divisions along v') + parser.add_argument('--out', type=str, default='iss_panel.stl') + args = parser.parse_args() + + panel = build_panel_mesh(length_u=args.length_u, width_v=args.width_v, + n_u=args.n_u, n_v=args.n_v) + out_path = Path(__file__).resolve().parent / args.out + panel.save(str(out_path)) + print(f'saved {out_path} ({len(panel.vectors)} faces, ' + f'{args.length_u} m x {args.width_v} m, ' + f'{args.n_u} x {args.n_v} quads)') diff --git a/case/plume/iss_panel_thesis/stl/iss_panel.stl b/case/plume/iss_panel_thesis/stl/iss_panel.stl new file mode 100644 index 0000000000000000000000000000000000000000..b95bc1595e1ea7d82122a951edbd2ac009b567c4 GIT binary patch literal 105684 zcma*JF_JS&lVz7;izAK*u_<)h&*lt`GXlTlN(c%RXf_-hvnB;CON2_&@&jU;o$t_P_nt|NX!G zvxM~Q|FGFP{^xD`+yA?5t(|ZF<2U|X?@uK&NNE#%H<+ly`CWEoDRtBa6LmPh%8o3h zj@n?N4(Frn$WrR44JPVvewH0sN*%SqL>fcpqu&aMpSE}>!J?X{x zu98&msEupldprO6u8a;hu2k=+jcek2#mBDVWSZOnk5S zC_CIBr4IPs!Nm89UuB0Iq|^c5JDB)h@w@DBgOobpdj}KWEB=%nZje$3eD7f5d&RBn zaD$XO;I*UgYUg_orHnrppHn^RxBa_8s^4L0kWz>9`&R}jb-=ox^Ke&8)ZzSUBzevZ zQtE)o&ri-o9nMGDk)_m88%)&U{46`NlsamIi8`G3vLj2Wqc)hR!}(EmWGQvj1`~BS zZ)Hc8Qb%ntQHOK;ir35G_bN-Nqc)hR!}%yXvXnY%gNZtvx3VKk_3x@Q*ww$QE7f`V zp7dgTS4paO)W$XOy`BGjS4M{$SE_f^#x?Q1;^S8ass8gQ4N|^W%-=-w)9^XmAf*oY zZZPq^;%%SVljv}RRR69@gOu+TKYnG9QU^Tmjk{vvd&PU%;RY#n!1oR&zE}J#JKP|p z4*1@|#P^DivcnBh>VWSZOnk5SRd%>RN*(aMgNg4Izsn9cNT~z9cQEn2;!oM(1}SyG z_YNk$SKP`DH%O@iUOW1(cE0!Tl`{TZd`|VK-}dhYseXs0K}sFY?_U|D)B)>$&cj_X zQHS%Zk>oiuNT~xRKR-DWbvPeoN0w4YZ7@-X^Rw*8QtGG;ChBnB%Z@Cij@n?N4(CVN zk)_m88%)&Uyp$->WR8j@n?N4(Frn$WrR44JPVv-pYkYN*(aLH|~mw?-lQ5ha05S0pB~A z_+Ig|>~MpWI^cT;6W=R7$__V3sRO=uF!8G|$-#eK2UU4fs+#sb6cr@j2C_e%rqrr1~9}1}Swozkg+r zQU|R2IS+TmL>B8&?@2Glca@}iM{Qga-`n}mcV%?Aaiw}kZCn%ID?WZ@km^5= z(jet~#r%acKMkM54N~fW?*VWSZOnk5SC_CIBr4IPs!Nm89UuB0Iq|^c5JDB)h@w@DB zgOobpdj}KWEB=%nZje$3eD7f5d&RBnaD$XO;I*UgYUg|Zpp^0F;&ZA;{kDHMNcB4` z4N~fGe*elKr4Cs4a~|%Bi8`ELjU>;RK}sDk`T5D2sKfavJF=8IYJ-V7oS$V!mQqJ; zFj0r|UUpQtGG;ChBl*U-5c5{9a`#b<_qEbvPeo zN0w4YZ7@-X^Hz3bss3G+2D|!qb)`Bl-;-X9?G|$-wh_dSG?^rdlDUPkm}!6X^`^0;>WKHQtE)` zy>VAee6M&fJKP|p4*1@|#P^DyWrrK2)B)c+nD}1tQFgdNN*(aMgNg4Izse3bNT~z9 zcQEn2;&<8M1}SyG_YNk$SNthE+#sb6_};<9_ljHD;RY#nz-vd})vouHGXB8V`208V zyFseoVQG-s^l3>2zu{X)mQqJ;jESd$UuB0Iq|^bAo9h2JSSFqdKFW@<%Tn4@8)M?B z;Ah$41}SyG_s=mVo(kT}4mU`t1HN~RiKl`eWrrK2)B)c+#>7*>TiM|TDRsd2jxq66 zaQm9slk|UsWtXM&er=42r-F~N!wpjEfakq&S7YL-;H~U%gH-=~r9n#noi99Z=OXSZ zOZAT0V4@C=_zy359v!@4soqf=Ow7<{I(VOr<6nl+ZmItBD2=hp40W_$8Kl$!kDD5I zH71@4-u9V2iSNn{QvJIs4O0DQPAWPc@L3R#bhtrE9qG|$-#f;{Q^7~s;RY#n!1s~MpWI^cW9n0PAqU3R!ZN*(aMV@y01 z{3$!!Af*oY-Z3Vg3T|bG8>G|$uO0or=XSpL_jqo`pNr3_9`)bHcVnb; zd@&fR<93E|O?+?XKi`$qAf*m?h{L!hzE^zw$`~pAJW7L!?-jRS86y=PgKwXMiSHF} z`-on^CwF6{^j(z(6W=R-{K^=q=olPz=i#oH_+Ih8U%~N|86y=PgKq{C-z$FZBYFWH zZj4lP3?3o|6W=R7_7S}xGe#;p1`iQ~iSHG^_7S}xGe#;p1`iQ~iSHG^_Yu7yGe#;p z1`iQ~iSHGE_7S}xGe#;p1`iQ~iSHG+eMB$FjFF0t!9$eMceV4azv8(We=a_!ddv(` z+63PXChBm0$Lsabk){3~>DpkT4(C_AUJo5vN*%SqL>#YaDK+? z_0W-})KMEu{<4&Kk83@2xIs!C@U$?P{ADTeBd+yi1}SyG)52i#m!-s;uH|F~DRsb8 z!eH{3rNr%PW>3ltQtE)Ghrz`6ijT6x4N~fW?;T8huXrmv+#uDztI{CldppnDxrk4` z7}ufZVB&kl$3C+sWd9%$}4Pq|^aV4}*#C z6@T`bJt;FtsRN!K1{2>aZu`uhlo_Pd0Z&gx-xX7xe`}G7OGo^PYdx7ks^4L0kn+9a z_pc06>VUU#xGN^USNw`=J()pD9q_a;nEaci#7A7~$qZ8JfTxAQ#P^DyajhpaNc|OQ zcv=`te6M(qYdx7k>aR${)52ild&Q5q){_~e{)#j_Ees~USG?(3PG*q$t3_1Taxj?q zUUBkYN*(Yv4tK@G_loy@L@&q;QtE)Ghrz`6il6(;o|GA+)B#TqgNg4IAN$On zlo_Pd0Z$KuiSHG^_L)5?Gf1feo*o7h-z$FaGka2IkWvRcJq#wkSNz#$_N2@pr4D#{ z7)*Sxxa~80Qf81+2RuC)eOF9%KJ9!B>-BmvgH*r6(jcX0=l8D+QtE)Wakwic+VA{| z*Xzj)QtE)Gg~8-wDe)24dNPBQI^bzxF!8a-gGS|Gf1@-QC-WyVB&kl?Q4n0i_9RU4tRPPOnk5S zC_CIBr4IPs!Nm89x3a?xQtE(%uEXbG;(I$29M7q7O?$^k0>il8@V%YWzhr!ur~3P~ zaZTJ1AHOn4sRQOy#5>o&yG(qqxRo7lkWvSH?_lD4#oIo!CvjJ9km}!6X^`^0;>WKH zQtE)WakwiczE`~OBYHt*kWvRcJq#wkSNz;(_N2@pr4D#{7)*Sx_}FLmq|6|t4tRPP zOnk5Swa@HHnL$b&@boa4_+IgQpV^Z#gOobp>0vPOz2eV4vnOQ+DRscp!(ifj#ciM2 zlQM&pI^gNa=(}R7^KOxfOGm8PdNPBQp5bX>F!8-&UF*pVQrZGf3xkQConP^KJ()pD z9q_a;m}tNA5wF*i8Kl$!PYZ*|+fw3ZT3ltQtE)Ghrz`6ijT6x4N~fW z?;T8huXrmv+#uDztI{CldppnDxrk4`7}ufZVB&kl$3C+sWd9%$}4Pq|^aV4}*#C6@T`bJt;FtsRN!K1{2>aZu`uhlo_Pd0Z&gx z-xX7xSBq3!I$~Mt$qZ8c4oicS?-k2h4;^lh(lb0Q3?{x;tZO})K}uWTX<;zYv-1(J z*OM8f)B#TmgNgP#KjZa!GK17R((tq}n5e^fkJszT3{vk%!_&fGq7LUryk1Xcka|ZN zo)!iZbvSSFdOevz>aDb{0V!`SE_f^#x?Q1;^S8ass8gQ4N|^W z%wIV3)9^XmAf*oYZZPq^;%%SVljv}RRR69@gOu+TKYnG9QU|<^!(B1)z2bcz(F-zz zlse$)VKDK%;^#iICuIgHb->fZVB&kl$3C+sWd9%$}4Pq|^aV4}*#C6@T`bJt;FtsRN!K1{2>aZu`uhlo_Pd0Z&gx-xX7xqeUt% z9q}iw^<)MqZGxwT!Nm89-*K%cGf1feo)!j^!%|{d>&XmKdWNTk!Nm89b*(2eNNEc^ zEes}lc7Dd|^<)Mqb->fYU~*VWyvMbk%pj!>cv=`t4oit2ajhpaNT~y!76y~UQsPb5 zax#OII^Zc`FgYwGZeKHdQf81+2RuCtCcamElpSu6QU`qRVB&klTiM|Tss3G+1}Wd$ zdEU-NeDcM(X1${}*wALAcl?>JPZ}N9z*D`WHm->q;^S8ass8gQ4N|^W%wIUyO~(_T z!wpjEfbRwq-z(nsnLUXPH%RsGsx(OXUh(5s1}SyG+c?}66W=S|_Yu7yGf1feo*o7h z-z$FZGka2IkWvRcJq#wkSA6U0vPOz2dgd>`9qHN*(a@Wb|Dz)p@o^#ib+u#I>Hx zAf-+4v@n?XUhzAw^<)OABhv7+Fqk|oC4R-Vp3ES1L>itJ1{2>ambISDAf;z`S{O{8 zmJ;h)PiBxhA`MRqgNdG<_jtXY%pi3{8lDyglc%M`kGR&88KjO#!_&fG^0btA)3uz; zAa%5e>RJv4lV^*ut~lcM)zKa@gVgb(LCW`vkFvuJQtE)`ZE;sje6M&bJKP|p4mjvK zbTIL~oe7TT)L>g1F@s@T@A=-&>0dI3?ebKAzc#Lk8{*?v1}SyG*GczQDWu9)iVEmCpmh(B?y zCo@QC6Fe;pCcanvj%z)cLFyT4cv=`tc1ww0ajhpaNIfGBPYZ*I?-d_$ttT@`JtGZI z3xmmSDe*I|^<)MqJ;T$&V6t0EtZO})LFyT4cv=`t^z8hI*Xzj)QqM@k)52h~TS~m? zT25w=dbWt_S`G%2y~S8p9C7>VXb+h|>iMHV%J+(ovcnBh>VW5MaaT-yuXrmv+#sb6 zIOsZbF!8;e36AH~xTd{hB!OXEZ}{HM>E9*3%TxXR+PEfeh>u?xq|^cPDdL^$-(4oY zSKP`DH%O@izIQP3z2a@3*^{^{H%O@io*o7h-z$FXGka2IkWvRcJq#wkSG?~tds1eQ zQU^Re3?{x;{M={uq|6|t4tRPPOnk5S*k|^n%pj!>czPI2e6RSm&+JK=K}sF)^e~wC zUh#XM*^@GZlse$)VKDK%;?F*_CuIgHb->fZVB&klZJ*hbGJ}*l;OWWeyJD*IXpxFb zNBoIvJ()pDo8W0-F!8^c*0r9@Ahkyto)!iZ zJv(pldOevzYFAps~@p2S_bK}sF)^e~wCUh!j}*^@GZlse$)VKDK%;(edl zlQM&pI^gMHF!8rf1}SyG)5Bomd&RGPW>3ltQtE)G zhrz`6ir@Rpo|GA+)B#TqgNg4IfA*O@DKki^1D+lR6W=Rt`^=t{8Kl$!PfteQ71Ms= zxy98_Jb&U^PiBzPCU{yHW8$gccUJe#pS{P&EsbE>_$qZ6@hNp!w zCY}o3bS)<{NcAmkiN^~JQvI1!Jh!jk7^KtzPmhi<@l^0pcDO-G9q_$lOgt65l^t%7 zQU@G#9X`jHv>32gd^@RerFutgFlj(Mi~rc`j-!J?3%F9fqc)hBp^k(1W6^m=2a`5a z>Zpw|@lywzJDq!P_3!ax#Nd|E~5W9xpIR>HBeh?D2XznL$b& z@RTsd#8bihKC>rf1}SyG)591OPX#~snLQ~pNT~y!9>$n>D)`uE_N2@pr4D#{7-Qn8 z;MYF0CuIgHb->fZ7!ywgzxSCvDKki^1D+nnn0PAqv(M~FnL$b&@boao#8bg-pV^Z# zgOobp>B-nMiA$$_t^8^IZF|U!k&5)-;b1UPhx0qE^<>6KMSAeiFqo*r`4!fBGGnA7 zJ$PssOw{3ggteZ`7^z4P9vTJ{bvQr6T2E$-RHO$F4TFg~ocFNSlNlow>A^$8V4@D^ zM_B90jFF1;;GtnKQHS#u)_O8yq?FdR91JGvaPF`Fe~po9=YL^~(ElH$ouu^ae5i9d z+*O)t@f%YbOk_H5zcNNDItGtBdprN}v^X_LwFtlp z_cM%Z;(Nv8FC2u~Va7ZlDS>TvE>yfqKMyID%<+F){6N~~)=bhtrE@4?f;V4`Q|t?bBBP3h8L(#3w(z!x$4!1;5JnN`utqdxIap zGDxWdp7+LGjftm%_p-wcQtE*39b@9D;Ah$41}SyG_l_~~RPa%DxIs!C@V#S9JQe&Z zJKP|p4*1?NCY}m@mmO}9QU`qR7!ywgf65LwNT~z9cZ`Xrf?L_)1}SyGYsaQZo9{V) z?EN#L-}djuNa+qs!za$~Ul}769fQwXibw9sC(f_3BTYp|ZTQ6bC_Bj zN@MJb4(Ik2ub0E`Zk4KyF^LZ6qwJ_swJ|2q;k=a{WvVpBuIN~J`JVJ*d{^v}@#7f= zDL2Iax@S_uQ_icwM9;5m5TRkgUMkj@w@DBW2AmGNcmpztL$)Nq<%C=`Cjo+cDON8KN_Tb zulQMZxG_>c8l-%$crQEL7^xo(QodLGC_CI3sUHnezE`}J9d3-&j|M5P zQhJ6**D$V$?-lpgBQ3~`k^0df<$J~L>Yg=pxG_>c8l+@8Z~NSS5*=wucT^fo)ZzU2 zl`&Fb4~|9W;jWmd!+GCH@|+nX74~3+@e^dC4(Dgtk*30~4JPVvKFW?X6?SbfQHS%Z z>_}5#*9H@HIKRt|G!=GjFj0r|r|d{mVb=x|bvU=OBTa>!8`|62)=&G!pX>c|sz?3q zem6+TrJl${?iyzOhl{CQ-lLw8gfOuBic#E)MY zq|^aN+jaQlOnk3+-%0YE8Kl$!lb@fQiSHFZ%MLe4sRO=uF!8fZ7?a&n!Ou3cCuIgHb->fZ7?a&n!F!w8lQM&pI^gMHjLB}P z;76O;lQM&pI^gMHjLB}P;H|~AoXjAl4tPo!W3pQ+xP8s+Ntr=P9q{xp#>DpqA7zId zq|^c5JI2KK25)7D8>ISoRT`u=-z&ZyKKWu?soqf=Om-li#qsIaGdg&C54cjjqc)hB zp^k(1W6^m=2a|oK)KME_Vum_ot%nXbNa-1#7RH$DmJ06Q*Ng{c2C2S9)^adN_2>PH z#Pb8b-kKe5kWvRcgmG76;(LSlxYom6xj{-D@U$?-WVclCGp_Yy1}SyG)4~{&-BQ6v zTJ*!X7+C3?}MuKFW?X6?SbfQHS$ZcBHAWYlBJuuCBeELs}1?d@+dZfIko8deirI z{^MzJYLMC^04vA^$8U?S7`8L!uq86y?x!9&Af^01Wn zh-*EWF;bBpJTwd@4@-$(ajhpaMk>;Shlat#_ln6K zMSAeiFqrsWaf@p`nK4q49y~M|eOEi*+Lbc?TzpRTm>HzB3BDUl)ZzSYGkX#pSxOzX z!9*R-uXb*pk91@yb<_qEbvPewZa;~RETxXxV4@D^XFE5~M>?{UI%u+fSk+OR1wan5e_KeZ}kL(2=FoQ5#Iu;e3=G zSxOzX!9*R-TiKDN)KMEu)Uj~9T+jHfuJ&$Rk>0V!^!DN*(asVB&klt-LEYNT~z9cQEn2;!W3b_#AGKdh8L^wHyp4`yOLmam0_f z){_~e^bAi6gUN0wv99%G2B}A+;b~zo(X;b2Uau!JNIfDAPYZ*|ZYl8**LpI8)FaaH zv@n?LmJ+|>T2E$>dPEwY76udFD}Kkdp3ETih%`Jc3?{p!#GkmHzB3BDUl)ZzSYGkX#pSxOzX!9*R-uXb*p zk91@yb<_qEbvPewZa;~RETxXxV4@D^XFE5~M>?{UI%u+fSk+OR1wan5e_KeZ}kL(2=FoQ5#Iu;e3=GSxOzX!9*R- zTiKDN)KMEu)Uj~9T+jHfuJ&$Rk>0V!+9M553xkR86}Pz7lNqG;NW;^T z@zkW9?;YRxbG?5~^_Ur?vpp3ETij5Itg3?_PZe#PtcWCp2cq~U2{FwuVJ zcf4LtW{^?`JS_|+ho!`yxYm;yq@Iz6r-i}9_ljFw>&XmK&q%}5l5uF#&i7v5_;bB~ zPW6}>q_hdX8%)&U{BARQ5*=Ae9kszk9nP*dgqrPNUyOw{3glpR@09kszk9nM?Xk)`@~RT}K--_@1synR38yGl~M z;b~zo@x9__TzcNUv z1K!5ru9&F9`PD}Bg3KVL4tRPPOw{3gw3$6AGf1feo*o7hbvQrU%$}4Pq|^aV4}*z1 zocA`fCuIgHb->fZV4@D^N1NG`GJ}*l;OSv7QHS%^X7;4aAf*m?dKgU9;oQDv_N2@p zr4D#{7);dRe3TtoN*%SqL>fhCs>O4R4MSSwbxMsbhHm-^9?fmDv zGCJJ2QoW-#u8HpzAHOn4^`A#+kn+7^{=%7`hR@*!DRsbigNg4IZ@QL4ha05&ceO9^ zc!5F6_lh6CGDxWdj^)>(gUNfJDe)fHdNPBQI^bzxFnL=_{ETZonL$b&@U$?Pye%a@ z;#yB;kWvRcEes}aONnKzCo@Rt8J-pf6W=S=wVuo%r7iHZFqr7s`4g|#lNqGc0Z$8q ziS|3Uc)gy?Af*m?S~9e^wevmgcm7=OpHn?%1}SZV?*oZlDS>Tqsf@p?IQWGQvj1`~BSA7w|DQb%nt zQHS$Zc4VpkU6lsA`ge7uI&a_4_^y&v@Axi^e|;GE-p+rR@H{%MH$2rlYU7%?AwGU( zkm^5=(jet~#r%cCr-;_;Uurib?JD zkn+9aJ+Af8;RdO9q~U2{F!8TrIw5xpQYNT~y!9tIP2I3I0h zPs$8Z>VT(*!9*R-&o;9sWd_N2@pr4D#{ z7);dRytSDVRYUb?9Ky_L&mzajhpa zNT~y!76y}MDe*I|^<)Mqb->fYVA3olKH^$WW{^?`JS_|+%~Il5T2CcOR1wan5e_~)y~cHk&Y~-j@n?N4(FrI?I+QZrPNUyOw{50 zZ0F|rNJo}ZM{O`shx6X%_LJzyQtGG;ChBm0v~%-(q$5kIqc)hR!+C3S`$=?UDRtBa z6LmPZuXw#2Iy+z=nXGD!8GM`@7qy<+~t;ZsEG^)Iy>q|^c54JN)< zyy;pF9d3|P2RtPVCcanvkhPr5Al3Gt3{t*VyvMa3I@}=DA`MRqgNg4IKjT_YW{_%; zhNp$W#P^DixYm;yq*|omX<;z&z2a9~>&XmK>VT((!Q|g8C4R@Xp3ESn4tQD^Onk5S z6W4k&gOobpX<;z=H%p0SttT@`=^37ujJ~U#@BO8e@#o@ms>jSA)$g!0NU6j5{VRi% zI^b;_?uv;zoL_B3FUSm1>VT(*!9*R-N1NG`GJ}*l;OSv7QHS%h&Fo2;K}sF)^e~vH z!+CErds1eQQU^Re3?}MuezciADKki^1D+lR6LmOmZDvo(3{vWVr-#8r9nS4*W>3lt zQtE)GhrvW0&PUmirPNUyOw{4Ll^t2Ce^;f!uKrzJsm}8wU&JS0jBD0AYU7&t-p+r% zE2G1WE7dz{hw zj~5uEe6RTND}$6e;8=bgI+*;m&y;wNYdx7kN*(aDFqr&hDe*I|^<)Mqb->fYVDgux z#7A7~$qZ8JfTxAQfhCs>b!kFaKH^$WW{~G|$Z{zUu7!%(c{46`%Af*m?RE+OxOnh(fUUs-aN*(aMV@!N+ z@T2T-gOobpd&ijg-r%k5aD$XO;Gpa9^B5D~8{ED~@|+o@)B%&X|DxFCdxH<&9||2~ zm!;HE8)IUI{(IcY4mU{k@2WINEqm2*JA97CxKh2NHkkNc@gD{}j}G3jRPU$_CT8d} z9lX!R@n6(vw^aXml*ZU)hC14>3{vWV$4!m98WZ0eyoI$MzAHCK_3x@QNcEpNspxpX zqlgYSNT~xJ!uT9x;(LSlvcnBh>VWSZW8!;*pJj&|q|^c5JI2KK1|MaI8>G|$-#f;{ z_XfYp4mU`t1HN~RiSG@5mmO}9QU`qR7!%(c{3$!!Af*oY-Z3V=H@KA@Zje$3yms{e zp4<7}KWMM<=i+m!NB#Hl-54p|VN=Qv1`~BSzniDUnK4pf4;~^06LmPh+K66|86y?; z;2~l#QHS$UcBHAWYlDe8oS$V!nhLu%n5e^fFFVpy*tNk#9nO!kBTa=}8%)&UypB!9*R-TiKDO!mbS_{kyvMc3!?Gy%^tB zmQu&<4C9*k-p+r%E2%+B9q7`e6P5b9d3-&j|M5u8mmO}5)Q<)!-z)x<9d3-&j|M5SEa$O{#{+E&dc|t7vsB1QoW-#u8HsM{O7we zI^4KYy`wg+iSHHj7tU-N9cGZ~KaaA5lVWSZOnk5SS$4QVN*(aMgNg4IA7zIdq|^c5 zJDB)h@vH1`gOobpdj}KWD}I+9Zje$3eD7f5d&Qr!!wpjEfbShle6P5b9d3|P2fTLl zUG04D-z#POx%iywQNQiq4O0CMOM{d;oZr7PNT~zX{hWupVxkV`S0l-DW{^?`On!cH zChBlL%8o3hj@n?N4(Dgtk)_m88%)&Uyq6tWN*%SqL>7fN*%SqL>ISoRT`vxulVsRgOobpd2iem6W=S|%MLe4sRO=uF!8C$i_fVZ_1pg4Al2`%G)Sq#`TZ+{lsaJD&w02jChBm0HIh7M z1}SyG`8REL8^aOr9sN~iXXo+NT~y!_r_f@@x9``>~MpWI^cT;6W=R-mK|=8 zQU`qRVB&klN7>;9DRsd24ko@={3<)#Af*oY-oeE8ir;028>G|$-#eK2Uh${waD$XO z;ClxX-z#oqha05S0k0i>S3BSPN2QEE7oSr->bL#7L8{+jX^>Kf^ZQo@DRscQpYw26 zOw{50Y9x8i3{vWV$nN`sW|6+eDukWvRc?~S`+;(Nt=+2IB$b-?!y zCcanvEIZsFr4IPs!Nm89kFvuJQtE*39ZY<$_*HheK}sF)y@QGG6~D_4H%O@izIQP3 zz2Z;V;RY#n!1oR&zE|AJ4mU`t1717&u6DlnH>Hd}7oSr->bL#7L8{+jX^>Kf^ZQo@ zDRscQpYw26Ow{50Y9x8i3{vWV$nN`sW|6+eDukWvRc?~S`+;(Nt= z+2IB$b-?!yCcanvEIZsFr4IPs!Nm89kFvuJQtE*39ZY<$_*HheK}sF)y@QGG6~D_4 jH%O@izIQP3z2Z;V;RY#n!1oR&zE|AJ4mU`t1OER23dBs| literal 0 HcmV?d00001 diff --git a/case/plume/iss_panel_thesis/study/iss_panel_baseline_full_cai.yaml b/case/plume/iss_panel_thesis/study/iss_panel_baseline_full_cai.yaml new file mode 100644 index 0000000..e26dadb --- /dev/null +++ b/case/plume/iss_panel_thesis/study/iss_panel_baseline_full_cai.yaml @@ -0,0 +1,104 @@ +# ISS-panel BASELINE, full collisionless Cai model. +# +# One firing of the argon round jet (D = 1 m, S0 = 2.0, T0 = 200 K, +# Tw = 300 K, fully diffuse) against the 22 m x 12 m panel, with the source +# centered on the panel and standing off at L = 4 m. The plume axis is fixed +# anti-parallel to the panel normal (parallel_to_normal), so at zero offset +# the centerline meets the panel exactly at its geometric center. +# +# This is the reference point of the offset/distance sweep, and the physical +# and geometric twin of iss_panel_baseline_simplified.yaml -- the ONLY +# difference between the two files is plume_model.name, so the pair isolates +# the far-field simplification. +# +# Run it: +# python case/plume/iss_panel_thesis/run.py baseline-full-cai + +study: + name: iss_panel_baseline_full_cai + description: >- + Centered, normal-incidence plume impingement on a 22 m x 12 m + ISS-representative panel at L = 4 m, CollisionlessGasKinetics. + case_dir: .. + output_dir: ../results/studies/baseline_full_cai + +thruster: + id: T1 + +plume_model: + # Cai & Wang 2012 FULL collisionless model: the exact special factor Q + # (Eq. 9) integrated over the finite exit disk, valid in the near field. + name: CollisionlessGasKinetics + parameters: + # Recorded for provenance; the values themselves are read from the + # case's thruster definition file (tcd/tdf.csv, thruster type ARG). + gas: argon + speed_ratio_S0: 2.0 + stagnation_temperature_K: 200.0 + nozzle_diameter_m: 1.0 + +target: + geometry_id: iss_panel.stl + # Panel geometric center; also the moment reference point and the origin + # of the panel-local (u, v) coordinates. + reference_point: [0.0, 0.0, 0.0] + # n: panel normal, toward the plume source. + normal: [0.0, 0.0, 1.0] + # u: longitudinal axis, the 22 m dimension. The transverse axis + # v = n x u = +Y (the 12 m dimension) is derived, never configured. + tangent: [1.0, 0.0, 0.0] + components: + - name: panel + selector: all + +sweep: + # The source is TRANSLATED parallel to the panel; its axis stays fixed + # anti-parallel to n. plate_angles_deg has no meaning in this mode. + source_axis_mode: parallel_to_normal + source_distances: [4.0] + source_offsets_u: [0.0] + source_offsets_v: [0.0] + n_firings: 1 + firing_duration_s: 1.0 + thrusters: [1] + # Each model / distance / offset combination is an independent experiment. + mode: per_case + +loads: + moment_reference_point: [0.0, 0.0, 0.0] + normalization: + # Panel area 22 m x 12 m and semi-length 11 m. + reference_area: 264.0 + reference_length: 11.0 + # Cai's normalization, derived from the case's own TDF entry: + # q_dyn = n0*m*U0^2/2, q_heat = n0*m*U0^3/2, with n0 = 1e20 m^-3, + # U0 = 577.0684534784414 m/s, R_specific = 208.13 J/kg/K. + dynamic_pressure: 1.1044652197738332 + reference_heat_flux: 637.3520362956127 + +knudsen: + # DERIVED METADATA ONLY. PyRPOD is collisionless: nothing in the solution + # reads Kn back. It exists so this analytical case can be labelled with + # the rarefaction regime it represents, for a later, separate comparison + # workflow. Kn = 1.0 m / 4.0 m = 0.25 here. + mean_free_path_m: 1.0 + reference_length: source_distance + definition: lambda_over_source_distance + +output: + vtk: + enabled: true + surface_distribution: + enabled: true + summary: + csv: case_results.csv + metadata: study_metadata.json + plots: + enabled: true + per_case_distribution: true + +metadata: + coordinate_system: >- + case global frame; 22 m x 12 m panel in the X-Y plane centered at the + origin, plume source on the +Z side. Panel-local u = +X (longitudinal, + 22 m), v = +Y (transverse, 12 m), n = +Z (normal, toward the source). diff --git a/case/plume/iss_panel_thesis/study/iss_panel_baseline_simplified.yaml b/case/plume/iss_panel_thesis/study/iss_panel_baseline_simplified.yaml new file mode 100644 index 0000000..09f9db4 --- /dev/null +++ b/case/plume/iss_panel_thesis/study/iss_panel_baseline_simplified.yaml @@ -0,0 +1,103 @@ +# ISS-panel BASELINE, simplified collisionless model. +# +# One firing of the argon round jet (D = 1 m, S0 = 2.0, T0 = 200 K, +# Tw = 300 K, fully diffuse) against the 22 m x 12 m panel, with the source +# centered on the panel and standing off at L = 4 m. The plume axis is fixed +# anti-parallel to the panel normal (parallel_to_normal), so at zero offset +# the centerline meets the panel exactly at its geometric center. +# +# This is the reference point of the offset/distance sweep, and the physical +# and geometric twin of iss_panel_baseline_full_cai.yaml -- the ONLY +# difference between the two files is plume_model.name, so the pair isolates +# the far-field simplification. +# +# Run it: +# python case/plume/iss_panel_thesis/run.py baseline-simplified + +study: + name: iss_panel_baseline_simplified + description: >- + Centered, normal-incidence plume impingement on a 22 m x 12 m + ISS-representative panel at L = 4 m, SimplifiedGasKinetics. + case_dir: .. + output_dir: ../results/studies/baseline_simplified + +thruster: + id: T1 + +plume_model: + # Cai & Wang 2012 far-field simplification (Q' of Eq. 13). + name: SimplifiedGasKinetics + parameters: + # Recorded for provenance; the values themselves are read from the + # case's thruster definition file (tcd/tdf.csv, thruster type ARG). + gas: argon + speed_ratio_S0: 2.0 + stagnation_temperature_K: 200.0 + nozzle_diameter_m: 1.0 + +target: + geometry_id: iss_panel.stl + # Panel geometric center; also the moment reference point and the origin + # of the panel-local (u, v) coordinates. + reference_point: [0.0, 0.0, 0.0] + # n: panel normal, toward the plume source. + normal: [0.0, 0.0, 1.0] + # u: longitudinal axis, the 22 m dimension. The transverse axis + # v = n x u = +Y (the 12 m dimension) is derived, never configured. + tangent: [1.0, 0.0, 0.0] + components: + - name: panel + selector: all + +sweep: + # The source is TRANSLATED parallel to the panel; its axis stays fixed + # anti-parallel to n. plate_angles_deg has no meaning in this mode. + source_axis_mode: parallel_to_normal + source_distances: [4.0] + source_offsets_u: [0.0] + source_offsets_v: [0.0] + n_firings: 1 + firing_duration_s: 1.0 + thrusters: [1] + # Each model / distance / offset combination is an independent experiment. + mode: per_case + +loads: + moment_reference_point: [0.0, 0.0, 0.0] + normalization: + # Panel area 22 m x 12 m and semi-length 11 m. + reference_area: 264.0 + reference_length: 11.0 + # Cai's normalization, derived from the case's own TDF entry: + # q_dyn = n0*m*U0^2/2, q_heat = n0*m*U0^3/2, with n0 = 1e20 m^-3, + # U0 = 577.0684534784414 m/s, R_specific = 208.13 J/kg/K. + dynamic_pressure: 1.1044652197738332 + reference_heat_flux: 637.3520362956127 + +knudsen: + # DERIVED METADATA ONLY. PyRPOD is collisionless: nothing in the solution + # reads Kn back. It exists so this analytical case can be labelled with + # the rarefaction regime it represents, for a later, separate comparison + # workflow. Kn = 1.0 m / 4.0 m = 0.25 here. + mean_free_path_m: 1.0 + reference_length: source_distance + definition: lambda_over_source_distance + +output: + vtk: + enabled: true + surface_distribution: + enabled: true + summary: + csv: case_results.csv + metadata: study_metadata.json + plots: + enabled: true + per_case_distribution: true + +metadata: + coordinate_system: >- + case global frame; 22 m x 12 m panel in the X-Y plane centered at the + origin, plume source on the +Z side. Panel-local u = +X (longitudinal, + 22 m), v = +Y (transverse, 12 m), n = +Z (normal, toward the source). diff --git a/case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml b/case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml new file mode 100644 index 0000000..5080a30 --- /dev/null +++ b/case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml @@ -0,0 +1,99 @@ +# ISS-panel OFFSET x DISTANCE sweep. +# +# The plume source is translated parallel to the 22 m x 12 m panel and its +# stand-off varied, with the plume axis fixed anti-parallel to the panel +# normal throughout, so the centerline strikes the panel at the requested +# panel-local (u, v) offset. This is deliberately NOT the same experiment as +# moving the source while continuously re-aiming it at the panel center; for +# that, use source_axis_mode: aim_at_reference and plate_angles_deg. +# +# 3 source distances x 5 u offsets x 1 v offset = 15 independent cases +# +# The u offsets walk the source from near the left edge, through half-span +# and the center, to near the right edge (the panel half-length is 11 m). +# +# Run it: +# python case/plume/iss_panel_thesis/run.py sweep +# +# This committed sweep is sized to run locally in a couple of minutes. See +# README.md for how to expand it: more offsets and distances, the full Cai +# model, and one study per Knudsen label. + +study: + name: iss_panel_offset_distance_sweep + description: >- + Plume source translated parallel to a 22 m x 12 m ISS-representative + panel over 5 longitudinal offsets and 3 stand-off distances, + SimplifiedGasKinetics. + case_dir: .. + output_dir: ../results/studies/offset_distance_sweep + +thruster: + id: T1 + +plume_model: + # Switch to CollisionlessGasKinetics for the full near-field model; the + # run is ~600x more expensive per struck face (see README.md). + name: SimplifiedGasKinetics + parameters: + gas: argon + speed_ratio_S0: 2.0 + stagnation_temperature_K: 200.0 + nozzle_diameter_m: 1.0 + +target: + geometry_id: iss_panel.stl + reference_point: [0.0, 0.0, 0.0] + normal: [0.0, 0.0, 1.0] + tangent: [1.0, 0.0, 0.0] + components: + - name: panel + selector: all + +sweep: + source_axis_mode: parallel_to_normal + source_distances: [2.0, 4.0, 6.0] + # Near left edge, half-span, center, half-span, near right edge. + source_offsets_u: [-10.0, -5.5, 0.0, 5.5, 10.0] + source_offsets_v: [0.0] + n_firings: 1 + firing_duration_s: 1.0 + thrusters: [1] + # Each distance/offset combination is an independent experiment, so each + # gets its own Jet Firing History, strike run and artifacts. + mode: per_case + +loads: + moment_reference_point: [0.0, 0.0, 0.0] + normalization: + reference_area: 264.0 + reference_length: 11.0 + dynamic_pressure: 1.1044652197738332 + reference_heat_flux: 637.3520362956127 + +knudsen: + # DERIVED METADATA ONLY -- never an input to the collisionless solution. + # With reference_length: source_distance, Kn varies across the sweep: + # 0.5 at L = 2 m, 0.25 at L = 4 m, 0.1667 at L = 6 m. + mean_free_path_m: 1.0 + reference_length: source_distance + definition: lambda_over_source_distance + +output: + vtk: + enabled: true + surface_distribution: + enabled: true + summary: + csv: sweep_results.csv + metadata: sweep_metadata.json + plots: + enabled: true + per_case_distribution: true + +metadata: + coordinate_system: >- + case global frame; 22 m x 12 m panel in the X-Y plane centered at the + origin, plume source on the +Z side translated parallel to the panel. + Panel-local u = +X (longitudinal, 22 m), v = +Y (transverse, 12 m), + n = +Z (normal, toward the source). diff --git a/case/plume/iss_panel_thesis/tcd/tcf_1_argon.txt b/case/plume/iss_panel_thesis/tcd/tcf_1_argon.txt new file mode 100644 index 0000000..53e6e0e --- /dev/null +++ b/case/plume/iss_panel_thesis/tcd/tcf_1_argon.txt @@ -0,0 +1,6 @@ +1 +m +0.000000 0.000000 0.000000 +0.000000 0.000000 0.000000 +T1 ARG 0 0 0 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 1.000000e+00 +0 diff --git a/case/plume/iss_panel_thesis/tcd/tdf.csv b/case/plume/iss_panel_thesis/tcd/tdf.csv new file mode 100644 index 0000000..dc39035 --- /dev/null +++ b/case/plume/iss_panel_thesis/tcd/tdf.csv @@ -0,0 +1,2 @@ +#,name,prop,F,isp,MIB,m,mdot,ve,d,R,gamma,Te,rhoe,n +ARG,CAI2016,argon,1,1,1,1,0.001,577.0684534784414,1.0,208.13,1.6666666666666667,200,6.6329E-06,1.0E+20 diff --git a/pyrpod/mdao/panel_plots.py b/pyrpod/mdao/panel_plots.py index 78316fe..6a4462b 100644 --- a/pyrpod/mdao/panel_plots.py +++ b/pyrpod/mdao/panel_plots.py @@ -138,7 +138,9 @@ def plot_panel_pressure(local_u: Sequence[float], local_v: Sequence[float], axes.set_xlabel("panel-local u (m), longitudinal") axes.set_ylabel("panel-local v (m), transverse") - axes.set_title(title, fontsize=10) + # On the figure, not the axes: a wide panel is drawn to scale, so an + # axes title would run under the colorbar. + figure.suptitle(title, fontsize=9, y=0.98) axes.set_aspect("equal", adjustable="box") axes.legend(fontsize=8, loc="upper right") figure.savefig(path, dpi=200, bbox_inches="tight") @@ -176,9 +178,10 @@ def plot_panel_pressure_for_case(case: CaseResult, out_dir: str, centerline = (case.source_offset_u, case.source_offset_v) \ if case.source_axis_mode == "parallel_to_normal" else (None, None) - title = (f"Panel-local pressure -- {case.case_id} " - f"({case.model_variant}, L = {case.source_distance:g} m, " - f"u = {case.source_offset_u:g} m, v = {case.source_offset_v:g} m)") + title = (f"Panel-local pressure -- {case.case_id}\n" + f"{case.model_variant}, L = {case.source_distance:g} m, " + f"source u = {case.source_offset_u:g} m, " + f"v = {case.source_offset_v:g} m") return plot_panel_pressure( local_u, local_v, pressure, os.path.join(out_dir, f"panel_pressure_{case.case_id}.png"), From b9dca2166861d01fea9e4067d7cf025f9cf5dc4d Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:43:06 -0500 Subject: [PATCH 07/10] case: add an independent Cai 2016 per-face reference generator cai2016_reference.py evaluates the exact Cai 2016 surface solution (CaiImpingement2016.py, Eqs. 9-14) at the same panel face centroids a study used and exports the same panel-local distribution schema, so the two can be diffed column for column. It is a REFERENCE GENERATOR, not a plume-model backend: it lives in the case directory, PyRPOD never imports it, PlumeStrikeCalculator cannot reach it, and it takes no part in any study run. CaiImpingement2016 stays independent of the production strike pipeline exactly as before. It implements normal incidence only (Cai alpha_0 = 90 deg), which is precisely the parallel_to_normal pose, and refuses any other geometry rather than silently mapping it. The frame mapping between the panel basis and Cai's nozzle frame is written out in the docstring. Value of the check: PyRPOD reaches the wall through a chain (plume field -> LocalFieldState -> Shen formulas) while this reference integrates the incident and re-emitted wall fluxes directly. At L = 4 m on the committed mesh the peaks agree to 2.8% in pressure, 3.4% in shear and 7.8% in heat flux -- the same documented Maxwellian-chain gap mdao_integration_test_02 already checks for integrated loads, now available per face. Co-Authored-By: Claude Opus 5 --- case/plume/iss_panel_thesis/README.md | 30 +++ .../iss_panel_thesis/cai2016_reference.py | 227 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 case/plume/iss_panel_thesis/cai2016_reference.py diff --git a/case/plume/iss_panel_thesis/README.md b/case/plume/iss_panel_thesis/README.md index 1cdfd51..dc0ed11 100644 --- a/case/plume/iss_panel_thesis/README.md +++ b/case/plume/iss_panel_thesis/README.md @@ -207,6 +207,36 @@ Give each copy its own `study.name` and `output_dir`. Exactly one reference-length mode may be given; supplying both, or neither, is a configuration error. +## Independent Cai 2016 reference (optional) + +`cai2016_reference.py` evaluates the **exact** Cai 2016 surface solution +(`pyrpod/plume/CaiImpingement2016.py`, Eqs. 9-14) at the *same* face +centroids and exports the *same* distribution schema, so the two can be +diffed column for column. + +It is an **independent analytical reference generator, not a plume-model +backend**: PyRPOD never imports it, `PlumeStrikeCalculator` cannot reach it, +and it takes no part in any study run. It implements normal incidence only +(Cai's `alpha_0 = 90 deg`), which is exactly the `parallel_to_normal` pose. + +```bash +cd case/plume/iss_panel_thesis + +# Evaluate at the committed mesh for the baseline pose +python cai2016_reference.py --distance 4.0 + +# Diff against a distribution a study already exported +python cai2016_reference.py --distance 4.0 --compare \ + results/studies/baseline_simplified/cases/case000_modelSimplified_L4_u0_v0/distributions/case000_modelSimplified_L4_u0_v0_panel_firing001.csv +``` + +On the committed mesh at L = 4 m the peak values agree to **2.8 % in +pressure, 3.4 % in shear and 7.8 % in heat flux**. That residual is the +Maxwellian wall chain (plume field -> `LocalFieldState` -> Shen formulas) +against a direct integration of the wall fluxes — the same documented gap +`tests/mdao/mdao_integration_test_02.py` already checks for integrated +loads. It is not a defect in either path. + ## See also - `docs/plume_validation_study.md` — the study framework, the YAML schema, diff --git a/case/plume/iss_panel_thesis/cai2016_reference.py b/case/plume/iss_panel_thesis/cai2016_reference.py new file mode 100644 index 0000000..3ab568a --- /dev/null +++ b/case/plume/iss_panel_thesis/cai2016_reference.py @@ -0,0 +1,227 @@ +"""INDEPENDENT Cai 2016 analytical reference for the ISS-panel case. + +This is a **reference generator, not a plume-model backend.** It is not +imported by PyRPOD, is not reachable from `PlumeStrikeCalculator`, and takes +no part in any study run. Its only job is to evaluate the *exact* Cai 2016 +surface solution (`pyrpod/plume/CaiImpingement2016.py`, Eqs. 9-14 by +Gauss-Legendre quadrature over the nozzle exit disk) at the **same face +centroids** a study used, and export it in the **same panel-local +distribution schema**, so the two can be diffed column for column. + +Why it is worth having +---------------------- +The production pipeline reaches the wall through a chain -- plume field -> +`LocalFieldState` -> Maxwellian (Shen) wall formulas -- evaluated per face. +The Cai 2016 surface solution integrates the incident *and* re-emitted +molecular fluxes at the wall directly. Agreement between them is an +independent check on the chain; disagreement localizes to it. That check +already exists for integrated loads in `tests/mdao/mdao_integration_test_02.py`; +this script provides it per face. + +Scope and limits +---------------- +* **Normal incidence only.** The exported geometry must be a flat panel with + the plume axis anti-parallel to its normal, i.e. a study running + `source_axis_mode: parallel_to_normal`. That is Cai's `alpha_0 = 90 deg`. + Anything else is refused rather than silently mapped. +* **Diffuse coefficients** (`Cp_d`, `Cf1_d`, `Cf2_d`, `Cq_d`) are exported; + the case is fully diffuse (`sigma = 1`). +* **No DSMC.** This is an analytical reference, nothing more. + +Usage +----- + # Evaluate at the committed panel mesh, for the baseline pose + python cai2016_reference.py --distance 4.0 + + # Match a swept case: source offset 5.5 m along u + python cai2016_reference.py --distance 4.0 --offset-u 5.5 + + # Compare against a distribution a study already exported + python cai2016_reference.py --distance 4.0 \ + --compare results/studies/baseline_simplified/cases//distributions/_panel_firing001.csv +""" + +from __future__ import annotations + +import argparse +import csv +import sys +from pathlib import Path + +import numpy as np + +CASE_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(CASE_DIR.parents[2])) + +from pyrpod.mdao.surface_distribution import ( # noqa: E402 + DISTRIBUTION_COLUMNS, + write_surface_distribution, +) +from pyrpod.plume import CaiImpingement2016 as cai # noqa: E402 + +# Case conditions, matching config.ini and tcd/tdf.csv (Cai 2016 Section 4). +S_0 = 2.0 # exit speed ratio +EPS = 1.5 # Tw / T0 = 300 / 200 +R_0 = 0.5 # nozzle exit radius (m), D = 1 m +Q_DYN = 1.1044652197738332 # n0*m*U0^2/2 (Pa) +Q_HEAT = 637.3520362956127 # n0*m*U0^3/2 (W/m^2) + +DEFAULT_STL = CASE_DIR / "stl" / "iss_panel.stl" + + +def reference_distribution(centroids: np.ndarray, areas: np.ndarray, + distance: float, offset_u: float = 0.0, + offset_v: float = 0.0) -> list[dict[str, float]]: + """Exact Cai 2016 surface loads at panel face centroids. + + Frame mapping. The panel lies in the global X-Y plane with its normal + along +Z; the source stands off at ``+distance * n`` and is translated to + ``(offset_u, offset_v)``, with its axis along ``-n``. Cai's nozzle frame + puts the exit at the origin with the jet along ``+X`` and, for + ``alpha_0 = 90 deg``, the plate in the ``Y-Z`` plane at ``X = L``: + + X_paper = distance (along the jet axis) + Y_paper = v - offset_v (transverse, Cai's s) + Z_paper = u - offset_u (longitudinal, Cai's tau) + + The longitudinal panel axis is mapped to Cai's ``tau`` so that ``Cf1_d``, + the shear along ``tau``, is the longitudinal shear. At normal incidence + the solution is axisymmetric, so the choice affects only which shear + component is which, never the exported magnitudes. + + Returns + ------- + list of dict + Rows in the :data:`DISTRIBUTION_COLUMNS` schema. ``strike_count`` is + 1.0 for every face carrying a non-zero load: this analytical solution + has no wedge/radius gating, so it is a coverage flag, not a tally. + """ + centroids = np.asarray(centroids, dtype=float) + local_u = centroids[:, 0] + local_v = centroids[:, 1] + + X = np.full(local_u.shape, float(distance)) + Y = local_v - float(offset_v) + Z = local_u - float(offset_u) + + field = cai.surface_coefficients(X, Y, Z, S_0, np.pi / 2.0, EPS, R_0) + + pressure = np.asarray(field["Cp_d"], dtype=float) * Q_DYN + shear = np.hypot(np.asarray(field["Cf1_d"], dtype=float), + np.asarray(field["Cf2_d"], dtype=float)) * Q_DYN + heat_flux = np.asarray(field["Cq_d"], dtype=float) * Q_HEAT + loaded = (pressure != 0.0) | (shear != 0.0) | (heat_flux != 0.0) + + return [ + { + "face_index": int(i), + "centroid_x": float(centroids[i, 0]), + "centroid_y": float(centroids[i, 1]), + "centroid_z": float(centroids[i, 2]), + "local_u": float(local_u[i]), + "local_v": float(local_v[i]), + "area": float(areas[i]), + "pressure": float(pressure[i]), + "shear_stress": float(shear[i]), + "heat_flux": float(heat_flux[i]), + "strike_count": 1.0 if loaded[i] else 0.0, + } + for i in range(len(centroids)) + ] + + +def compare_with(rows: list[dict[str, float]], path: str) -> None: + """Report per-face agreement against a study-exported distribution CSV.""" + with open(path, "r", encoding="utf-8", newline="") as handle: + study = list(csv.DictReader(handle)) + if len(study) != len(rows): + raise SystemExit( + f"face-count mismatch: reference has {len(rows)}, " + f"{path} has {len(study)}; both must come from the same mesh") + + print(f"\ncompared against {path}") + print(f"{'quantity':14s} {'ref max':>12s} {'study max':>12s} " + f"{'max |diff|':>12s} {'rel. of peak':>13s}") + for column in ("pressure", "shear_stress", "heat_flux"): + reference = np.array([row[column] for row in rows], dtype=float) + candidate = np.array([float(row[column]) for row in study], + dtype=float) + difference = np.abs(reference - candidate) + peak = float(np.max(np.abs(reference))) + relative = float(np.max(difference) / peak) if peak > 0.0 else float("nan") + print(f"{column:14s} {peak:12.6g} {float(np.max(candidate)):12.6g} " + f"{float(np.max(difference)):12.6g} {relative:13.4%}") + print("\nA gap here is the Maxwellian wall chain, not a bug: PyRPOD " + "reaches the wall through plume field -> LocalFieldState -> Shen " + "formulas, while this reference integrates the wall fluxes " + "directly.") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--stl", type=Path, default=DEFAULT_STL, + help="panel STL to evaluate at") + parser.add_argument("--distance", type=float, required=True, + help="source stand-off L along the panel normal (m)") + parser.add_argument("--offset-u", type=float, default=0.0, + help="source offset along the longitudinal axis (m)") + parser.add_argument("--offset-v", type=float, default=0.0, + help="source offset along the transverse axis (m)") + parser.add_argument("--out", type=Path, default=None, + help="output CSV (default: cai2016_reference_*.csv " + "beside this script)") + parser.add_argument("--compare", type=Path, default=None, + help="a study-exported distribution CSV to diff against") + args = parser.parse_args(argv) + + if args.distance <= 0.0: + raise SystemExit(f"--distance must be positive, got {args.distance}") + + from stl import mesh + + panel = mesh.Mesh.from_file(str(args.stl)) + normals = panel.get_unit_normals() + if not np.allclose(normals, [0.0, 0.0, 1.0], atol=1e-6): + raise SystemExit( + f"{args.stl} is not a flat panel with a +Z normal; this " + "reference generator implements normal incidence " + "(Cai alpha_0 = 90 deg) only") + + centroids = panel.vectors.mean(axis=1) + v0, v1, v2 = panel.vectors[:, 0], panel.vectors[:, 1], panel.vectors[:, 2] + areas = 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis=1) + + rows = reference_distribution(centroids, areas, args.distance, + args.offset_u, args.offset_v) + + out = args.out or (CASE_DIR / f"cai2016_reference_L{args.distance:g}" + f"_u{args.offset_u:g}_v{args.offset_v:g}.csv") + write_surface_distribution(out, rows, { + "source": "INDEPENDENT analytical reference; NOT a PyRPOD plume " + "model and not used by any study run", + "solution": "Cai 2016 (Aerospace 3(4):43) Eqs. 9-14, exact " + "quadrature over the nozzle exit disk", + "generator": "case/plume/iss_panel_thesis/cai2016_reference.py", + "geometry": str(args.stl), + "alpha_0_deg": 90.0, + "source_distance": float(args.distance), + "source_offset_u": float(args.offset_u), + "source_offset_v": float(args.offset_v), + "speed_ratio_S0": S_0, + "temperature_ratio_eps": EPS, + "nozzle_radius_m": R_0, + "dynamic_pressure_Pa": Q_DYN, + "reference_heat_flux_W_m2": Q_HEAT, + "accommodation": "fully diffuse (sigma = 1); diffuse coefficients " + "Cp_d, Cf1_d, Cf2_d, Cq_d", + "columns_note": f"schema matches {list(DISTRIBUTION_COLUMNS)}", + }) + print(f"wrote {out} ({len(rows)} faces)") + + if args.compare: + compare_with(rows, str(args.compare)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 1a1863554d7410c8623d91d774e71f9166f25068 Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 21:59:09 -0500 Subject: [PATCH 08/10] tests: cover model dispatch, panel offsets, Knudsen and the ISS case Two new files, both registered in tests/test_manifest.yaml (README regenerated with scripts/generate_test_dashboard.py). No existing assertion was weakened or removed. mdao_unit_test_07.py (63 tests) covers the units: * both Cai variants selectable by name, unknown names rejected rather than defaulted, kinetics keys round-tripping; * both models reduced to the same populated LocalFieldState, agreeing bit-for-bit on the centerline (shared closed forms) and differing off it (which is what makes the selection meaningful), with one shared Maxwellian implementation consuming either; * parallel_to_normal poses: centered position and DCM, a proper orthonormal rotation at every offset, translation without tilting the axis, and exact agreement with aim_at_reference at zero offset; * offsets defaulting to zero, case count = distances x u x v, the pose order, and refusal of incompatible pose definitions; * Kn for both reference-length modes, the five documented Kn labels, absent when unconfigured, and every invalid form rejected; * panel-local transforms and hand-computed normal-force, moment and center-of-pressure projections including the sign conventions; * the distribution schema and the new result columns. mdao_integration_test_05.py (41 tests) runs the committed case end to end. The key one: running the two baselines, which differ only in plume_model.name, and asserting the LOADS differ -- if selection were metadata only, they would be identical. Also re-integrates the exported per-face pressures and checks the result against CaseResult.pressure_force, checks centered-source symmetry and offset moment signs, the sweep's count, order and identifiers, headless plotting, and that the existing flat-plate configurations, case identifiers and baseline numbers are unchanged. Sweep tests build their own 64-face panel rather than running the committed 15-case sweep on 2112 faces; the baseline runs on the committed mesh unchanged. Full suite: 427 passed, 12 skipped, 1 failed -- rpod_unit_test_01::test_stl_to_vtk, which fails identically on master and is unrelated (convert_stl_to_vtk does not write its output file). Co-Authored-By: Claude Opus 5 --- tests/README.md | 10 +- tests/mdao/mdao_integration_test_05.py | 597 ++++++++++++++++++ tests/mdao/mdao_unit_test_07.py | 825 +++++++++++++++++++++++++ tests/test_manifest.yaml | 47 ++ 4 files changed, 1475 insertions(+), 4 deletions(-) create mode 100644 tests/mdao/mdao_integration_test_05.py create mode 100644 tests/mdao/mdao_unit_test_07.py diff --git a/tests/README.md b/tests/README.md index 994cd47..56154c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,9 +32,9 @@ Source of truth for the metadata below: [`test_manifest.yaml`](test_manifest.yam | Metric | Count | | --- | --- | -| Manifest entries | 100 | -| Collected by pytest (files) | 51 | -| Collected by pytest (test cases) | 334 | +| Manifest entries | 102 | +| Collected by pytest (files) | 53 | +| Collected by pytest (test cases) | 440 | | Manual verification scripts | 42 | | Placeholder tests | 12 | | Blocked tests | 5 | @@ -73,8 +73,9 @@ separately). | [`mdao_unit_test_02.py`](mdao/mdao_unit_test_02.py) | 1 | Intended to build an array of cant-angle-swept thruster configurations (symmetric pitch/yaw canting) and visualize each sweep step. The entire body is currently commented out, so the test asserts nothing. | `placeholder` | — | | [`mdao_unit_test_03.py`](mdao/mdao_unit_test_03.py) | 23 | Unit tests for prescribed firing generation and the exact meaning of n_firings: invalid counts are rejected, N requested firings produce exactly N JFH entries (read back through JetFiringHistory), an explicit firing list that disagrees with n_firings is an error, a whole-sweep sequence holds exactly poses x n_firings pose-tagged entries, the generated pose convention reproduces the committed flat-plate sweep JFH, and the dynamics-driven approach honors an exact count. | `implemented` | — | | [`mdao_unit_test_04.py`](mdao/mdao_unit_test_04.py) | 20 | Unit tests for per-component surface-load integration on meshes with analytically known loading: pressure and shear force integration, moments about a user-defined reference point, center of pressure (recovered, moment-consistent, and unavailable for zero-load and near-cancellation cases), coefficient calculation and omission, and component face selection. | `implemented` | — | -| [`mdao_unit_test_05.py`](mdao/mdao_unit_test_05.py) | 32 | Unit tests for the YAML study configuration: the committed flat-plate examples parse, paths resolve against the configuration file, validation rejects an unsupported plume model, an unknown sweep mode, invalid firing counts, mismatched firing lists and bad geometry or normalization inputs, the per-pose n_firings semantics hold in both sweep modes, and the case's own config.ini keeps parsing unchanged. | `implemented` | — | +| [`mdao_unit_test_05.py`](mdao/mdao_unit_test_05.py) | 34 | Unit tests for the YAML study configuration: the committed flat-plate examples parse, paths resolve against the configuration file, validation rejects an unsupported plume model, an unknown sweep mode, invalid firing counts, mismatched firing lists and bad geometry or normalization inputs, the per-pose n_firings semantics hold in both sweep modes, and the case's own config.ini keeps parsing unchanged. | `implemented` | — | | [`mdao_unit_test_06.py`](mdao/mdao_unit_test_06.py) | 22 | Unit tests for the generic external-reference comparison (absolute and relative error, normalized RMSE, peak error, integrated-load error, center-of-pressure displacement; CSV / JSON / YAML datasets compared identically regardless of origin; unmatched cases and missing quantities reported rather than fabricated) and for the structured result schema's CSV and JSON serialization. | `implemented` | — | +| [`mdao_unit_test_07.py`](mdao/mdao_unit_test_07.py) | 63 | Unit tests for the ISS-panel study extensions: collisionless plume-model dispatch (both Cai variants selectable by name, unknown names rejected, both reduced to one common LocalFieldState feeding one shared Maxwellian gas-surface implementation); panel-local pose generation (parallel_to_normal translates the source without tilting the plume axis, and coincides with aim_at_reference at zero offset); offset-sweep enumeration and the refusal to combine incompatible pose definitions; derived Knudsen metadata for both reference-length modes; panel-local coordinate transforms and hand-computed normal-force and moment projections; and the distribution-CSV schema. | `implemented` | Cai & Wang 2012, J. Spacecraft and Rockets 49(2), 335-340, doi:10.2514/1.A32046 | #### Integration @@ -84,6 +85,7 @@ separately). | [`mdao_integration_test_02.py`](mdao/mdao_integration_test_02.py) | 8 | Baseline flat-plate case run end to end through the package-level TradeStudy.from_config API: one case with exactly one JFH entry, the standard per-face strike VTK at the advertised path, CSV and JSON summaries with full provenance, physically consistent head-on loads, available coefficients, and agreement of the integrated normal load with the independent Cai 2016 exact reference. | `implemented` | Cai 2016, Aerospace 3(4), 43, doi:10.3390/aerospace3040043, Eq. 15 | | [`mdao_integration_test_03.py`](mdao/mdao_integration_test_03.py) | 13 | Multi-angle / multi-distance flat-plate sweep through the trade-study API (a reduced subset of the committed sweep configuration): one result per angle-distance case, mirror symmetry in +/- angle, load decay with distance, center-of-pressure travel, agreement with the independent Cai 2016 reference through the generic comparison interface, per-case VTK isolation, optional trend plots, and the same machinery running on the cylinder target with coefficients correctly unavailable. | `implemented` | Cai 2016, Aerospace 3(4), 43, doi:10.3390/aerospace3040043, Eq. 15 | | [`mdao_integration_test_04.py`](mdao/mdao_integration_test_04.py) | 12 | The single-history sweep engine (sweep.mode: single_jfh): engine dispatch from the configuration, exactly one Jet Firing History holding poses x n_firings entries, one result record per firing keyed to its pose, per-firing equivalence with the per-case engine, a single results/strikes VTK series, the per-component sweep envelope (absent rather than fabricated without the full pipeline), and the same independent Cai 2016 reference comparison. | `implemented` | Cai 2016, Aerospace 3(4), 43, doi:10.3390/aerospace3040043, Eq. 15 | +| [`mdao_integration_test_05.py`](mdao/mdao_integration_test_05.py) | 41 | The committed ISS-representative solar-panel case (22 m x 12 m flat panel, source translated parallel to the panel) run end to end through TradeStudy.from_config().run(): panel-local resultants, derived Knudsen metadata and distribution export on the committed baseline; proof that selecting CollisionlessGasKinetics changes the ANSWER and not merely the metadata; the pressure force re-integrated from the exported per-face values matching the CaseResult; symmetry of a centered source and the documented moment signs for offset sources; offset-sweep case count, order and identifiers; headless plot generation; and backward compatibility of the existing flat-plate configurations, case identifiers and baseline results. | `implemented` | Cai & Wang 2012, J. Spacecraft and Rockets 49(2), 335-340, doi:10.2514/1.A32046 | #### Verification diff --git a/tests/mdao/mdao_integration_test_05.py b/tests/mdao/mdao_integration_test_05.py new file mode 100644 index 0000000..1f3de2f --- /dev/null +++ b/tests/mdao/mdao_integration_test_05.py @@ -0,0 +1,597 @@ +# ======================== +# PyRPOD: tests/mdao/mdao_integration_test_05.py +# ======================== +# The committed ISS-representative solar-panel case run end to end through +# the package-level trade-study API: +# +# study = TradeStudy.from_config('.../iss_panel_baseline_simplified.yaml') +# results = study.run() +# +# The case is an idealized 22 m x 12 m flat panel (case/plume/iss_panel_thesis) +# struck by the Cai 2016 argon round jet, with the source TRANSLATED parallel +# to the panel (source_axis_mode: parallel_to_normal). Checked here: +# +# * the committed baseline runs and produces the panel-local resultants, +# the derived Knudsen metadata and the distribution export; +# * selecting CollisionlessGasKinetics changes the ANSWER, not just the +# recorded metadata -- the one property that proves model dispatch +# reaches the calculation; +# * the integrated pressure force recovered from the exported per-face +# values matches the CaseResult pressure force; +# * a centered source produces a symmetric load: no moment about the panel +# center and a center of pressure at the origin; an offset source moves +# both, with the documented sign; +# * the offset sweep enumerates n_distances x n_u_offsets x n_v_offsets +# cases with stable identifiers, and its CSV/JSON carry the new columns; +# * plot generation completes headless. +# +# Small meshes and small sweeps: the offset-sweep tests build their own +# coarse panel and a 2 x 3 grid rather than running the committed 15-case +# sweep on 2112 faces. The BASELINE runs on the committed mesh unchanged. +# +# No DSMC is involved anywhere: both models under test are collisionless and +# Kn is asserted to be metadata only. +# +# Run: python -m pytest mdao/mdao_integration_test_05.py (from tests/) + +import copy +import csv +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import yaml + +from pyrpod.mdao.TradeStudy import TradeStudy +from pyrpod.mdao.study_config import StudyConfig + +_TESTS_DIR = Path(__file__).resolve().parents[1] +CASE_DIR = _TESTS_DIR.parent / 'case' / 'plume' / 'iss_panel_thesis' +STUDY_DIR = CASE_DIR / 'study' +BASELINE_YAML = STUDY_DIR / 'iss_panel_baseline_simplified.yaml' +FULL_CAI_YAML = STUDY_DIR / 'iss_panel_baseline_full_cai.yaml' +SWEEP_YAML = STUDY_DIR / 'iss_panel_offset_distance_sweep.yaml' + +PANEL_LENGTH_U = 22.0 +PANEL_WIDTH_V = 12.0 +PANEL_AREA = PANEL_LENGTH_U * PANEL_WIDTH_V + +PANEL_U = np.array([1.0, 0.0, 0.0]) +PANEL_V = np.array([0.0, 1.0, 0.0]) +PANEL_N = np.array([0.0, 0.0, 1.0]) + + +def run_config(mapping, output_dir, name): + """Write a modified configuration into the case's study dir and run it. + + The file must live beside the committed ones so its relative + ``case_dir: ..`` still resolves to the case; it is removed afterwards. + """ + path = STUDY_DIR / f'_tmp_{name}.yaml' + path.write_text(yaml.safe_dump(mapping, sort_keys=False), + encoding='utf-8') + try: + study = TradeStudy.from_config(path, output_dir=output_dir) + return study, study.run() + finally: + path.unlink(missing_ok=True) + + +def coarse_panel_mapping(**sweep_overrides): + """The baseline configuration on a coarse mesh, for the sweep tests.""" + mapping = yaml.safe_load(BASELINE_YAML.read_text(encoding='utf-8')) + mapping['study']['name'] = 'iss_panel_coarse' + mapping['target']['geometry_id'] = 'iss_panel_coarse.stl' + mapping['sweep'].update(sweep_overrides) + return mapping + + +class ISSPanelBaseline(unittest.TestCase): + """The committed simplified baseline, on the committed mesh.""" + + @classmethod + def setUpClass(cls): + cls.output_dir = tempfile.mkdtemp(prefix='pyrpod_iss_baseline_') + cls.study = TradeStudy.from_config(BASELINE_YAML, + output_dir=cls.output_dir) + cls.results = cls.study.run() + cls.case = cls.results.cases[0] + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.output_dir, ignore_errors=True) + + # ------------------------------------------------------------ structure + def test_one_case_one_component_one_firing(self): + self.assertEqual(len(self.results), 1) + self.assertEqual(self.case.study_name, 'iss_panel_baseline_simplified') + self.assertEqual(self.case.component, 'panel') + self.assertEqual(self.case.firing_id, 1) + self.assertEqual(self.case.plume_model, 'SimplifiedGasKinetics') + + def test_target_mesh_is_the_committed_panel(self): + self.assertAlmostEqual(self.case.component_area, PANEL_AREA, places=6) + self.assertEqual(self.case.mesh_faces, self.case.component_faces) + + def test_pose_is_centered_and_parallel_to_the_panel_normal(self): + self.assertEqual(self.case.source_axis_mode, 'parallel_to_normal') + self.assertEqual(self.case.source_offset_u, 0.0) + self.assertEqual(self.case.source_offset_v, 0.0) + np.testing.assert_allclose(self.case.plume_source_position, + [0.0, 0.0, 4.0], atol=1e-9) + # First DCM column is the thruster axis: exactly -n, not re-aimed. + dcm = np.asarray(self.case.plume_source_orientation).reshape(3, 3) + np.testing.assert_allclose(dcm[:, 0], -PANEL_N, atol=1e-9) + + def test_case_id_names_the_model_distance_and_offsets(self): + self.assertEqual(self.case.case_id, + 'case000_modelSimplified_L4_u0_v0') + + # -------------------------------------------------------- panel-local loads + def test_normal_force_is_positive_into_the_panel(self): + # The plume pushes along -n, so the global force is -Z and the + # reported normal force is positive. + self.assertLess(self.case.force[2], 0.0) + self.assertGreater(self.case.normal_force, 0.0) + self.assertAlmostEqual(self.case.normal_force, + -float(self.case.force[2]), places=9) + + def test_centered_source_gives_no_moment_about_the_panel_center(self): + # The panel is symmetric about its center and the source is on the + # normal through it, so every local moment must vanish. + for name in ('local_moment_u', 'local_moment_v', 'local_moment_n'): + with self.subTest(component=name): + self.assertAlmostEqual(getattr(self.case, name), 0.0, + delta=1e-9 * self.case.normal_force + * PANEL_LENGTH_U) + + def test_centered_source_puts_the_center_of_pressure_at_the_origin(self): + self.assertEqual(self.case.center_of_pressure_status, 'ok') + self.assertAlmostEqual(self.case.center_of_pressure_u, 0.0, places=6) + self.assertAlmostEqual(self.case.center_of_pressure_v, 0.0, places=6) + + def test_surface_field_peaks_are_positive_and_finite(self): + self.assertGreater(self.case.max_pressure, 0.0) + self.assertGreater(self.case.max_heat_flux, 0.0) + self.assertGreater(self.case.affected_area, 0.0) + self.assertLessEqual(self.case.affected_area, PANEL_AREA + 1e-9) + + # ------------------------------------------------------ Knudsen metadata + def test_knudsen_is_derived_from_the_configured_mean_free_path(self): + self.assertAlmostEqual(self.case.mean_free_path, 1.0, places=12) + self.assertAlmostEqual(self.case.knudsen_reference_length, 4.0, + places=12) + self.assertAlmostEqual(self.case.knudsen_number, 0.25, places=12) + self.assertEqual(self.case.knudsen_definition, + 'lambda_over_source_distance') + + def test_metadata_records_kn_as_derived_and_disclaims_dsmc(self): + provenance = self.results.provenance + self.assertIn('derived metadata only', + provenance['knudsen']['role']) + limitations = ' '.join(provenance['known_limitations']) + self.assertIn('collisionless', limitations) + self.assertIn('No DSMC data is read, written or compared', + limitations.replace('no DSMC', 'No DSMC')) + + # ---------------------------------------------------------- distributions + def test_distribution_csv_covers_every_face_with_the_required_columns(self): + path = self.case.surface_distribution_path + self.assertTrue(path and os.path.isfile(path)) + with open(path, encoding='utf-8', newline='') as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual(len(rows), self.case.component_faces) + for column in ('face_index', 'centroid_x', 'centroid_y', 'centroid_z', + 'local_u', 'local_v', 'area', 'pressure', + 'shear_stress', 'heat_flux', 'strike_count'): + self.assertIn(column, rows[0]) + + def test_distribution_coordinates_span_the_panel(self): + with open(self.case.surface_distribution_path, encoding='utf-8', + newline='') as handle: + rows = list(csv.DictReader(handle)) + local_u = np.array([float(row['local_u']) for row in rows]) + local_v = np.array([float(row['local_v']) for row in rows]) + # Every centroid lies inside the panel, and the centroids span it to + # within one element (a triangle centroid sits inside its own cell, + # so the extremes are inset by a fraction of the element size). + self.assertLessEqual(float(np.max(np.abs(local_u))), + PANEL_LENGTH_U / 2) + self.assertLessEqual(float(np.max(np.abs(local_v))), + PANEL_WIDTH_V / 2) + element_u = PANEL_LENGTH_U / 44 + element_v = PANEL_WIDTH_V / 24 + self.assertGreater(float(np.ptp(local_u)), + PANEL_LENGTH_U - element_u) + self.assertGreater(float(np.ptp(local_v)), PANEL_WIDTH_V - element_v) + # Symmetric about the panel center. + self.assertAlmostEqual(float(np.mean(local_u)), 0.0, places=6) + self.assertAlmostEqual(float(np.mean(local_v)), 0.0, places=6) + + def test_integrated_pressure_force_matches_the_exported_faces(self): + # Re-integrate the CSV independently: sum(-p_i * A_i * n_hat). + with open(self.case.surface_distribution_path, encoding='utf-8', + newline='') as handle: + rows = list(csv.DictReader(handle)) + pressure = np.array([float(row['pressure']) for row in rows]) + area = np.array([float(row['area']) for row in rows]) + recovered = -float(np.sum(pressure * area)) # along n + np.testing.assert_allclose(self.case.pressure_force, + [0.0, 0.0, recovered], rtol=1e-9, + atol=1e-12) + self.assertAlmostEqual(float(np.sum(area)), PANEL_AREA, places=6) + + def test_distribution_sidecar_records_units_and_provenance(self): + sidecar = (os.path.splitext(self.case.surface_distribution_path)[0] + + '.meta.json') + document = json.loads(Path(sidecar).read_text(encoding='utf-8')) + self.assertEqual(document['units']['pressure'], 'Pa') + self.assertEqual(document['units']['heat_flux'], 'W/m^2') + self.assertEqual(document['plume_model'], 'SimplifiedGasKinetics') + self.assertEqual(document['knudsen_number'], 0.25) + self.assertEqual(document['source_axis_mode'], 'parallel_to_normal') + self.assertIn('none', document['interpolation']) + + # --------------------------------------------------------------- artifacts + def test_vtk_remains_the_primary_per_face_output(self): + self.assertTrue(self.case.vtk_path + and os.path.isfile(self.case.vtk_path)) + self.assertTrue(self.case.vtk_path.endswith('.vtu')) + + def test_summary_csv_carries_the_new_columns(self): + with open(self.results.summary_csv_path, encoding='utf-8', + newline='') as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual(len(rows), 1) + row = rows[0] + for column in ('source_offset_u', 'source_offset_v', + 'source_axis_mode', 'model_variant', 'normal_force', + 'local_moment_u', 'local_moment_v', 'local_moment_n', + 'center_of_pressure_u', 'center_of_pressure_v', + 'knudsen_number', 'mean_free_path', + 'knudsen_reference_length', 'knudsen_definition', + 'surface_distribution_path'): + self.assertIn(column, row, f'missing CSV column {column}') + self.assertEqual(row['model_variant'], 'Simplified') + self.assertEqual(row['source_axis_mode'], 'parallel_to_normal') + self.assertAlmostEqual(float(row['knudsen_number']), 0.25) + + def test_metadata_json_keeps_model_and_knudsen_provenance(self): + document = json.loads( + Path(self.results.metadata_path).read_text(encoding='utf-8')) + provenance = document['provenance'] + self.assertEqual(provenance['plume_model'], 'SimplifiedGasKinetics') + self.assertEqual(provenance['source_axis_mode'], 'parallel_to_normal') + self.assertEqual(provenance['knudsen']['mean_free_path_m'], 1.0) + basis = provenance['panel_basis'] + np.testing.assert_allclose(basis['u'], PANEL_U, atol=1e-12) + np.testing.assert_allclose(basis['v'], PANEL_V, atol=1e-12) + np.testing.assert_allclose(basis['n'], PANEL_N, atol=1e-12) + self.assertEqual(document['cases'][0]['knudsen_number'], 0.25) + + def test_plot_generation_completes_headless(self): + # setUpClass already ran plots via the configuration; re-running + # returns the paths and must not raise without a display. + written = self.study.plot() + self.assertTrue(written) + for path in written: + self.assertTrue(os.path.isfile(path), path) + names = {os.path.basename(path) for path in written} + self.assertIn('normal_force_vs_offset_u.png', names) + self.assertIn(f'panel_pressure_{self.case.case_id}.png', names) + + +class ModelSelectionChangesTheAnswer(unittest.TestCase): + """The selected model drives the calculation, not just the metadata.""" + + @classmethod + def setUpClass(cls): + cls.output_dir = tempfile.mkdtemp(prefix='pyrpod_iss_models_') + cls.cases = {} + for label, config_path in (('simplified', BASELINE_YAML), + ('full_cai', FULL_CAI_YAML)): + study = TradeStudy.from_config( + config_path, output_dir=os.path.join(cls.output_dir, label)) + cls.cases[label] = study.run().cases[0] + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.output_dir, ignore_errors=True) + + def test_both_models_run_the_same_geometry_and_pose(self): + simplified, full = self.cases['simplified'], self.cases['full_cai'] + self.assertEqual(simplified.plume_model, 'SimplifiedGasKinetics') + self.assertEqual(full.plume_model, 'CollisionlessGasKinetics') + self.assertEqual(simplified.mesh_faces, full.mesh_faces) + np.testing.assert_allclose(simplified.plume_source_position, + full.plume_source_position, atol=1e-12) + self.assertEqual(simplified.struck_faces, full.struck_faces) + + def test_the_loads_actually_differ(self): + # If model selection were metadata only, these would be identical. + simplified, full = self.cases['simplified'], self.cases['full_cai'] + self.assertNotAlmostEqual(simplified.max_pressure, full.max_pressure, + places=6) + self.assertNotAlmostEqual(simplified.normal_force, full.normal_force, + places=6) + + def test_the_two_models_stay_physically_close(self): + # Both are the same collisionless jet; the full model differs by the + # near-field correction, not by an order of magnitude. + simplified, full = self.cases['simplified'], self.cases['full_cai'] + for name in ('normal_force', 'max_pressure', 'max_heat_flux'): + with self.subTest(quantity=name): + a, b = getattr(simplified, name), getattr(full, name) + self.assertLess(abs(a - b) / abs(a), 0.15) + + def test_both_report_the_same_derived_knudsen_number(self): + # Kn depends on the configuration, never on the model. + self.assertEqual(self.cases['simplified'].knudsen_number, + self.cases['full_cai'].knudsen_number) + + +class OffsetSweep(unittest.TestCase): + """Offset-sweep enumeration and physics, on a small mesh.""" + + DISTANCES = [3.0, 5.0] + OFFSETS_U = [-6.0, 0.0, 6.0] + + @classmethod + def setUpClass(cls): + # A deliberately coarse panel: this test is about the sweep, not the + # mesh. Same 22 x 12 m panel, 8 x 4 quads = 64 faces. + import sys + sys.path.insert(0, str(CASE_DIR / 'stl')) + from generate_panel import build_panel_mesh + + cls.stl_path = CASE_DIR / 'stl' / 'iss_panel_coarse.stl' + build_panel_mesh(n_u=8, n_v=4).save(str(cls.stl_path)) + + cls.output_dir = tempfile.mkdtemp(prefix='pyrpod_iss_sweep_') + mapping = coarse_panel_mapping(source_distances=cls.DISTANCES, + source_offsets_u=cls.OFFSETS_U, + source_offsets_v=[0.0]) + cls.study, cls.results = run_config(mapping, cls.output_dir, 'sweep') + cls.cases = list(cls.results.cases) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.output_dir, ignore_errors=True) + cls.stl_path.unlink(missing_ok=True) + + def _case(self, distance, offset_u): + for case in self.cases: + if (case.source_distance == distance + and case.source_offset_u == offset_u): + return case + raise AssertionError(f'no case at L={distance}, u={offset_u}') + + # ------------------------------------------------------------ enumeration + def test_case_count_is_the_product_of_the_swept_axes(self): + self.assertEqual(len(self.cases), + len(self.DISTANCES) * len(self.OFFSETS_U) * 1) + + def test_cases_are_ordered_distance_major_then_offset(self): + self.assertEqual( + [(case.source_distance, case.source_offset_u) + for case in self.cases], + [(distance, offset) for distance in self.DISTANCES + for offset in self.OFFSETS_U]) + + def test_case_ids_are_unique_and_name_their_parameters(self): + ids = [case.case_id for case in self.cases] + self.assertEqual(len(set(ids)), len(ids)) + self.assertEqual(self._case(3.0, -6.0).case_id, + 'case000_modelSimplified_L3_um6_v0') + self.assertEqual(self._case(5.0, 6.0).case_id, + 'case005_modelSimplified_L5_u6_v0') + + def test_each_case_wrote_its_own_jfh_and_artifacts(self): + for case in self.cases: + with self.subTest(case=case.case_id): + self.assertTrue(os.path.isfile(case.jfh_path)) + self.assertIn(case.case_id, case.jfh_path) + self.assertTrue(os.path.isfile(case.vtk_path)) + self.assertTrue( + os.path.isfile(case.surface_distribution_path)) + + # ---------------------------------------------------------------- physics + def test_the_source_translates_without_tilting_the_plume_axis(self): + for case in self.cases: + with self.subTest(case=case.case_id): + np.testing.assert_allclose( + case.plume_source_position, + [case.source_offset_u, case.source_offset_v, + case.source_distance], atol=1e-9) + dcm = np.asarray(case.plume_source_orientation).reshape(3, 3) + np.testing.assert_allclose(dcm[:, 0], -PANEL_N, atol=1e-9) + + def test_centered_cases_are_symmetric(self): + for distance in self.DISTANCES: + case = self._case(distance, 0.0) + with self.subTest(L=distance): + scale = case.normal_force * 11.0 + self.assertAlmostEqual(case.local_moment_v, 0.0, + delta=1e-9 * scale) + self.assertAlmostEqual(case.center_of_pressure_u, 0.0, + places=6) + + def test_offset_source_moves_the_center_of_pressure_with_it(self): + for distance in self.DISTANCES: + for offset in (-6.0, 6.0): + case = self._case(distance, offset) + with self.subTest(L=distance, u=offset): + # Inside the panel and well clear of its edges, the + # footprint is fully captured, so the CoP sits at the + # centerline intersection. + self.assertAlmostEqual(case.center_of_pressure_u, offset, + delta=0.15) + # Zero to within cross-product round-off on a 12 m span. + self.assertAlmostEqual(case.center_of_pressure_v, 0.0, + delta=1e-3) + + def test_positive_u_offset_gives_a_positive_moment_about_v(self): + # The documented sign convention (see pyrpod.mdao.surface_loads). + for distance in self.DISTANCES: + positive = self._case(distance, 6.0) + negative = self._case(distance, -6.0) + with self.subTest(L=distance): + self.assertGreater(positive.local_moment_v, 0.0) + self.assertLess(negative.local_moment_v, 0.0) + # Mirror symmetry of the panel about u = 0. + self.assertAlmostEqual(positive.local_moment_v, + -negative.local_moment_v, + delta=1e-6 * abs(positive.local_moment_v)) + self.assertAlmostEqual(positive.normal_force, + negative.normal_force, + delta=1e-6 * positive.normal_force) + + def test_peak_pressure_falls_with_stand_off(self): + for offset in self.OFFSETS_U: + with self.subTest(u=offset): + self.assertGreater(self._case(3.0, offset).max_pressure, + self._case(5.0, offset).max_pressure) + + def test_knudsen_tracks_the_swept_distance(self): + for distance in self.DISTANCES: + case = self._case(distance, 0.0) + with self.subTest(L=distance): + self.assertAlmostEqual(case.knudsen_number, 1.0 / distance, + places=12) + + # ----------------------------------------------------------------- output + def test_summary_csv_has_one_row_per_case_with_flat_columns(self): + with open(self.results.summary_csv_path, encoding='utf-8', + newline='') as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual(len(rows), len(self.cases)) + offsets = sorted({float(row['source_offset_u']) for row in rows}) + self.assertEqual(offsets, sorted(self.OFFSETS_U)) + for row in rows: + self.assertNotEqual(row['normal_force'], '') + self.assertNotEqual(row['local_moment_v'], '') + self.assertNotEqual(row['knudsen_number'], '') + + def test_plots_include_the_offset_trends(self): + written = self.study.plot() + names = {os.path.basename(path) for path in written} + for expected in ('normal_force_vs_offset_u.png', + 'moment_v_vs_offset_u.png', + 'peak_pressure_vs_offset_u.png', + 'cop_u_vs_offset_u.png', + 'normal_force_vs_distance.png'): + self.assertIn(expected, names) + for path in written: + self.assertTrue(os.path.isfile(path), path) + + +class TransverseOffsetSweep(unittest.TestCase): + """A v offset loads the u moment, mirroring the u-offset behavior.""" + + @classmethod + def setUpClass(cls): + import sys + sys.path.insert(0, str(CASE_DIR / 'stl')) + from generate_panel import build_panel_mesh + + cls.stl_path = CASE_DIR / 'stl' / 'iss_panel_coarse.stl' + build_panel_mesh(n_u=8, n_v=4).save(str(cls.stl_path)) + + cls.output_dir = tempfile.mkdtemp(prefix='pyrpod_iss_voffset_') + mapping = coarse_panel_mapping(source_distances=[3.0], + source_offsets_u=[0.0], + source_offsets_v=[-3.0, 0.0, 3.0]) + cls.study, cls.results = run_config(mapping, cls.output_dir, + 'voffset') + cls.cases = {case.source_offset_v: case for case in cls.results.cases} + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.output_dir, ignore_errors=True) + cls.stl_path.unlink(missing_ok=True) + + def test_three_cases_one_per_transverse_offset(self): + self.assertEqual(sorted(self.cases), [-3.0, 0.0, 3.0]) + + def test_positive_v_offset_gives_a_negative_moment_about_u(self): + # M = (v_off * v_hat) x (-F_n * n_hat) = -v_off * F_n * u_hat. + self.assertLess(self.cases[3.0].local_moment_u, 0.0) + self.assertGreater(self.cases[-3.0].local_moment_u, 0.0) + self.assertAlmostEqual(self.cases[0.0].local_moment_u, 0.0, + delta=1e-9 * self.cases[0.0].normal_force * 6.0) + + def test_center_of_pressure_follows_the_transverse_offset(self): + for offset in (-3.0, 3.0): + with self.subTest(v=offset): + self.assertAlmostEqual( + self.cases[offset].center_of_pressure_v, offset, + delta=0.15) + self.assertAlmostEqual( + self.cases[offset].center_of_pressure_u, 0.0, delta=1e-3) + + def test_transverse_plots_are_generated_when_v_is_swept(self): + names = {os.path.basename(path) for path in self.study.plot()} + for expected in ('normal_force_vs_offset_v.png', + 'moment_u_vs_offset_v.png', + 'cop_v_vs_offset_v.png'): + self.assertIn(expected, names) + + +class BackwardCompatibility(unittest.TestCase): + """Existing studies keep their behavior, paths and results.""" + + def test_committed_flat_plate_configurations_still_parse(self): + flat_plate = _TESTS_DIR.parent / 'case' / 'plume' / \ + 'plume_flat_plate_sweep' / 'study' + for name in ('flat_plate_baseline.yaml', 'flat_plate_sweep.yaml', + 'flat_plate_sweep_single_jfh.yaml'): + with self.subTest(config=name): + config = StudyConfig.from_yaml(flat_plate / name) + # Untouched defaults: the historical model, axis mode and + # zero offsets, and no Knudsen metadata. + self.assertEqual(config.plume_model, 'SimplifiedGasKinetics') + self.assertEqual(config.sweep.source_axis_mode, + 'aim_at_reference') + self.assertEqual(config.sweep.source_offsets_u, (0.0,)) + self.assertEqual(config.sweep.source_offsets_v, (0.0,)) + self.assertIsNone(config.knudsen) + self.assertFalse(config.output.write_surface_distribution) + + def test_aim_at_reference_case_ids_are_unchanged(self): + from pyrpod.mdao.plume_validation import case_id_for + self.assertEqual(case_id_for(0, 0.0, 4.0), 'case000_alpha0p0_d4') + self.assertEqual(case_id_for(12, -30.0, 2.0), + 'case012_alpham30p0_d2') + + def test_baseline_flat_plate_results_are_unchanged(self): + # The documented head-on baseline numbers of the existing case; the + # new panel-local fields are additions, not a redefinition. + flat_plate = (_TESTS_DIR.parent / 'case' / 'plume' + / 'plume_flat_plate_sweep' / 'study' + / 'flat_plate_baseline.yaml') + output_dir = tempfile.mkdtemp(prefix='pyrpod_flat_plate_regression_') + try: + results = TradeStudy.from_config( + flat_plate, output_dir=output_dir).run() + case = results.cases[0] + self.assertEqual(case.case_id, 'case000_alpha0p0_d4') + np.testing.assert_allclose(case.force, [0.0, 0.0, -2.763], + atol=5e-3) + self.assertAlmostEqual(case.max_pressure, 0.340, places=3) + self.assertAlmostEqual(case.max_heat_flux, 50.1, places=1) + self.assertAlmostEqual(case.coefficients['CF'], 0.0391, places=4) + # The additions are present and consistent with the old fields. + self.assertAlmostEqual(case.normal_force, -float(case.force[2]), + places=9) + self.assertIsNone(case.knudsen_number) + self.assertIsNone(case.surface_distribution_path) + finally: + shutil.rmtree(output_dir, ignore_errors=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/mdao/mdao_unit_test_07.py b/tests/mdao/mdao_unit_test_07.py new file mode 100644 index 0000000..3a88c87 --- /dev/null +++ b/tests/mdao/mdao_unit_test_07.py @@ -0,0 +1,825 @@ +# ======================== +# PyRPOD: tests/mdao/mdao_unit_test_07.py +# ======================== +# Unit tests for the ISS-panel study extensions: +# +# * plume-model dispatch -- both collisionless Cai variants are selectable +# by name, unknown names are rejected, and BOTH reduce to the same +# LocalFieldState structure (the interface the shared Maxwellian +# gas-surface formulas consume); +# * panel-local pose generation -- a centered normal-incidence source +# lands where it should with the expected DCM, a nonzero u offset +# translates the source parallel to the panel WITHOUT tilting the plume +# axis, and offsets default to zero; +# * sweep enumeration -- the offset grid produces exactly +# n_distances x n_u_offsets x n_v_offsets cases, in a deterministic +# order, and incompatible pose definitions are refused; +# * derived Knudsen metadata -- correct for both reference-length modes, +# cleanly absent when unconfigured, and clearly rejected when invalid; +# * panel-local coordinate transforms and load projections, including a +# hand-computed moment and normal-force case and the sign conventions; +# * the distribution-CSV schema. +# +# Nothing here touches DSMC: every model under test is collisionless, and +# the Knudsen number is asserted to be metadata that no solution reads. +# +# Run: python -m pytest mdao/mdao_unit_test_07.py (from tests/) + +import copy +import csv +import os +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pytest +import yaml + +from pyrpod.mdao.firing_plan import ( + build_case_firings, + pose_for, + pose_for_sweep_pose, + translated_pose_for, +) +from pyrpod.mdao.study_config import ( + KnudsenSpec, + StudyConfig, + StudyConfigError, + SweepPose, + SweepSpec, + TargetSpec, +) +from pyrpod.mdao.study_results import CaseResult +from pyrpod.mdao.surface_distribution import ( + DISTRIBUTION_COLUMNS, + distribution_rows, + write_surface_distribution, +) +from pyrpod.mdao.surface_loads import ( + integrate_component_loads, + panel_local_coordinates, + project_to_panel_frame, +) +from pyrpod.plume.gas_kinetics_models import ( + DEFAULT_PLUME_MODEL, + PLUME_MODELS, + PlumeModelError, + create_model, + kinetics_key_for, + local_field_state, + maxwellian_surface_loads, + model_name_for_kinetics, + resolve_model_name, +) + +_TESTS_DIR = Path(__file__).resolve().parents[1] +FLAT_PLATE_CASE = _TESTS_DIR.parent / 'case' / 'plume' / 'plume_flat_plate_sweep' +ISS_CASE = _TESTS_DIR.parent / 'case' / 'plume' / 'iss_panel_thesis' +BASELINE_YAML = FLAT_PLATE_CASE / 'study' / 'flat_plate_baseline.yaml' + +# The case's own thruster definition (tcd/tdf.csv, thruster type ARG). +THRUSTER = {'d': 1.0, 've': 577.0684534784414, 'R': 208.13, + 'gamma': 1.6666666666666667, 'Te': 200, 'n': 1.0e20} + +# Panel-local basis of the ISS-panel case: u = +X, v = +Y, n = +Z. +PANEL_U = np.array([1.0, 0.0, 0.0]) +PANEL_V = np.array([0.0, 1.0, 0.0]) +PANEL_N = np.array([0.0, 0.0, 1.0]) + + +def baseline_mapping(): + return yaml.safe_load(BASELINE_YAML.read_text(encoding='utf-8')) + + +def from_mapping(data): + return StudyConfig.from_mapping(data, source_path=str(BASELINE_YAML)) + + +def panel_target(): + return TargetSpec.from_mapping( + {'reference_point': [0.0, 0.0, 0.0], + 'normal': [0.0, 0.0, 1.0], 'tangent': [1.0, 0.0, 0.0]}, 'panel.stl') + + +def sweep_mapping(**overrides): + """A parallel_to_normal sweep mapping with the given overrides.""" + data = {'source_axis_mode': 'parallel_to_normal', + 'source_distances': [4.0], 'n_firings': 1, 'thrusters': [1]} + data.update(overrides) + return data + + +# ---------------------------------------------------------------- model dispatch +class PlumeModelSelection(unittest.TestCase): + """Both Cai variants are selectable; nothing else is.""" + + def test_registry_holds_exactly_the_two_collisionless_models(self): + self.assertEqual(sorted(PLUME_MODELS), + ['CollisionlessGasKinetics', 'SimplifiedGasKinetics']) + + def test_supported_names_resolve(self): + for name in PLUME_MODELS: + with self.subTest(model=name): + self.assertEqual(resolve_model_name(name), name) + + def test_unknown_name_is_rejected_never_defaulted(self): + for name in ('DSMC', 'Collisionless', 'simplifiedgaskinetics', ''): + with self.subTest(model=name): + with pytest.raises(PlumeModelError): + resolve_model_name(name) + + def test_omitted_name_takes_the_historical_default(self): + self.assertEqual(resolve_model_name(None), DEFAULT_PLUME_MODEL) + self.assertEqual(DEFAULT_PLUME_MODEL, 'SimplifiedGasKinetics') + + def test_kinetics_keys_round_trip(self): + for key, name in (('Simplified', 'SimplifiedGasKinetics'), + ('Collisionless', 'CollisionlessGasKinetics')): + with self.subTest(key=key): + self.assertEqual(model_name_for_kinetics(key), name) + self.assertEqual(kinetics_key_for(name), key) + + def test_disabled_and_unknown_kinetics_keys_are_reported(self): + with pytest.raises(PlumeModelError) as excinfo: + model_name_for_kinetics('None') + self.assertIn('disables', str(excinfo.value)) + with pytest.raises(PlumeModelError): + model_name_for_kinetics('DSMC') + + def test_study_configuration_accepts_both_and_rejects_others(self): + for name in PLUME_MODELS: + with self.subTest(model=name): + data = baseline_mapping() + data['plume_model']['name'] = name + self.assertEqual(from_mapping(data).plume_model, name) + + data = baseline_mapping() + data['plume_model']['name'] = 'BoltzmannGasKinetics' + with pytest.raises(StudyConfigError): + from_mapping(data) + + +class CommonLocalFieldState(unittest.TestCase): + """Both models reduce to one field-state structure the GSI consumes.""" + + FIELDS = ('number_density', 'mass_density', 'axial_velocity', + 'radial_velocity', 'velocity_magnitude', 'temperature', + 'speed_ratio') + + def _state(self, model_name, theta): + model = create_model(model_name, 4.0, theta, THRUSTER, 300.0, 1.0) + return local_field_state(model) + + def test_both_models_populate_every_field(self): + for model_name in PLUME_MODELS: + for theta in (0.0, 0.3): + with self.subTest(model=model_name, theta=theta): + state = self._state(model_name, theta) + for name in self.FIELDS: + value = getattr(state, name) + self.assertTrue(np.isfinite(value), + f'{name} is not finite') + self.assertGreaterEqual(value, 0.0) + self.assertEqual(state.velocity, + (state.axial_velocity, + state.radial_velocity)) + self.assertIn('number_density', state.to_dict()) + + def test_velocity_magnitude_is_the_axial_radial_resultant(self): + state = self._state('CollisionlessGasKinetics', 0.3) + self.assertAlmostEqual( + state.velocity_magnitude, + float(np.hypot(state.axial_velocity, state.radial_velocity)), + places=9) + + def test_centerline_is_flagged_and_purely_axial(self): + for model_name in PLUME_MODELS: + with self.subTest(model=model_name): + state = self._state(model_name, 0.0) + self.assertTrue(state.on_centerline) + self.assertEqual(state.radial_velocity, 0.0) + + def test_models_agree_on_the_centerline_and_differ_off_it(self): + # The exact closed forms are shared, so the centerline must match + # bit-for-bit; off-axis the full model integrates the exit disk and + # must therefore differ -- that difference IS the model selection. + centerline = [self._state(name, 0.0) for name in PLUME_MODELS] + self.assertEqual(centerline[0].to_dict(), centerline[1].to_dict()) + + simplified = self._state('SimplifiedGasKinetics', 0.3) + full = self._state('CollisionlessGasKinetics', 0.3) + self.assertNotAlmostEqual(simplified.number_density, + full.number_density, places=6) + + def test_shared_gsi_consumes_either_state_identically(self): + # One implementation of the Maxwellian wall formulas, fed by both + # models: no per-model duplication of pressure/shear/heat logic. + for model_name in PLUME_MODELS: + with self.subTest(model=model_name): + state = self._state(model_name, 0.2) + pressure, shear, heat = maxwellian_surface_loads( + state, sigma=1.0, T_w=300.0, R=THRUSTER['R'], + gamma=THRUSTER['gamma'], incidence=0.2) + self.assertGreater(pressure, 0.0) + self.assertGreater(heat, 0.0) + self.assertTrue(np.isfinite(shear)) + + +# --------------------------------------------------------------- pose generation +class PanelLocalPoseGeneration(unittest.TestCase): + """parallel_to_normal translates the source without tilting the axis.""" + + def test_centered_normal_incidence_pose(self): + position, dcm = translated_pose_for(4.0, 0.0, 0.0, [0, 0, 0], + PANEL_N, PANEL_U) + np.testing.assert_allclose(position, [0.0, 0.0, 4.0], atol=1e-12) + # First column is the thruster axis, anti-parallel to the normal. + np.testing.assert_allclose(dcm[:, 0], -PANEL_N, atol=1e-12) + np.testing.assert_allclose(dcm[:, 1], PANEL_V, atol=1e-12) + np.testing.assert_allclose(dcm[:, 2], PANEL_U, atol=1e-12) + + def test_dcm_is_a_proper_orthonormal_rotation(self): + for offset_u, offset_v in ((0.0, 0.0), (-9.0, 0.0), (5.5, 2.0)): + with self.subTest(u=offset_u, v=offset_v): + _, dcm = translated_pose_for(4.0, offset_u, offset_v, + [0, 0, 0], PANEL_N, PANEL_U) + np.testing.assert_allclose(dcm.T @ dcm, np.eye(3), atol=1e-12) + self.assertAlmostEqual(float(np.linalg.det(dcm)), 1.0, + places=12) + + def test_u_offset_translates_parallel_and_leaves_the_axis_alone(self): + _, reference_dcm = translated_pose_for(4.0, 0.0, 0.0, [0, 0, 0], + PANEL_N, PANEL_U) + for offset_u in (-9.0, -4.5, 4.5, 9.0): + with self.subTest(u=offset_u): + position, dcm = translated_pose_for( + 4.0, offset_u, 0.0, [0, 0, 0], PANEL_N, PANEL_U) + # Translated along u only; stand-off unchanged. + np.testing.assert_allclose(position, + [offset_u, 0.0, 4.0], atol=1e-12) + # The axis is still exactly -n: NOT re-aimed at the center. + np.testing.assert_allclose(dcm[:, 0], -PANEL_N, atol=1e-12) + np.testing.assert_allclose(dcm, reference_dcm, atol=1e-12) + + def test_v_offset_translates_along_the_transverse_axis(self): + position, dcm = translated_pose_for(6.0, 0.0, 3.0, [0, 0, 0], + PANEL_N, PANEL_U) + np.testing.assert_allclose(position, [0.0, 3.0, 6.0], atol=1e-12) + np.testing.assert_allclose(dcm[:, 0], -PANEL_N, atol=1e-12) + + def test_zero_offset_reproduces_the_aim_at_reference_head_on_pose(self): + # The new mode EXTENDS the old convention rather than redefining it. + aimed_position, aimed_dcm = pose_for(0.0, 4.0, [0, 0, 0], + PANEL_N, PANEL_U) + position, dcm = translated_pose_for(4.0, 0.0, 0.0, [0, 0, 0], + PANEL_N, PANEL_U) + np.testing.assert_allclose(position, aimed_position, atol=1e-12) + np.testing.assert_allclose(dcm, aimed_dcm, atol=1e-12) + + def test_aimed_mode_is_untouched_by_the_new_dispatcher(self): + pose = SweepPose(plate_angle_deg=30.0, source_distance=5.0, + axis_mode='aim_at_reference') + target = panel_target() + position, dcm = pose_for_sweep_pose(pose, target) + expected = pose_for(30.0, 5.0, target.reference_point, target.normal, + target.tangent) + np.testing.assert_allclose(position, expected[0], atol=1e-12) + np.testing.assert_allclose(dcm, expected[1], atol=1e-12) + + def test_generated_firings_carry_the_offsets(self): + sweep = SweepSpec.from_mapping( + sweep_mapping(source_offsets_u=[7.0], source_offsets_v=[-2.0])) + pose = sweep.sweep_poses[0] + firings = build_case_firings(sweep, panel_target(), + pose.plate_angle_deg, + pose.source_distance, pose=pose) + self.assertEqual(len(firings), 1) + self.assertEqual(firings[0].source_offset_u, 7.0) + self.assertEqual(firings[0].source_offset_v, -2.0) + self.assertEqual(firings[0].source_axis_mode, 'parallel_to_normal') + np.testing.assert_allclose(firings[0].position, [7.0, -2.0, 4.0], + atol=1e-12) + + +class OffsetSweepEnumeration(unittest.TestCase): + """Case count and order for the offset grid.""" + + def test_offsets_default_to_zero(self): + sweep = SweepSpec.from_mapping({'source_distances': [4.0]}) + self.assertEqual(sweep.source_offsets_u, (0.0,)) + self.assertEqual(sweep.source_offsets_v, (0.0,)) + self.assertEqual(sweep.source_axis_mode, 'aim_at_reference') + for pose in sweep.sweep_poses: + self.assertEqual(pose.source_offset_u, 0.0) + self.assertEqual(pose.source_offset_v, 0.0) + + def test_case_count_is_the_product_of_the_three_axes(self): + distances = [2.0, 4.0, 6.0] + offsets_u = [-9.0, -4.5, 0.0, 4.5, 9.0] + offsets_v = [0.0, 3.0] + sweep = SweepSpec.from_mapping(sweep_mapping( + source_distances=distances, source_offsets_u=offsets_u, + source_offsets_v=offsets_v)) + self.assertEqual(len(sweep.sweep_poses), + len(distances) * len(offsets_u) * len(offsets_v)) + self.assertEqual(sweep.total_firings, len(sweep.sweep_poses)) + + def test_pose_order_is_distance_then_u_then_v(self): + sweep = SweepSpec.from_mapping(sweep_mapping( + source_distances=[2.0, 4.0], source_offsets_u=[-1.0, 1.0], + source_offsets_v=[0.0, 5.0])) + self.assertEqual( + [(p.source_distance, p.source_offset_u, p.source_offset_v) + for p in sweep.sweep_poses], + [(2.0, -1.0, 0.0), (2.0, -1.0, 5.0), + (2.0, 1.0, 0.0), (2.0, 1.0, 5.0), + (4.0, -1.0, 0.0), (4.0, -1.0, 5.0), + (4.0, 1.0, 0.0), (4.0, 1.0, 5.0)]) + + def test_default_offsets_preserve_the_historical_pose_order(self): + sweep = SweepSpec.from_mapping( + {'plate_angles_deg': [-10.0, 10.0], + 'source_distances': [2.0, 4.0]}) + self.assertEqual(sweep.poses, + ((-10.0, 2.0), (10.0, 2.0), + (-10.0, 4.0), (10.0, 4.0))) + + def test_non_finite_offsets_are_rejected(self): + for bad in ([float('nan')], [float('inf')], [0.0, float('-inf')]): + with self.subTest(offsets=bad): + with pytest.raises(StudyConfigError): + SweepSpec.from_mapping(sweep_mapping( + source_offsets_u=bad)) + + def test_empty_offset_list_is_rejected(self): + with pytest.raises(StudyConfigError): + SweepSpec.from_mapping(sweep_mapping(source_offsets_u=[])) + + def test_unknown_axis_mode_is_rejected(self): + with pytest.raises(StudyConfigError) as excinfo: + SweepSpec.from_mapping({'source_distances': [4.0], + 'source_axis_mode': 'follow_the_plume'}) + self.assertIn('source_axis_mode', str(excinfo.value)) + + def test_incompatible_pose_definitions_are_never_silently_combined(self): + # Offsets while aiming at the reference point: the offset has no + # meaning when the axis is re-aimed every time. + with pytest.raises(StudyConfigError) as excinfo: + SweepSpec.from_mapping({'source_distances': [4.0], + 'source_offsets_u': [3.0]}) + self.assertIn('parallel_to_normal', str(excinfo.value)) + + # An approach angle with a fixed axis: the angle cannot be realized. + with pytest.raises(StudyConfigError) as excinfo: + SweepSpec.from_mapping(sweep_mapping( + plate_angles_deg=[0.0, 30.0])) + self.assertIn('plate_angles_deg', str(excinfo.value)) + + +# ------------------------------------------------------------ Knudsen metadata +class DerivedKnudsenMetadata(unittest.TestCase): + """Kn is computed correctly, and is metadata only.""" + + def test_source_distance_reference_length(self): + spec = KnudsenSpec.from_mapping({ + 'mean_free_path_m': 1.0, 'reference_length': 'source_distance'}) + self.assertEqual(spec.reference_mode, 'source_distance') + self.assertEqual(spec.definition, 'lambda_over_source_distance') + for distance, expected in ((2.0, 0.5), (4.0, 0.25), (10.0, 0.1)): + with self.subTest(L=distance): + self.assertAlmostEqual(spec.knudsen_number(distance), + expected, places=12) + self.assertEqual(spec.reference_length_for(distance), distance) + + def test_explicit_reference_length(self): + spec = KnudsenSpec.from_mapping({ + 'mean_free_path_m': 0.1, 'reference_length_m': 1.0, + 'definition': 'lambda_over_nozzle_diameter'}) + self.assertEqual(spec.reference_mode, 'explicit') + self.assertEqual(spec.definition, 'lambda_over_nozzle_diameter') + # Fixed length: the same Kn whatever the case's stand-off is. + for distance in (2.0, 4.0, 100.0): + with self.subTest(L=distance): + self.assertAlmostEqual(spec.knudsen_number(distance), 0.1, + places=12) + self.assertEqual(spec.reference_length_for(distance), 1.0) + + def test_the_documented_kn_labels_are_reproducible(self): + # One study per label, nozzle diameter D = 1 m as the reference. + for mean_free_path, label in ((100.0, 100.0), (10.0, 10.0), + (1.0, 1.0), (0.1, 0.1), (0.01, 0.01)): + with self.subTest(Kn=label): + spec = KnudsenSpec.from_mapping({ + 'mean_free_path_m': mean_free_path, + 'reference_length_m': 1.0}) + self.assertAlmostEqual(spec.knudsen_number(4.0), label, + places=12) + + def test_omitted_block_yields_no_spec_and_no_fields(self): + self.assertIsNone(KnudsenSpec.from_mapping(None)) + self.assertIsNone(KnudsenSpec.from_mapping({})) + config = from_mapping(baseline_mapping()) + self.assertIsNone(config.knudsen) + self.assertNotIn('knudsen', config.provenance()) + + def test_missing_mean_free_path_is_refused_never_inferred(self): + with pytest.raises(StudyConfigError) as excinfo: + KnudsenSpec.from_mapping({'reference_length': 'source_distance'}) + message = str(excinfo.value) + self.assertIn('mean_free_path_m', message) + self.assertIn('never infers', message) + + def test_non_positive_or_non_finite_mean_free_path_is_rejected(self): + for value in (0.0, -1.0, float('nan'), float('inf'), 'thin'): + with self.subTest(mean_free_path=value): + with pytest.raises(StudyConfigError): + KnudsenSpec.from_mapping({ + 'mean_free_path_m': value, + 'reference_length': 'source_distance'}) + + def test_exactly_one_reference_length_mode_is_required(self): + # Neither. + with pytest.raises(StudyConfigError) as excinfo: + KnudsenSpec.from_mapping({'mean_free_path_m': 1.0}) + self.assertIn('EXACTLY ONE', str(excinfo.value)) + # Both. + with pytest.raises(StudyConfigError) as excinfo: + KnudsenSpec.from_mapping({'mean_free_path_m': 1.0, + 'reference_length': 'source_distance', + 'reference_length_m': 1.0}) + self.assertIn('EXACTLY ONE', str(excinfo.value)) + + def test_unknown_symbolic_reference_length_is_rejected(self): + with pytest.raises(StudyConfigError) as excinfo: + KnudsenSpec.from_mapping({'mean_free_path_m': 1.0, + 'reference_length': 'nozzle_diameter'}) + self.assertIn('reference_length_m', str(excinfo.value)) + + def test_invalid_explicit_reference_length_is_rejected(self): + for value in (0.0, -2.0, float('inf'), 'wide'): + with self.subTest(reference_length_m=value): + with pytest.raises(StudyConfigError): + KnudsenSpec.from_mapping({'mean_free_path_m': 1.0, + 'reference_length_m': value}) + + def test_provenance_records_kn_as_derived_metadata(self): + data = baseline_mapping() + data['knudsen'] = {'mean_free_path_m': 1.0, + 'reference_length': 'source_distance'} + provenance = from_mapping(data).provenance() + self.assertIn('knudsen', provenance) + self.assertIn('derived metadata only', + provenance['knudsen']['role']) + + +# ---------------------------------------------------- panel-local projections +class PanelLocalCoordinates(unittest.TestCase): + + def test_known_coordinates_are_recovered(self): + points = np.array([[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], + [0.0, -2.0, 0.0], [-1.5, 4.0, 0.0]]) + local_u, local_v = panel_local_coordinates(points, [0, 0, 0], + PANEL_U, PANEL_V) + np.testing.assert_allclose(local_u, [0.0, 3.0, 0.0, -1.5], atol=1e-12) + np.testing.assert_allclose(local_v, [0.0, 0.0, -2.0, 4.0], atol=1e-12) + + def test_coordinates_are_measured_from_the_reference_point(self): + points = np.array([[5.0, 7.0, 0.0]]) + local_u, local_v = panel_local_coordinates(points, [2.0, 3.0, 0.0], + PANEL_U, PANEL_V) + np.testing.assert_allclose(local_u, [3.0], atol=1e-12) + np.testing.assert_allclose(local_v, [4.0], atol=1e-12) + + def test_a_rotated_basis_still_recovers_the_panel_coordinates(self): + # A panel in the X-Z plane: u = +X, n = -Y, v = n x u = +Z. + u_hat = np.array([1.0, 0.0, 0.0]) + n_hat = np.array([0.0, -1.0, 0.0]) + v_hat = np.cross(n_hat, u_hat) + np.testing.assert_allclose(v_hat, [0.0, 0.0, 1.0], atol=1e-12) + local_u, local_v = panel_local_coordinates( + np.array([[2.0, 0.0, -3.0]]), [0, 0, 0], u_hat, v_hat) + np.testing.assert_allclose(local_u, [2.0], atol=1e-12) + np.testing.assert_allclose(local_v, [-3.0], atol=1e-12) + + +def _loads(centroids, pressures, *, areas=None, reference=(0.0, 0.0, 0.0), + source=(0.0, 0.0, 4.0)): + """Integrate a synthetic pressure-only load on a +Z-facing panel.""" + centroids = np.asarray(centroids, dtype=float) + n = len(centroids) + areas = np.ones(n) if areas is None else np.asarray(areas, dtype=float) + pressures = np.asarray(pressures, dtype=float) + return integrate_component_loads( + component_name='panel', face_indices=np.arange(n), + centroids=centroids, unit_normals=np.tile(PANEL_N, (n, 1)), + areas=areas, pressures=pressures, shear_stresses=np.zeros(n), + heat_fluxes=np.zeros(n), strikes=(pressures != 0.0).astype(float), + moment_reference_point=reference, source_position=source) + + +class PanelLoadProjection(unittest.TestCase): + """Hand-computed normal force, moments and center of pressure.""" + + def test_normal_force_is_positive_into_the_panel(self): + # 10 Pa over 1 m^2 on a +Z-facing face: the plume pushes along -Z, + # so the global force is -10 Z and the normal force is +10 N. + loads = _loads([[0.0, 0.0, 0.0]], [10.0]) + np.testing.assert_allclose(loads.force, [0.0, 0.0, -10.0], atol=1e-12) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(panel.normal_force, 10.0, places=12) + self.assertAlmostEqual(panel.local_force_u, 0.0, places=12) + self.assertAlmostEqual(panel.local_force_v, 0.0, places=12) + + def test_symmetric_centered_load_gives_no_moment_about_the_center(self): + loads = _loads([[-2.0, 0.0, 0.0], [2.0, 0.0, 0.0], + [0.0, -2.0, 0.0], [0.0, 2.0, 0.0]], + [7.0, 7.0, 7.0, 7.0]) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(panel.local_moment_u, 0.0, places=12) + self.assertAlmostEqual(panel.local_moment_v, 0.0, places=12) + self.assertAlmostEqual(panel.local_moment_n, 0.0, places=12) + self.assertAlmostEqual(panel.center_of_pressure_u, 0.0, places=12) + self.assertAlmostEqual(panel.center_of_pressure_v, 0.0, places=12) + self.assertAlmostEqual(panel.normal_force, 28.0, places=12) + + def test_off_center_load_gives_the_right_moment_sign_and_magnitude(self): + # One 10 Pa face of 1 m^2 at u = +2: F = -10 Z, arm = +2 X, so + # M = (2 X) x (-10 Z) = +20 Y = +20 v. A source displaced toward + # +u therefore gives a POSITIVE local_moment_v. + loads = _loads([[2.0, 0.0, 0.0]], [10.0]) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(panel.local_moment_v, 20.0, places=12) + self.assertAlmostEqual(panel.local_moment_u, 0.0, places=12) + self.assertAlmostEqual(panel.center_of_pressure_u, 2.0, places=12) + + # Mirrored offset: equal magnitude, opposite sign. + mirrored = project_to_panel_frame(_loads([[-2.0, 0.0, 0.0]], [10.0]), + PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(mirrored.local_moment_v, -20.0, places=12) + self.assertAlmostEqual(mirrored.center_of_pressure_u, -2.0, places=12) + + def test_transverse_offset_loads_the_u_moment(self): + # A face at v = +3: M = (3 Y) x (-10 Z) = -30 X = -30 u. + loads = _loads([[0.0, 3.0, 0.0]], [10.0]) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(panel.local_moment_u, -30.0, places=12) + self.assertAlmostEqual(panel.local_moment_v, 0.0, places=12) + self.assertAlmostEqual(panel.center_of_pressure_v, 3.0, places=12) + + def test_center_of_pressure_is_measured_from_the_moment_reference(self): + loads = _loads([[5.0, 0.0, 0.0]], [10.0], reference=(3.0, 0.0, 0.0)) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertAlmostEqual(panel.center_of_pressure_u, 2.0, places=12) + + def test_unavailable_center_of_pressure_projects_to_none(self): + loads = _loads([[1.0, 0.0, 0.0]], [0.0]) + panel = project_to_panel_frame(loads, PANEL_U, PANEL_V, PANEL_N) + self.assertIsNone(panel.center_of_pressure_u) + self.assertIsNone(panel.center_of_pressure_v) + self.assertAlmostEqual(panel.normal_force, 0.0, places=12) + + +# ------------------------------------------------------ distribution export +class SurfaceDistributionExport(unittest.TestCase): + + def _mesh(self, n=6): + centroids = np.column_stack([np.linspace(-5.0, 5.0, n), + np.linspace(-2.0, 2.0, n), + np.zeros(n)]) + return centroids, np.full(n, 0.5) + + def test_rows_cover_every_face_with_the_required_columns(self): + centroids, areas = self._mesh() + n = len(centroids) + rows = distribution_rows( + np.arange(n), centroids, areas, np.linspace(1.0, 2.0, n), + np.linspace(0.1, 0.2, n), np.linspace(10.0, 20.0, n), + np.ones(n), reference_point=[0, 0, 0], u_hat=PANEL_U, + v_hat=PANEL_V) + self.assertEqual(len(rows), n) + for row in rows: + self.assertEqual(set(row), set(DISTRIBUTION_COLUMNS)) + + def test_local_coordinates_match_the_panel_basis(self): + centroids, areas = self._mesh() + n = len(centroids) + rows = distribution_rows( + np.arange(n), centroids, areas, np.zeros(n), np.zeros(n), + np.zeros(n), None, reference_point=[0, 0, 0], u_hat=PANEL_U, + v_hat=PANEL_V) + np.testing.assert_allclose([row['local_u'] for row in rows], + centroids[:, 0], atol=1e-12) + np.testing.assert_allclose([row['local_v'] for row in rows], + centroids[:, 1], atol=1e-12) + + def test_component_subset_preserves_the_original_face_indices(self): + centroids, areas = self._mesh() + n = len(centroids) + rows = distribution_rows( + [1, 4], centroids, areas, np.arange(n, dtype=float), + np.zeros(n), np.zeros(n), None, reference_point=[0, 0, 0], + u_hat=PANEL_U, v_hat=PANEL_V) + self.assertEqual([row['face_index'] for row in rows], [1, 4]) + self.assertEqual([row['pressure'] for row in rows], [1.0, 4.0]) + + def test_values_are_written_without_interpolation(self): + centroids, areas = self._mesh() + n = len(centroids) + pressures = np.linspace(3.0, 9.0, n) + rows = distribution_rows( + np.arange(n), centroids, areas, pressures, np.zeros(n), + np.zeros(n), None, reference_point=[0, 0, 0], u_hat=PANEL_U, + v_hat=PANEL_V) + with tempfile.TemporaryDirectory() as tmp: + path = write_surface_distribution( + os.path.join(tmp, 'dist.csv'), rows, + {'study_name': 'unit', 'plume_model': 'SimplifiedGasKinetics'}) + with open(path, encoding='utf-8', newline='') as handle: + written = list(csv.DictReader(handle)) + self.assertEqual(len(written), n) + self.assertEqual(list(written[0]), list(DISTRIBUTION_COLUMNS)) + np.testing.assert_allclose( + [float(row['pressure']) for row in written], pressures, + rtol=0, atol=0) + + sidecar = os.path.splitext(path)[0] + '.meta.json' + self.assertTrue(os.path.isfile(sidecar)) + import json + document = json.loads(Path(sidecar).read_text(encoding='utf-8')) + self.assertEqual(document['units']['pressure'], 'Pa') + self.assertEqual(document['units']['local_u'], 'm') + self.assertEqual(document['n_faces'], n) + self.assertEqual(document['plume_model'], 'SimplifiedGasKinetics') + + def test_empty_distribution_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + with pytest.raises(ValueError): + write_surface_distribution(os.path.join(tmp, 'x.csv'), []) + + +# --------------------------------------------------------- result serialization +def _case_result(**overrides): + fields = dict( + study_name='s', case_id='case000', component='panel', firing_id=1, + geometry_id='panel.stl', mesh_faces=2, component_faces=2, + component_area=1.0, coordinate_system='global', units={'length': 'm'}, + plume_source_position=[0.0, 0.0, 4.0], + plume_source_orientation=[1.0] + [0.0] * 8, + target_normal=[0.0, 0.0, 1.0], target_tangent=[1.0, 0.0, 0.0], + target_reference_point=[0.0, 0.0, 0.0], plate_angle_deg=0.0, + source_distance=4.0, firing_duration_s=1.0, thrusters=[1], + plume_model='CollisionlessGasKinetics', plume_model_parameters={}, + pressure_force=[0.0, 0.0, -1.0], shear_force=[0.0, 0.0, 0.0], + force=[0.0, 0.0, -1.0], force_magnitude=1.0, + moment_reference_point=[0.0, 0.0, 0.0], + pressure_moment=[0.0, 0.0, 0.0], shear_moment=[0.0, 0.0, 0.0], + moment=[0.0, 2.0, 0.0], moment_magnitude=2.0, + center_of_pressure=[2.0, 0.0, 0.0], center_of_pressure_status='ok', + residual_couple=0.0, pressure_weighted_centroid=[2.0, 0.0, 0.0], + max_pressure=0.3, max_shear_stress=0.03, max_heat_flux=50.0, + total_heat_load=100.0, affected_area=0.5, struck_faces=1) + fields.update(overrides) + return CaseResult(**fields) + + +class ResultSchemaExtensions(unittest.TestCase): + + def test_new_fields_default_safely(self): + # Every addition is optional, so an older-style construction works. + case = _case_result() + self.assertEqual(case.source_offset_u, 0.0) + self.assertEqual(case.source_offset_v, 0.0) + self.assertEqual(case.source_axis_mode, 'aim_at_reference') + self.assertIsNone(case.normal_force) + self.assertIsNone(case.knudsen_number) + self.assertIsNone(case.surface_distribution_path) + + def test_model_variant_is_derived_from_the_model_name(self): + self.assertEqual(_case_result().model_variant, 'Collisionless') + self.assertEqual( + _case_result(plume_model='SimplifiedGasKinetics').model_variant, + 'Simplified') + + def test_csv_row_carries_the_new_metadata_flat(self): + case = _case_result( + source_offset_u=5.5, source_offset_v=-1.0, + source_axis_mode='parallel_to_normal', normal_force=2.5, + local_moment_u=0.0, local_moment_v=13.75, local_moment_n=0.0, + center_of_pressure_u=5.4, center_of_pressure_v=-1.0, + knudsen_number=0.25, mean_free_path=1.0, + knudsen_reference_length=4.0, + knudsen_definition='lambda_over_source_distance', + surface_distribution_path='/tmp/dist.csv') + row = case.to_row() + self.assertEqual(row['source_offset_u'], 5.5) + self.assertEqual(row['source_offset_v'], -1.0) + self.assertEqual(row['source_axis_mode'], 'parallel_to_normal') + self.assertEqual(row['model_variant'], 'Collisionless') + self.assertEqual(row['normal_force'], 2.5) + self.assertEqual(row['local_moment_v'], 13.75) + self.assertEqual(row['center_of_pressure_u'], 5.4) + self.assertEqual(row['knudsen_number'], 0.25) + self.assertEqual(row['knudsen_definition'], + 'lambda_over_source_distance') + self.assertEqual(row['surface_distribution_path'], '/tmp/dist.csv') + # Flat: no nested containers or arrays in a CSV row. + for key, value in row.items(): + self.assertNotIsInstance(value, (list, dict, tuple, np.ndarray), + f'column {key} is not flat') + + def test_absent_optional_values_leave_empty_columns(self): + row = _case_result().to_row() + for column in ('normal_force', 'local_moment_v', 'knudsen_number', + 'mean_free_path', 'knudsen_reference_length', + 'knudsen_definition', 'surface_distribution_path'): + self.assertEqual(row[column], '', f'{column} should be empty') + + def test_json_form_keeps_model_and_knudsen_provenance(self): + case = _case_result(knudsen_number=0.25, mean_free_path=1.0, + knudsen_definition='lambda_over_source_distance') + document = case.to_dict() + self.assertEqual(document['plume_model'], 'CollisionlessGasKinetics') + self.assertEqual(document['knudsen_number'], 0.25) + self.assertEqual(document['knudsen_definition'], + 'lambda_over_source_distance') + + def test_quantity_exposes_the_new_comparable_scalars(self): + case = _case_result(normal_force=2.5, local_moment_v=13.75, + center_of_pressure_u=5.4, knudsen_number=0.25) + self.assertEqual(case.quantity('normal_force'), 2.5) + self.assertEqual(case.quantity('local_moment_v'), 13.75) + self.assertEqual(case.quantity('center_of_pressure_u'), 5.4) + self.assertEqual(case.quantity('knudsen_number'), 0.25) + # Unavailable quantities stay None rather than becoming zero. + self.assertIsNone(_case_result().quantity('normal_force')) + self.assertIsNone(case.quantity('not_a_quantity')) + + +# ------------------------------------------------- committed ISS configurations +class CommittedISSPanelConfigurations(unittest.TestCase): + + def _config(self, name): + return StudyConfig.from_yaml(ISS_CASE / 'study' / name) + + def test_baseline_configurations_differ_only_in_the_model(self): + simplified = self._config('iss_panel_baseline_simplified.yaml') + full = self._config('iss_panel_baseline_full_cai.yaml') + self.assertEqual(simplified.plume_model, 'SimplifiedGasKinetics') + self.assertEqual(full.plume_model, 'CollisionlessGasKinetics') + # Same geometry, same pose, same loads definition. + self.assertEqual(simplified.sweep.sweep_poses, + full.sweep.sweep_poses) + np.testing.assert_allclose(simplified.target.reference_point, + full.target.reference_point) + self.assertEqual(simplified.loads.normalization.reference_area, + full.loads.normalization.reference_area) + + def test_baseline_is_a_centered_parallel_to_normal_pose(self): + config = self._config('iss_panel_baseline_simplified.yaml') + self.assertEqual(config.sweep.source_axis_mode, 'parallel_to_normal') + self.assertEqual(config.n_cases, 1) + pose = config.sweep.sweep_poses[0] + self.assertEqual((pose.source_distance, pose.source_offset_u, + pose.source_offset_v), (4.0, 0.0, 0.0)) + position, dcm = pose_for_sweep_pose(pose, config.target) + np.testing.assert_allclose(position, [0.0, 0.0, 4.0], atol=1e-12) + np.testing.assert_allclose(dcm[:, 0], [0.0, 0.0, -1.0], atol=1e-12) + + def test_panel_basis_is_the_documented_one(self): + config = self._config('iss_panel_baseline_simplified.yaml') + u_hat, v_hat, n_hat = config.target.local_basis() + np.testing.assert_allclose(u_hat, PANEL_U, atol=1e-12) + np.testing.assert_allclose(v_hat, PANEL_V, atol=1e-12) + np.testing.assert_allclose(n_hat, PANEL_N, atol=1e-12) + np.testing.assert_allclose(np.cross(u_hat, v_hat), n_hat, atol=1e-12) + + def test_sweep_enumerates_distances_times_offsets(self): + config = self._config('iss_panel_offset_distance_sweep.yaml') + self.assertEqual(config.sweep.mode, 'per_case') + self.assertEqual(len(config.sweep.source_distances), 3) + self.assertEqual(len(config.sweep.source_offsets_u), 5) + self.assertEqual(len(config.sweep.source_offsets_v), 1) + self.assertEqual(config.n_cases, 15) + self.assertEqual(len(config.sweep.sweep_poses), 15) + + def test_sweep_configures_distributions_plots_and_knudsen(self): + config = self._config('iss_panel_offset_distance_sweep.yaml') + self.assertTrue(config.output.write_surface_distribution) + self.assertTrue(config.output.write_plots) + self.assertTrue(config.output.write_distribution_plots) + self.assertIsNotNone(config.knudsen) + self.assertEqual(config.knudsen.reference_mode, 'source_distance') + self.assertAlmostEqual(config.knudsen.knudsen_number(2.0), 0.5) + self.assertAlmostEqual(config.knudsen.knudsen_number(4.0), 0.25) + + def test_offsets_stay_within_the_panel(self): + config = self._config('iss_panel_offset_distance_sweep.yaml') + for offset in config.sweep.source_offsets_u: + self.assertLessEqual(abs(offset), 11.0) + for offset in config.sweep.source_offsets_v: + self.assertLessEqual(abs(offset), 6.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_manifest.yaml b/tests/test_manifest.yaml index be9a7c0..edf07d0 100644 --- a/tests/test_manifest.yaml +++ b/tests/test_manifest.yaml @@ -159,6 +159,29 @@ tests: manual_command: null collection_ignore_reason: null + - path: tests/mdao/mdao_unit_test_07.py + description: >- + Unit tests for the ISS-panel study extensions: collisionless + plume-model dispatch (both Cai variants selectable by name, unknown + names rejected, both reduced to one common LocalFieldState feeding one + shared Maxwellian gas-surface implementation); panel-local pose + generation (parallel_to_normal translates the source without tilting + the plume axis, and coincides with aim_at_reference at zero offset); + offset-sweep enumeration and the refusal to combine incompatible pose + definitions; derived Knudsen metadata for both reference-length modes; + panel-local coordinate transforms and hand-computed normal-force and + moment projections; and the distribution-CSV schema. + subsystem: mdao + category: unit + execution_mode: automated + development_status: implemented + collection_status: collected + reference: >- + Cai & Wang 2012, J. Spacecraft and Rockets 49(2), 335-340, + doi:10.2514/1.A32046 + manual_command: null + collection_ignore_reason: null + - path: tests/mdao/mdao_integration_test_01.py description: >- Placeholder MDAO integration test; the test body returns immediately @@ -323,6 +346,30 @@ tests: manual_command: null collection_ignore_reason: null + - path: tests/mdao/mdao_integration_test_05.py + description: >- + The committed ISS-representative solar-panel case (22 m x 12 m flat + panel, source translated parallel to the panel) run end to end through + TradeStudy.from_config().run(): panel-local resultants, derived + Knudsen metadata and distribution export on the committed baseline; + proof that selecting CollisionlessGasKinetics changes the ANSWER and + not merely the metadata; the pressure force re-integrated from the + exported per-face values matching the CaseResult; symmetry of a + centered source and the documented moment signs for offset sources; + offset-sweep case count, order and identifiers; headless plot + generation; and backward compatibility of the existing flat-plate + configurations, case identifiers and baseline results. + subsystem: mdao + category: integration + execution_mode: automated + development_status: implemented + collection_status: collected + reference: >- + Cai & Wang 2012, J. Spacecraft and Rockets 49(2), 335-340, + doi:10.2514/1.A32046 + manual_command: null + collection_ignore_reason: null + # ---------------------------------------------------------------- mission - path: tests/mission/mission_unit_test_01.py description: >- From 860732e20823f835be750abda0c0179afbe1568b Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 22:04:45 -0500 Subject: [PATCH 09/10] docs: document model selection, axis modes, offsets, Kn and distributions docs/plume_validation_study.md gains four sections and updates several: * Plume models -- the two selectable collisionless Cai variants, the mapping to [pm] kinetics, and the fact that the name SELECTS the model that computes the field rather than recording metadata. Notes that CaiImpingement2016 stays independent of the strike pipeline. * Source axis modes -- aim_at_reference vs parallel_to_normal written out as formulas, why they are different experiments, and that they coincide exactly at zero offset. * Panel-local u/v offsets -- the basis derived from the existing normal/tangent keys, the enumeration order, the case count and the case identifier forms. * Derived Knudsen metadata -- both reference-length modes, every validation rule, and repeated statements that Kn never enters the solution. * Panel-local surface distributions -- the column schema, why it exists alongside (never instead of) the VTK files, and the explicit absence of interpolation and common-grid projection. * ISS-panel example -- exact commands for all three studies and the independent Cai 2016 cross-check. Sign conventions now have their own subsection with a table and a worked moment derivation: normal_force = -F.n is positive INTO the panel, and a source displaced toward +u gives a positive local_moment_v. The result-schema and YAML sections list every addition with its default and state that all of them are optional or safely defaulted. The DSMC subsection and Known limitations now say plainly that no DSMC handling exists -- no OpenFOAM execution, dictionaries, field import, mesh interpolation, job management or comparison report -- and that both models are collisionless with no collisional, wake or shadowing correction. Also drops the stale "the only plume model this workflow supports" comment from the committed flat-plate baseline YAML. Co-Authored-By: Claude Opus 5 --- .../study/flat_plate_baseline.yaml | 4 +- docs/plume_validation_study.md | 503 ++++++++++++++++-- 2 files changed, 473 insertions(+), 34 deletions(-) diff --git a/case/plume/plume_flat_plate_sweep/study/flat_plate_baseline.yaml b/case/plume/plume_flat_plate_sweep/study/flat_plate_baseline.yaml index 3625a3c..463c41d 100644 --- a/case/plume/plume_flat_plate_sweep/study/flat_plate_baseline.yaml +++ b/case/plume/plume_flat_plate_sweep/study/flat_plate_baseline.yaml @@ -27,7 +27,9 @@ study: thruster: id: T1 -# The only plume model this workflow supports. +# The collisionless plume model that computes the field for this study. +# CollisionlessGasKinetics (the full Cai & Wang 2012 model) is the other +# supported value; Simplified is the paper-matching choice here. plume_model: name: SimplifiedGasKinetics parameters: diff --git a/docs/plume_validation_study.md b/docs/plume_validation_study.md index de48ec4..51acca9 100644 --- a/docs/plume_validation_study.md +++ b/docs/plume_validation_study.md @@ -8,7 +8,11 @@ than flown from vehicle dynamics. - [Quick start](#quick-start) - [Architecture](#architecture) +- [Plume models](#plume-models) - [Sweep modes: one JFH per case, or one for the sweep](#sweep-modes-one-jfh-per-case-or-one-for-the-sweep) +- [Source axis modes: aiming vs translating](#source-axis-modes-aiming-vs-translating) +- [Panel-local u/v offsets](#panel-local-uv-offsets) +- [Derived Knudsen metadata](#derived-knudsen-metadata) - [YAML configuration](#yaml-configuration) - [`n_firings` and prescribed firings](#n_firings-and-prescribed-firings) - [Integrated loads](#integrated-loads) @@ -19,9 +23,11 @@ than flown from vehicle dynamics. - [Coefficients](#coefficients) - [Result schema](#result-schema) - [VTK outputs](#vtk-outputs) +- [Panel-local surface distributions](#panel-local-surface-distributions) - [Optional plots](#optional-plots) - [External reference data](#external-reference-data) - [Adding another validation geometry](#adding-another-validation-geometry) +- [ISS-panel example](#iss-panel-example) - [Known limitations](#known-limitations) --- @@ -62,6 +68,24 @@ results = study.run() study.plot() # optional trend figures ``` +The **ISS-representative panel** example (22 m x 12 m panel, source +translated parallel to it, either plume model, panel-local distributions and +derived Knudsen metadata) is the same call — see +[ISS-panel example](#iss-panel-example): + +```python +study = TradeStudy.from_config( + 'case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml') +results = study.run() + +case = results.cases[0] +print(case.normal_force) # + into the panel +print(case.local_moment_v) # panel moment about the transverse axis +print(case.center_of_pressure_u) # panel-local, from the panel center +print(case.knudsen_number) # derived metadata; not a model input +print(case.surface_distribution_path) +``` + Outputs land under the configured `output_dir` (by default `/results/studies//`, which is gitignored): @@ -98,16 +122,19 @@ responsibility each, all under `pyrpod/mdao/`: | `study_runtime.py` | Plumbing both engines share (assets, geometry, strikes, records) | | `surface_loads.py` | Integrate per-face fields into component loads | | `study_results.py` | Result schema; CSV + JSON output | +| `surface_distribution.py` | Panel-local per-face distribution CSV + sidecar | | `reference_data.py` | Generic external-reference comparison and metrics | -| `study_plots.py` | Optional sweep trend figures | +| `study_plots.py` | Optional angle-sweep trend figures | +| `panel_plots.py` | Optional offset-sweep and panel-pressure figures | + +Plume-model dispatch lives under `pyrpod/plume/` in +`gas_kinetics_models.py`, next to the models themselves. Everything domain-specific is delegated to the existing PyRPOD objects — `JetFiringHistory`, `TargetVehicle`, `VisitingVehicle`, `MissionEnvironment` and `PlumeStrikeEstimationStudy` — so a study inherits the pipeline's validation, logging, plume physics and VTK conventions instead of -reimplementing them. The single plume model is `SimplifiedGasKinetics`; the -configuration records it explicitly and rejects anything else. No plume-model -registry is introduced. +reimplementing them. `TradeStudy.from_config` picks the engine from `sweep.mode` (see the next section); both do the same seven things, differing only in how many Jet @@ -154,6 +181,50 @@ artifact-free. This applies to both engines. --- +## Plume models + +Two **collisionless** analytical plume models are selectable, both from +Cai & Wang 2012 and both already verified in this repository: + +| `plume_model.name` | `[pm] kinetics` | Model | +| --- | --- | --- | +| `SimplifiedGasKinetics` | `Simplified` | Far-field simplification (the `Q'` of Eq. 13) — a closed form, fast | +| `CollisionlessGasKinetics` | `Collisionless` | Full model: the exact factor `Q` (Eq. 9) integrated over the finite exit disk, valid in the near field | + +`SimplifiedGasKinetics` is the default, so **a configuration that names no +model behaves exactly as before**. Any other name raises `StudyConfigError`. + +**The name selects the model that computes the plume field — it is not +metadata.** `study_runtime.load_case_assets` applies it by setting the +environment's *in-memory* `[pm] kinetics` key, which is the one input both +strike paths read, so the selection reaches the calculation itself. The +case's `config.ini` on disk is never modified, and a study naming the model +its case already configures changes nothing. A case with +`[pm] kinetics = None` is rejected: a validation study needs surface loads. + +Dispatch is a small registry in `pyrpod/plume/gas_kinetics_models.py`, not a +plugin framework — `CollisionlessGasKinetics` subclasses +`SimplifiedGasKinetics` with an identical constructor, so selecting between +them is a class lookup. Both are reduced to one common `LocalFieldState`: + +``` +number_density, mass_density, axial_velocity, radial_velocity, +velocity_magnitude, temperature, speed_ratio +``` + +and a single `maxwellian_surface_loads()` applies the Shen gas-surface +interaction to it, so **pressure, shear and heat-transfer logic exists once** +rather than once per model. On the plume centerline both models use the same +exact closed forms and therefore agree bit-for-bit; off-axis they differ, +which is what makes the selection meaningful. + +`pyrpod/plume/CaiImpingement2016.py` stays **independent of the strike +pipeline** and is not a model backend. It is used only as an external +analytical reference (see [External reference data](#external-reference-data) +and `case/plume/iss_panel_thesis/cai2016_reference.py`). + +--- + ## Sweep modes: one JFH per case, or one for the sweep The same configuration can be decomposed two ways, chosen with `sweep.mode`. @@ -207,6 +278,138 @@ reading the file back. --- +## Source axis modes: aiming vs translating + +`sweep.source_axis_mode` chooses how the plume axis is oriented at each +generated pose. These are **different experiments**, not two spellings of one. + +For a target reference point `C` with normal `n` (toward the source), +longitudinal axis `u` (= `target.tangent`) and transverse axis `v = n x u`: + +### `aim_at_reference` (default — existing behavior) + +``` +d(alpha) = cos(alpha)*n + sin(alpha)*u +position = C + L*d(alpha) +axis = -d(alpha) # re-aimed at C at every angle +``` + +The source rides an arc of radius `L` about `C` and **always points at +`C`**. `plate_angles_deg` sweeps the approach angle; `alpha = 0` is head-on. +This is what every existing study uses, and it is unchanged. + +### `parallel_to_normal` (ISS-panel studies) + +``` +position = C + L*n + u_offset*u + v_offset*v +axis = -n # fixed; never re-aimed +``` + +The source is **translated parallel to the surface** with its axis held +fixed, so the plume centerline meets the panel at `(u_offset, v_offset)` +instead of always at `C`. `plate_angles_deg` has no meaning here and is +rejected if set. + +**At zero offset the two modes coincide exactly** (`parallel_to_normal` +equals `aim_at_reference` at `alpha = 0`, position and DCM), so the new mode +extends the old convention rather than redefining it. In both modes the JFH +DCM carries the thruster axis in its **first column**, the existing +repository convention. + +Incompatible combinations are refused, never silently merged: offsets in +`aim_at_reference` mode, or a non-zero `plate_angles_deg` in +`parallel_to_normal` mode, each raise `StudyConfigError`. + +--- + +## Panel-local u/v offsets + +The surface-local basis comes from the **existing** `target.normal` and +`target.tangent` keys — no new geometry keys: + +| Axis | Definition | ISS panel | +| --- | --- | --- | +| `u` | `target.tangent`, longitudinal | +X, the 22 m dimension | +| `v` | `n x u`, transverse | +Y, the 12 m dimension | +| `n` | `target.normal`, toward the source | +Z | + +The triad is right-handed (`u x v = n`), and `v` is exactly the binormal +`firing_plan.pose_for` already used for the second DCM column, so the pose +convention and the reporting convention share one basis. +`TargetSpec.local_basis()` returns it. + +`sweep.source_offsets_u` and `sweep.source_offsets_v` sweep the source along +`u` and `v`. Both default to `[0.0]`, so an existing configuration's poses +are untouched. Cases are enumerated **distance-major, then u, then v, then +angle**: + +``` +for distance: for u_offset: for v_offset: for angle +``` + +which collapses to the historical "all angles at the first distance, then +all angles at the next" at the default offsets. For an offset sweep the case +count is exactly `n_distances x n_u_offsets x n_v_offsets`. Non-finite +offsets are rejected. + +Case identifiers name what actually varies: + +| Mode | Identifier | +| --- | --- | +| `aim_at_reference` | `case000_alpha0p0_d4` (unchanged) | +| `parallel_to_normal` | `case000_modelCollisionless_L4_u0_v0` | + +Every offset value is recorded in each result (`source_offset_u`, +`source_offset_v`, `source_axis_mode`). + +--- + +## Derived Knudsen metadata + +**PyRPOD's plume models are collisionless, and the optional `knudsen` block +does not change that.** No solution, field value or surface load anywhere in +the pipeline reads Kn back; nothing is corrected for rarefaction. The block +exists so an analytical case can be *labelled* with the regime it is meant to +represent, which is what a later, entirely separate workflow needs to line +PyRPOD cases up with externally generated data. + +`Kn = mean_free_path_m / L_ref`, with `L_ref` chosen by **exactly one** of two +mutually exclusive modes: + +```yaml +knudsen: + mean_free_path_m: 1.0 + reference_length: source_distance # the case's own swept distance + definition: lambda_over_source_distance +``` + +```yaml +knudsen: + mean_free_path_m: 1.0 + reference_length_m: 0.5 # a fixed length + definition: lambda_over_nozzle_diameter +``` + +Rules, all enforced with a specific `StudyConfigError`: + +- `mean_free_path_m` is **required**, positive and finite. It is **never + inferred** from the gas properties in the thruster definition file — a + free-molecular model carries no collision rate to infer it from. +- Exactly one reference-length mode. Supplying both, or neither, is an error. +- `reference_length` accepts only the symbolic value `source_distance`; any + other fixed length goes in `reference_length_m`. +- `definition` is a free-text label, defaulted from the mode. + +Omit the block entirely and every Knudsen field is simply absent — empty CSV +columns and `null` in JSON, never a fabricated value. The study metadata +records the block with an explicit `role` field stating it is derived +metadata only. + +To sweep Kn itself, write **one study per label** (the mean free path is a +single scalar per study, not a swept axis); see the ISS-panel README. + +--- + ## YAML configuration A study configuration is a **layer on top of an existing PyRPOD case**. The @@ -225,7 +428,7 @@ thruster: id: T1 # optional; validated against the case TCF plume_model: - name: SimplifiedGasKinetics # the only accepted value + name: SimplifiedGasKinetics # | CollisionlessGasKinetics parameters: # recorded for provenance only gas: argon speed_ratio_S0: 2.0 @@ -233,16 +436,19 @@ plume_model: target: geometry_id: flat_plate_transformed.stl # defaults to the case's [tv] stl reference_point: [0.0, 0.0, 0.0] # sweep is built about this point - normal: [0.0, 0.0, 1.0] # toward the plume-source side - tangent: [1.0, 0.0, 0.0] # sweep plane's in-plane axis + normal: [0.0, 0.0, 1.0] # n: toward the plume-source side + tangent: [1.0, 0.0, 0.0] # u: longitudinal (v = n x u) components: - name: plate selector: all # or face_indices: [...] / bounds: {...} sweep: mode: per_case # per_case (default) | single_jfh - plate_angles_deg: [0.0] # 0 = head-on along `normal` + source_axis_mode: aim_at_reference # (default) | parallel_to_normal + plate_angles_deg: [0.0] # 0 = head-on; aim_at_reference only source_distances: [4.0] # from `reference_point` + source_offsets_u: [0.0] # panel-local; parallel_to_normal only + source_offsets_v: [0.0] # panel-local; parallel_to_normal only n_firings: 1 # EXACT JFH entries per pose firing_duration_s: 1.0 thrusters: [1] @@ -256,10 +462,17 @@ loads: dynamic_pressure: 1.1044652197738332 reference_heat_flux: 637.3520362956127 +knudsen: # optional; DERIVED METADATA ONLY + mean_free_path_m: 1.0 # required; never inferred + reference_length: source_distance # XOR reference_length_m: + definition: lambda_over_source_distance # free-text label + output: vtk: {enabled: true} + surface_distribution: + {enabled: false, subdir: distributions} summary: {csv: case_results.csv, metadata: study_metadata.json} - plots: {enabled: false} + plots: {enabled: false, subdir: plots, per_case_distribution: false} reference: path: null # optional external reference data @@ -270,15 +483,36 @@ metadata: units: {} # overrides the SI defaults ``` +**Schema additions on this branch**, all optional and defaulted so every +existing YAML file parses and behaves unchanged: + +| Key | Default | Meaning | +| --- | --- | --- | +| `plume_model.name` | `SimplifiedGasKinetics` | Now also accepts `CollisionlessGasKinetics`; selects the model that computes the field | +| `sweep.source_axis_mode` | `aim_at_reference` | `aim_at_reference` \| `parallel_to_normal` | +| `sweep.source_offsets_u` | `[0.0]` | Longitudinal source offsets (m) | +| `sweep.source_offsets_v` | `[0.0]` | Transverse source offsets (m) | +| `knudsen` | absent | Whole block; see [Derived Knudsen metadata](#derived-knudsen-metadata) | +| `knudsen.mean_free_path_m` | — | Required within the block; positive, finite | +| `knudsen.reference_length` | — | Only `source_distance`; XOR with the next | +| `knudsen.reference_length_m` | — | A fixed positive length; XOR with the previous | +| `knudsen.definition` | from the mode | Free-text label | +| `output.surface_distribution.enabled` | `false` | Export panel-local per-face CSVs | +| `output.surface_distribution.subdir` | `distributions` | Where they land | +| `output.plots.per_case_distribution` | `false` | Per-case panel pressure maps | + Validation is strict and specific: a missing case directory, a case without a -`config.ini`, an unsupported plume model, an empty or non-positive sweep -axis, non-orthogonal target axes, duplicate component names, a non-positive -normalization value, or a firing list whose length disagrees with -`n_firings` each raise `StudyConfigError` naming the offending key. - -Angles and distances are geometry, not physics: `normal` and `tangent` define -the plane the source is swept in, so a curved target simply supplies the axes -its sweep should use (see [Adding another validation +`config.ini`, an unknown plume model, an empty or non-positive sweep axis, +non-finite offsets, an unknown axis mode, incompatible pose definitions, +non-orthogonal target axes, duplicate component names, a non-positive +normalization value, an invalid or ambiguous `knudsen` block, or a firing +list whose length disagrees with `n_firings` each raise `StudyConfigError` +naming the offending key. + +Angles, distances and offsets are geometry, not physics: `normal` and +`tangent` define both the plane the source is swept in and the surface-local +reporting basis, so a curved target simply supplies the axes its sweep +should use (see [Adding another validation geometry](#adding-another-validation-geometry)). --- @@ -316,8 +550,10 @@ sweep: dcm: [[0, 0, 1], [0, 1, 0], [-1, 0, 0]] ``` -The generated pose convention, for a target reference point `C` with outward -normal `n_hat` and in-plane tangent `t_hat`: +The generated pose convention depends on `sweep.source_axis_mode` — see +[Source axis modes](#source-axis-modes-aiming-vs-translating). In the default +`aim_at_reference` mode, for a target reference point `C` with outward normal +`n_hat` and in-plane tangent `t_hat`: ``` d_hat(alpha) = cos(alpha) * n_hat + sin(alpha) * t_hat @@ -398,6 +634,31 @@ reported as an auxiliary, always-defined location; it coincides with the classical center of pressure for a planar component under unidirectional pressure. +### Panel-local resultants and their signs + +The same force and moment vectors are also reported on the target's +surface-local basis `(u, v, n)` (see [Panel-local u/v +offsets](#panel-local-uv-offsets)). This is a pure projection — it adds no +physics — and `surface_loads.project_to_panel_frame()` computes it. + +**The sign conventions are stated and tested explicitly:** + +| Field | Definition | Sign | +| --- | --- | --- | +| `normal_force` | `-F . n` | **Positive when the load presses INTO the panel** (away from the plume source). The target normal points *toward* the source and the plume pushes against it, so a compressive impingement load is positive. A negative value would mean the resultant pulls the panel toward the source. | +| `local_force_u`, `local_force_v` | `F . u`, `F . v` | In-surface components of the same resultant. | +| `local_moment_u/v/n` | `M_ref . u`, `. v`, `. n` | The **same** moment vector the global-frame fields report, about the **same** reference point, projected on each axis. Positive follows the right-hand rule about that axis. | +| `center_of_pressure_u/v` | `(r_cop - r_ref) . u`, `. v` | Panel-local coordinates of the center of pressure, **measured from the moment reference point** (the panel center in the ISS-panel studies). | + +Worked moment sign: a pressure patch centred at `+u` pushes along `-n`, so +`M = (u_off * u) x (-F_n * n) = +u_off * F_n * v` (using `u x n = -v`). **A +source displaced toward `+u` therefore gives a positive `local_moment_v`**, +growing with the offset until the patch starts leaving the panel. Likewise a +source displaced toward `+v` gives a **negative** `local_moment_u`. + +When no center of pressure exists (`zero_load` / `ill_conditioned`), +`center_of_pressure_u/v` are `None` rather than zero. + ### Thermal quantities Instantaneous and peak quantities only (time-integrated heat dose is @@ -440,29 +701,53 @@ so downstream code (CSV, plots, reference comparison) is mode-agnostic: - **pose and sweep** — `plume_source_position`, `plume_source_orientation` (9 DCM values), `target_normal`, `target_tangent`, `target_reference_point`, `plate_angle_deg`, `source_distance`, + `source_offset_u`, `source_offset_v`, `source_axis_mode`, `firing_duration_s`, `thrusters` -- **model** — `plume_model`, `plume_model_parameters` +- **model** — `plume_model`, `plume_model_parameters`, and the derived + `model_variant` (`Simplified` / `Collisionless`, for plot legends and + grouping) - **loads** — `pressure_force`, `shear_force`, `force`, `force_magnitude`, `moment_reference_point`, `pressure_moment`, `shear_moment`, `moment`, `moment_magnitude`, `center_of_pressure`, `center_of_pressure_status`, `residual_couple`, `pressure_weighted_centroid` +- **panel-local loads** — `normal_force`, `local_force_u`, `local_force_v`, + `local_moment_u`, `local_moment_v`, `local_moment_n`, + `center_of_pressure_u`, `center_of_pressure_v` (see [Panel-local + resultants and their signs](#panel-local-resultants-and-their-signs)) - **surface fields** — `max_pressure`, `max_shear_stress`, `max_heat_flux`, `total_heat_load`, `affected_area`, `struck_faces` +- **derived Knudsen metadata** — `knudsen_number`, `mean_free_path`, + `knudsen_reference_length`, `knudsen_definition` - **coefficients** — `coefficients`, `coefficients_available` -- **artifacts and provenance** — `vtk_path`, `jfh_path`, `config_path`, - `case_dir`, `code_version` (git commit when available), `generated_at` +- **artifacts and provenance** — `vtk_path`, `jfh_path`, + `surface_distribution_path`, `config_path`, `case_dir`, `code_version` + (git commit when available), `generated_at` + +**Every field added on this branch is optional or safely defaulted**, so an +older result file still loads and the reference-comparison API is unchanged. +Offsets default to `0.0` and the axis mode to `aim_at_reference`; the +panel-local, Knudsen and distribution fields default to `None` and serialize +as **empty CSV columns**, never as a fabricated zero. + +`CaseResult.quantity()` exposes the new comparable scalars — +`normal_force`, `local_force_u/v`, `local_moment_u/v/n`, +`center_of_pressure_u/v` and `knudsen_number` — alongside the existing ones, +returning `None` when a quantity is unavailable for that record. Two machine-readable artifacts are written, in formats the repository already uses (no Parquet, no new dependency): - **CSV** (`StudyResults.write_csv`) — one flat row per record; vectors are expanded to `_x/_y/_z` columns and each coefficient gets its own - `coeff_` column, so the file is directly plottable; + `coeff_` column, so the file is directly plottable. **No per-face + array is ever embedded in a CSV row** — those go to the VTK files and the + distribution CSVs; - **JSON** (`StudyResults.write_metadata`) — `schema`, study-level - `provenance` (including the plume model, mesh size, component list, code - version and known limitations) and the nested per-case records. This is the - document an externally generated dataset is later transformed into for - comparison. + `provenance` (including the plume model, the source axis mode, the panel + basis, the Knudsen block with its explicit "derived metadata only" role, + mesh size, component list, code version and known limitations) and the + nested per-case records. This is the document an externally generated + dataset is later transformed into for comparison. --- @@ -490,12 +775,61 @@ numbers are identical; only the files are skipped). --- +## Panel-local surface distributions + +**The VTK files remain the primary full-resolution visualization output.** +Enabling `output.surface_distribution.enabled` adds a *second view of the +same numbers*: one CSV per case and component, holding every face of that +component in the target's own `(u, v)` coordinates. + +The reason is practical. A `.vtu` is the right artifact for ParaView and the +wrong one for a plotting script, a spreadsheet, or a later comparison +workflow that would otherwise need a VTK reader. + +Columns (stable order, `pyrpod/mdao/surface_distribution.py`): + +``` +face_index, centroid_x, centroid_y, centroid_z, local_u, local_v, +area, pressure, shear_stress, heat_flux, strike_count +``` + +- `local_u` / `local_v` are `(centroid - target.reference_point)` projected + on the target's `u` and `v` axes. +- `face_index` is the index into the **full** target mesh, so a row traces + back to both the mesh and the VTK file even for a component subset. +- **No interpolation, resampling, smoothing or structured-grid projection.** + Every row is one native mesh face carrying the value the strike pipeline + computed for it. An unstructured triangle mesh is exported as unstructured + triangles. +- **No common-grid projection onto any external mesh.** Producing a shared + grid is a separate workflow's job and is not implemented here. + +Units and case metadata go in a sidecar `.meta.json` beside the CSV — +column units, the panel basis, the pose, the plume model and any derived +Knudsen fields — so a distribution file is self-describing without the study +metadata document. The CSV path is recorded in +`CaseResult.surface_distribution_path`, and therefore in the summary CSV and +JSON too. + +Files land in the per-case directory +(`/cases//distributions/`) for `per_case` mode, and in +`/distributions/` for `single_jfh`. + +--- + ## Optional plots Plot generation is entirely optional — no automated test requires graphical -output — and matplotlib's non-interactive `Agg` backend is pinned when -`pyrpod.mdao.study_plots` is imported (lazily, only when plots are asked -for). Enable with `output.plots.enabled: true` or call `study.plot()`: +output — and matplotlib's non-interactive `Agg` backend is pinned when a +plotting module is imported (lazily, only when plots are asked for), so a +headless or CI run never opens a window. No plotting dependency beyond +matplotlib is introduced. Enable with `output.plots.enabled: true` or call +`study.plot()`. + +`study_runtime.study_plots_for()` picks the families a given study needs, so +neither engine nor the `TradeStudy` façade holds plotting logic. + +**Angle sweeps** (`pyrpod/mdao/study_plots.py`, always produced): ``` force_vs_angle.png moment_vs_angle.png heat_flux_vs_angle.png @@ -503,6 +837,31 @@ force_vs_distance.png moment_vs_distance.png center_of_pressure.png reference_comparison.png (when a comparison report exists) ``` +**Offset sweeps** (`pyrpod/mdao/panel_plots.py`, added when the study sweeps +offsets or uses `parallel_to_normal`). Every series is grouped by stand-off +distance **and** plume model, so results merged from a Simplified run and a +Collisionless run of the same geometry plot as separate labelled series +rather than being averaged: + +``` +normal_force_vs_offset_u.png moment_v_vs_offset_u.png +peak_pressure_vs_offset_u.png cop_u_vs_offset_u.png +normal_force_vs_distance.png +``` + +When `v` offsets are swept, the analogous transverse figures appear too +(`normal_force_vs_offset_v.png`, `moment_u_vs_offset_v.png`, +`cop_v_vs_offset_v.png`). + +**Per-case panel pressure maps** (`output.plots.per_case_distribution: true`, +which also needs `output.surface_distribution.enabled`): +`panel_pressure_.png` — panel-local `u` horizontal, `v` vertical, +pressure in Pa, with the panel edges drawn and the plume-centerline +intersection marked. A flat plate is an **unstructured triangle mesh**, so no +structured grid is fabricated: the field is drawn as a Delaunay +triangulation of the face centroids, falling back to a face-coloured scatter +when the face count is too small for contours to be honest. + --- ## External reference data @@ -561,6 +920,16 @@ engineering chain against the exact collisionless solution. ### Adding DSMC results later +> **No DSMC handling exists in PyRPOD, and none is added by this branch.** +> Nothing here launches OpenFOAM, writes OpenFOAM dictionaries, imports or +> parses DSMC fields, interpolates a DSMC mesh, manages DSMC jobs, digitizes +> published DSMC data, or produces a DSMC comparison report. Both plume +> models are **collisionless**: there is no collisional correction, no wake, +> shadowing or secondary-collision physics, and the Knudsen number is derived +> metadata that no solution reads. What PyRPOD produces is **analytical +> datasets**; comparing them with externally generated DSMC results is a +> separate workflow. + Nothing DSMC-specific is needed. Transform the external results into either supported format, keyed by `plate_angle_deg` / `source_distance` / `component` (or `case_id`, which the study metadata records), then call @@ -593,6 +962,59 @@ it deliberately supplies no normalization inputs. --- +## ISS-panel example + +`case/plume/iss_panel_thesis/` is the worked example of everything above: an +idealized **22 m x 12 m** flat panel standing in for one ISS solar-array +wing, with the source translated parallel to it. See that directory's +`README.md` for the full conventions, expansion guidance and Knudsen labels. + +```bash +# Centered source at L = 4 m, SimplifiedGasKinetics (seconds) +python case/plume/iss_panel_thesis/run.py baseline-simplified + +# The same case with CollisionlessGasKinetics (~20 s, 2112 faces) +python case/plume/iss_panel_thesis/run.py baseline-full-cai + +# 3 distances x 5 longitudinal offsets = 15 cases (~20 s) +python case/plume/iss_panel_thesis/run.py sweep + +# All three, into a scratch tree, with progress logging +python case/plume/iss_panel_thesis/run.py all \ + --output-dir /tmp/iss_panel --verbose + +# Skip figure generation +python case/plume/iss_panel_thesis/run.py sweep --no-plots +``` + +Or from Python, through the ordinary package API: + +```python +from pyrpod.mdao.TradeStudy import TradeStudy + +study = TradeStudy.from_config( + 'case/plume/iss_panel_thesis/study/iss_panel_offset_distance_sweep.yaml') +results = study.run() +paths = study.plot() +``` + +The two baseline configurations differ in **exactly one line** — +`plume_model.name` — so the pair isolates the far-field simplification: 2.824 +N vs 2.802 N centered normal force, 0.3317 Pa vs 0.3136 Pa peak pressure. +That the numbers differ at all is what demonstrates that model selection +reaches the calculation rather than only the metadata. + +An **independent** analytical cross-check ships alongside it: +`case/plume/iss_panel_thesis/cai2016_reference.py` evaluates the exact Cai +2016 surface solution at the same face centroids and exports the same +distribution schema. It is a reference generator, **not** a plume-model +backend — PyRPOD never imports it and `PlumeStrikeCalculator` cannot reach +it. At `L = 4 m` the peaks agree to 2.8% in pressure, 3.4% in shear and 7.8% +in heat flux, which is the Maxwellian wall chain against a direct +integration of the wall fluxes. + +--- + ## Known limitations - **No shadowing or occlusion.** Face selection is the existing pipeline @@ -613,9 +1035,24 @@ it deliberately supplies no normalization inputs. `max_pressures` / `cum_strikes` in a per-case VTK accordingly. - **Coefficients require explicit normalization.** By design: nothing is inferred from the geometry. -- **One thruster, one plume model.** The study workflow prescribes a single - firing source and `SimplifiedGasKinetics`. Multi-thruster and multi-group - behavior is untouched elsewhere in PyRPOD but is not exercised here. +- **One thruster per study.** The workflow prescribes a single firing source. + Multi-thruster and multi-group behavior is untouched elsewhere in PyRPOD + but is not exercised here. +- **Both plume models are collisionless.** `SimplifiedGasKinetics` and + `CollisionlessGasKinetics` are free-molecular: no intermolecular + collisions, no continuum or transitional correction, no wake, and no + secondary-collision physics. A configured Knudsen number is **derived + metadata** and never enters the solution, so labelling a case `Kn = 0.01` + does **not** make it a continuum result — it records the regime the case is + intended to represent. A study is only physically meaningful where the + free-molecular assumption holds. +- **No DSMC.** No OpenFOAM execution, dictionary generation, field import, + mesh interpolation, job management or DSMC comparison report exists in + PyRPOD. The outputs are analytical datasets for a later, separate + comparison workflow. +- **Distribution CSVs are native faces only.** No interpolation and no + common-grid projection onto an external mesh; producing a shared grid is + out of scope here. - **Cylinder validation is not quantitative yet.** The architecture and the comparison interface are ready for it; the reference data is not. - **Heat loading is instantaneous/peak only.** Time-integrated heat dose and From 1622b1598155167417e143934c507965c289088a Mon Sep 17 00:00:00 2001 From: andytorrestb Date: Tue, 4 Aug 2026 22:16:43 -0500 Subject: [PATCH 10/10] mdao: restore a clean mypy run over the changed packages Two narrow typing slips in the new code, both annotation-only: * KnudsenSpec.from_mapping's XOR check guarantees the explicit reference length is not None on that branch, but mypy cannot see it -- narrowed with a targeted ignore and a comment naming the invariant; * plot_panel_pressure's mappable is a TriContourSet on one branch and a PathCollection on the other, so it is annotated as their common ScalarMappable base. `python -m mypy pyrpod/mdao pyrpod/plume` is clean again (18 files), as it was on master (15 files). Co-Authored-By: Claude Opus 5 --- pyrpod/mdao/panel_plots.py | 2 ++ pyrpod/mdao/study_config.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyrpod/mdao/panel_plots.py b/pyrpod/mdao/panel_plots.py index 6a4462b..463b0aa 100644 --- a/pyrpod/mdao/panel_plots.py +++ b/pyrpod/mdao/panel_plots.py @@ -50,6 +50,7 @@ import matplotlib.pyplot as plt # noqa: E402 (backend must be set first) import matplotlib.tri as mtri # noqa: E402 +from matplotlib.cm import ScalarMappable # noqa: E402 from pyrpod.mdao.study_plots import PALETTE # noqa: E402 from pyrpod.mdao.study_results import CaseResult, StudyResults # noqa: E402 @@ -115,6 +116,7 @@ def plot_panel_pressure(local_u: Sequence[float], local_v: Sequence[float], use_contours = (u.size >= MIN_FACES_FOR_CONTOURS and float(np.ptp(u)) > 0.0 and float(np.ptp(v)) > 0.0 and float(np.ptp(p)) > 0.0) + mappable: ScalarMappable if use_contours: triangulation = mtri.Triangulation(u, v) mappable = axes.tricontourf(triangulation, p, levels=24, diff --git a/pyrpod/mdao/study_config.py b/pyrpod/mdao/study_config.py index 9f41a9a..71babff 100644 --- a/pyrpod/mdao/study_config.py +++ b/pyrpod/mdao/study_config.py @@ -687,8 +687,9 @@ def from_mapping(cls, data: Mapping[str, Any] | None f", got {symbolic!r}") mode, reference_length = "source_distance", None else: + # The XOR check above guarantees `explicit` is not None here. try: - reference_length = float(explicit) + reference_length = float(explicit) # type: ignore[arg-type] except (TypeError, ValueError) as exc: raise StudyConfigError( "knudsen.reference_length_m must be a positive finite "