This document covers the repaired TradeStudy architecture and the
package-level API for prescribed plume/target validation sweeps: studies
in which the firing poses are placed by the engineer (or generated from a
swept approach angle and source distance about a stationary target) rather
than flown from vehicle dynamics.
- Quick start
- Architecture
- Sweep modes: one JFH per case, or one for the sweep
- YAML configuration
n_firingsand prescribed firings- Integrated loads
- Result schema
- VTK outputs
- Optional plots
- External reference data
- Adding another validation geometry
- Known limitations
A complete flat-plate example ships with the repository. It runs the Cai 2016
Section-4 conditions (argon round jet, D = 1 m, S0 = 2.0, T0 = 200 K,
Tw = 300 K, fully diffuse) against the 8 m x 8 m plate of
case/plume/plume_flat_plate_sweep, with the plume source head-on at
L = 4D:
from pyrpod.mdao.TradeStudy import TradeStudy
study = TradeStudy.from_config(
'case/plume/plume_flat_plate_sweep/study/flat_plate_baseline.yaml')
results = study.run()
case = results.cases[0]
print(case.force) # [~0, ~0, -2.763] N (plume pushes -Z)
print(case.moment) # ~0 about the plate center
print(case.center_of_pressure) # ~[0, 0, 0]
print(case.max_pressure) # 0.340 Pa
print(case.max_heat_flux) # 50.1 W/m^2
print(case.coefficients['CF']) # 0.0391
print(case.vtk_path) # .../cases/case000_.../results/strikes/firing-0.vtu
print(results.summary_csv_path) # .../case_results.csvThe multi-angle / multi-distance sweep (19 angles x 5 distances) is the same
call against flat_plate_sweep.yaml:
study = TradeStudy.from_config(
'case/plume/plume_flat_plate_sweep/study/flat_plate_sweep.yaml')
results = study.run()
study.plot() # optional trend figuresOutputs land under the configured output_dir (by default
<case>/results/studies/<study name>/, which is gitignored):
results/studies/flat_plate_sweep/
jfh/case000_alpha0p0_d4.A one prescribed JFH per case
cases/case000_alpha0p0_d4/results/strikes/firing-0.vtu
sweep_results.csv one row per case x component x firing
sweep_metadata.json provenance + nested case records
plots/ optional trend figures
reference_comparison.csv written when reference data is configured
To run a committed configuration into a scratch location (this is what the
automated tests do), pass output_dir:
TradeStudy.from_config(path, output_dir='/tmp/my_study').run()TradeStudy is a thin façade. The work lives in small modules with one
responsibility each, all under pyrpod/mdao/:
| Module | Responsibility |
|---|---|
study_config.py |
Parse and validate the YAML study configuration |
firing_plan.py |
Build prescribed firings; write Jet Firing Histories |
plume_validation.py |
Engine: one JFH per case (mode: per_case) |
parameter_sweep.py |
Engine: one JFH for the sweep (mode: single_jfh) |
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 |
reference_data.py |
Generic external-reference comparison and metrics |
study_plots.py |
Optional sweep trend figures |
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.
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
Firing Histories the sweep is decomposed into:
- build the prescribed firings;
- write the Jet Firing History and read it back with the normal
JetFiringHistoryparser; - run the plume-strike calculation;
- integrate per-component loads;
- export the per-face VTK fields;
- record a structured result;
- optionally compare against external reference data.
The dynamics-driven sweeps (run_axial_overshoot_sweep,
run_surface_cant_sweep, run_multi_var_sweep) were broken before any
physics ran. Fixed on this branch:
init_trade_studyconstructedPlumeStrikeEstimationStudy.RPOD(case_dir)— a class that does not exist — and passed a case directory where the study takes aMissionEnvironment. It now builds the environment and the realPlumeStrikeEstimationStudy.- The sweeps called
jfh_plume_strikes(trade_study=True), which that method's(parallel, workers)signature rejects withTypeError. print_mission_reportread impingement maxima from study attributes that are never set; it now summarizes them from the per-firing arraysjfh_plume_strikesreturns (the old attribute path still works for existing callers).interpret_mission_reportreadself.rpod.config, which does not exist; the configuration lives on the study'sMissionEnvironment.
With VTK output enabled (the default) each case runs the full
PlumeStrikeEstimationStudy.jfh_plume_strikes() pipeline, with the target's
output root temporarily redirected to that case's folder so sweep cases
cannot overwrite one another's artifacts. With VTK output disabled the study
calls the pipeline's own per-firing core, compute_plume_strikes() — the
same function jfh_plume_strikes calls per firing, so the numbers are
identical — which keeps large sweeps and automated tests fast and
artifact-free. This applies to both engines.
The same configuration can be decomposed two ways, chosen with sweep.mode.
Per-firing numbers are identical either way — an automated test asserts
it pose by pose — so the choice is about what you want to look at, not about
accuracy.
per_case (default) |
single_jfh |
|
|---|---|---|
| Engine | PlumeValidationStudy |
ParameterSweepStudy |
| Jet Firing Histories | one per angle-distance case | one, spanning every pose |
case_id / firing_id |
one case per pose, firings 1..n_firings |
one case, firings 1..poses x n_firings |
| Strike runs | one per case | one, over the whole history |
| VTK layout | cases/<case_id>/results/strikes/firing-<i>.vtu |
results/strikes/firing-<i>.vtu — one series, scrubbable in ParaView |
| Cumulative fields | restart each case | accumulate across the sweep |
| Sweep envelope | n/a | recorded per component |
Use per_case when each pose is an independent experiment (a validation
matrix compared pose-by-pose against reference data): nothing one pose does
can affect another's artifacts. Use single_jfh when the poses form one
sequence and you want the pipeline's cumulative fields to mean something:
max_pressures/max_shears— the worst load each face saw anywhere in the sweep;cum_strikes— a coverage map over the whole sweep;cum_heat_flux_load— accumulated heat-flux dose.
Those per-face fields live in the last firing's VTK file; their per-component
summary is exposed as study.validation_study.envelope and written into the
metadata document under provenance.sweep_envelope:
study = TradeStudy.from_config('.../flat_plate_sweep_single_jfh.yaml')
results = study.run()
study.validation_study.envelope['plate']
# {'max_pressure': 0.340, 'max_shear_stress': 0.120,
# 'max_heat_flux_load': 126.95, 'total_strike_events': 31104.0,
# 'unique_struck_faces': 10368, 'swept_affected_area': 64.0,
# 'component_area': 64.0}The envelope needs the pipeline's cumulative arrays, which only the full
strike path produces: with output.vtk.enabled: false it is reported as
empty rather than reconstructed from the per-firing records.
n_firings keeps one meaning in both modes — entries contributed by each
pose — so a configuration switches modes by changing one line. In
single_jfh mode the single history is required to hold exactly
len(poses) x n_firings entries, checked after writing and again after
reading the file back.
A study configuration is a layer on top of an existing PyRPOD case. The
case's config.ini keeps owning the STL, TCF, TDF, plume model and gating
geometry, so every existing case and public API is unaffected; the YAML adds
only what a trade study needs and an INI cannot express.
study:
name: cai2016_flat_plate_baseline # required; identifies every result row
description: ...
case_dir: .. # required; relative to THIS file
output_dir: ../results/studies/flat_plate_baseline
thruster:
id: T1 # optional; validated against the case TCF
plume_model:
name: SimplifiedGasKinetics # the only accepted value
parameters: # recorded for provenance only
gas: argon
speed_ratio_S0: 2.0
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
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_distances: [4.0] # from `reference_point`
n_firings: 1 # EXACT JFH entries per pose
firing_duration_s: 1.0
thrusters: [1]
# firings: [...] # optional explicit poses; see below
loads:
moment_reference_point: [0.0, 0.0, 0.0]
normalization: # optional; omit for no coefficients
reference_area: 64.0
reference_length: 4.0
dynamic_pressure: 1.1044652197738332
reference_heat_flux: 637.3520362956127
output:
vtk: {enabled: true}
summary: {csv: case_results.csv, metadata: study_metadata.json}
plots: {enabled: false}
reference:
path: null # optional external reference data
label: null
metadata:
coordinate_system: case global frame
units: {} # overrides the SI defaultsValidation 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
geometry).
n_firings is the exact number of Jet Firing History entries contributed
by each swept pose. It is validated before anything is generated (positive
integer only — zero, negative, fractional and non-numeric values are
rejected), the generated sequence is asserted to match it, and the JFH read
back from disk is asserted to report the same count. In per_case mode that
is the exact length of every case's history; in single_jfh mode the one
shared history must hold exactly len(poses) * n_firings entries.
Two ways to supply the poses:
- Generated (the usual case): the pose comes from the swept
plate_angles_deg/source_distancesentry and is repeated forn_firingssuccessive firing intervals — one JFH entry per firing, each offiring_duration_s. - Explicitly prescribed: list the poses under
sweep.firings. Their count must agree withn_firings(insingle_jfhmode, withlen(poses) * n_firings, the sequence being assigned to the poses in sweep order); a mismatch is an error, never a silent truncation or extension.
sweep:
n_firings: 2
firings:
- position: [0.0, 0.0, 4.0]
dcm: [[0, 0, 1], [0, 1, 0], [-1, 0, 0]] # first COLUMN = thruster axis
thrusters: [1]
duration_s: 0.5
- position: [0.5, 0.0, 4.0]
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:
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 carries the thruster axis as its first
column, which is what the strike pipeline reads as the plume normal. This
reproduces the committed sweep-JFH generators
(case/plume/plume_flat_plate_sweep/jfh/generate_sweep_jfh.py) to file
precision — a regression test pins all 95 poses of that file.
The dynamics-driven path was corrected to the same meaning:
PlumeStrikeEstimationStudy.print_jfh_1d_approach_n_fire(..., n_firings=N)
previously accepted N and ignored it. It now validates the count, stops the
approach simulation at exactly N entries, and raises if the approach
completes in fewer firings than requested.
pyrpod/mdao/surface_loads.py turns the strike pipeline's per-face arrays
into component-level resultants. Sign and direction conventions follow the
pipeline's own face-selection rule: a face is struck only when its unit
normal n_hat opposes the plume direction, i.e. points back toward the
source.
F_p,i = -p_i * A_i * n_hat_i (pressure, into the surface)
t_hat_i = normalize(u_hat_i - (u_hat_i . n_in_i) * n_in_i), n_in_i = -n_hat_i
F_s,i = tau_i * A_i * t_hat_i (shear, along the tangential flow)
u_hat_i is the local flow direction — the unit vector from the plume source
to the face centroid, since the collisionless flow is radial. Faces at exactly
normal incidence have no tangential direction and contribute no shear force.
Reported: pressure_force, shear_force, force, force_magnitude.
M_ref = sum (r_i - r_ref) x F_i
about the user-defined loads.moment_reference_point. The pressure and shear
contributions are reported separately (pressure_moment, shear_moment)
alongside the combined moment and moment_magnitude.
A unique three-dimensional center of pressure does not exist in general: the resultant of a distributed load is a force plus a couple, and only the moment component perpendicular to the force can be represented by shifting the force's line of action. The definition used here is stated explicitly:
r_cop = r_ref + (F x M_ref) / |F|^2
— the point on the line of action of the resultant force that lies closest
to the moment reference point. The force-parallel moment component is
reported separately as residual_couple; no choice of r_cop can remove it.
Degenerate cases are handled explicitly and never return a misleading value:
| Situation | center_of_pressure |
center_of_pressure_status |
|---|---|---|
| Non-zero resultant | the point above | ok |
| Nothing loaded | None |
zero_load |
Resultant is cancellation noise (below 1e-6 of the summed face-force magnitudes, e.g. a closed target loaded symmetrically) |
None |
ill_conditioned |
pressure_weighted_centroid — sum(p_i A_i r_i) / sum(p_i A_i) — is
reported as an auxiliary, always-defined location; it coincides with the
classical center of pressure for a planar component under unidirectional
pressure.
Instantaneous and peak quantities only (time-integrated heat dose is
deliberately out of scope on this branch): max_heat_flux (peak per-face
heat flux, W/m^2) and total_heat_load (sum q_i A_i over the component, W),
plus max_pressure, max_shear_stress, affected_area and struck_faces.
The per-face heat-flux load the pipeline already accumulates stays in the VTK
output.
Coefficients are computed only when the configuration supplies every input a given coefficient needs. Nothing is defaulted or invented.
| Coefficients | Required inputs |
|---|---|
Cp_max, Cf_max |
dynamic_pressure |
Cq_max |
reference_heat_flux |
CF, CFx, CFy, CFz |
dynamic_pressure, reference_area |
CM, CMx, CMy, CMz |
+ reference_length |
With no normalization block at all, coefficients is empty and
coefficients_available is False — which is what the cylinder example
does, since no reference values exist for it yet.
StudyResults holds one CaseResult per case x component x firing, each
carrying enough metadata to reproduce and later compare the calculation. In
per_case mode there is one case_id per pose; in single_jfh mode every
record shares the study's single case_id and is distinguished by
firing_id, but both carry the same plate_angle_deg / source_distance,
so downstream code (CSV, plots, reference comparison) is mode-agnostic:
- identity —
study_name,case_id,component,firing_id - geometry —
geometry_id,mesh_faces,component_faces,component_area,coordinate_system,units - pose and sweep —
plume_source_position,plume_source_orientation(9 DCM values),target_normal,target_tangent,target_reference_point,plate_angle_deg,source_distance,firing_duration_s,thrusters - model —
plume_model,plume_model_parameters - 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 - surface fields —
max_pressure,max_shear_stress,max_heat_flux,total_heat_load,affected_area,struck_faces - coefficients —
coefficients,coefficients_available - artifacts and provenance —
vtk_path,jfh_path,config_path,case_dir,code_version(git commit when available),generated_at
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<name>_x/_y/_zcolumns and each coefficient gets its owncoeff_<name>column, so the file is directly plottable; - JSON (
StudyResults.write_metadata) —schema, study-levelprovenance(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.
Per-face data are never reduced away. With output.vtk.enabled (the default)
the standard pipeline writer produces one .vtu per firing, laid out
according to the sweep mode:
per_case:<output_dir>/cases/<case_id>/results/strikes/firing-<i>.vtusingle_jfh:<output_dir>/results/strikes/firing-<i>.vtu— one numbered series over the whole sweep, which ParaView opens as a time sequence
Both carry the pipeline's own cell fields:
strikes, cum_strikes, pressures, max_pressures, shear_stress, max_shears,
heat_flux_rate, heat_flux_load, cum_heat_flux_load
Every case result advertises its own vtk_path, and that path is also in the
CSV and JSON summaries, so artifacts are discoverable from the returned
results. Set output.vtk.enabled: false for a fast, artifact-free run (the
numbers are identical; only the files are skipped).
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():
force_vs_angle.png moment_vs_angle.png heat_flux_vs_angle.png
force_vs_distance.png moment_vs_distance.png center_of_pressure.png
reference_comparison.png (when a comparison report exists)
pyrpod/mdao/reference_data.py compares study results against independently
generated data without knowing where it came from. A reference record is
just named quantities attached to matching keys; DSMC, an analytical
solution, an experiment and another code are all handled identically, and no
producer-specific importer exists.
Supported formats:
- CSV — one row per record.
case_id,component,plate_angle_degandsource_distanceare recognized as keys; every other numeric column is a quantity, and<name>_x/_y/_ztriplets are folded into vectors. This is exactly the layoutStudyResults.write_csvemits, so an external producer can be transformed into it column-for-column. - JSON / YAML:
label: DSMC run 12
source: /runs/dsmc-12
units: {force: N}
records:
- key: {plate_angle_deg: -40.0, source_distance: 2.0, component: plate}
quantities:
force: [1.117, 0.0, -2.376]
max_pressure: 0.34
center_of_pressure: [0.031, 0.0, 0.015]from pyrpod.mdao.reference_data import load_reference_dataset
report = study.compare(load_reference_dataset('dsmc_run_12.json'))
print(report.max_relative_error('force'))
report.write_csv('comparison.csv')Metrics are applied only where they are mathematically meaningful: absolute
error, relative error (None when the reference is zero, never infinity),
normalized RMSE (by reference range or mean), peak-value error,
integrated-load error (compared as vectors, so a load of the right magnitude
pointing the wrong way is an error) and center-of-pressure displacement. A
quantity the reference supplies but the result does not is reported as
missing_candidate; a case with no matching record is listed in
unmatched_cases. Nothing is fabricated or defaulted.
For the flat-plate tests the independent reference is the exact Cai 2016
solution (pyrpod/plume/CaiImpingement2016.py, Eq. 15 quadrature),
converted to dimensional loads with the case's own normalization — PyRPOD's
own output is never used as the validation baseline. Measured agreement over
the sweep: integrated normal load within ~1.5% mean / 2.2% max, component
heat load within ~15%, matching the documented accuracy of the Maxwellian
engineering chain against the exact collisionless solution.
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
study.compare(...) or point reference.path at the file. Because the
result schema records the mesh, coordinate system, units, source pose, model
and configuration provenance, the transformation can be done from the stored
metadata without rerunning PyRPOD.
- Create (or reuse) a PyRPOD case directory with its
config.ini, target STL, TCF and TDF — the study layer adds nothing here. - Add a study YAML next to it under
study/, settingtarget.reference_pointto the geometry's reference (plate center, cylinder centroid, ...) andtarget.normal/target.tangentto the plane the source should be swept in. - Break the target into components if the loads should be reported
separately, using
face_indicesor aboundsbox on face centroids. - Supply
loads.normalizationonly if valid reference values exist for that geometry; otherwise leave it out and the coefficients are reported as unavailable. - Point
reference.pathat external data whenever it exists.
case/plume/plume_cylinder_sweep/study/cylinder_baseline.yaml is a worked
example on a curved, closed target. Its results are a pipeline smoke case,
not a validated physical answer — no cylinder reference data exists yet — and
it deliberately supplies no normalization inputs.
- No shadowing or occlusion. Face selection is the existing pipeline behavior: a face is struck when it lies inside the plume wedge/radius and its normal faces the source. Plume shadowing, occlusion by intervening geometry, self-shadowing of concave or closed targets and back-facing surfaces are not modeled, so a face hidden behind other geometry still receives load. This branch changes nothing about that; results on closed targets must be read with it in mind.
- Edge-on poses are degenerate. At exactly
+/-90 degthe facing test's dot product is zero in exact arithmetic, so float32 STL normals decide strike membership arbitrarily. The plate-averaged load there is ~zero, but individual near-nozzle faces can carry large grazing values. The committed sweep keeps those poses; the symmetry checks exclude them. - Cumulative fields mean different things per mode. In
single_jfhmode they are a genuine sweep envelope; inper_casemode they only restate the case they belong to, since each case's history starts fresh. Readmax_pressures/cum_strikesin 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. - 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 impulse are deliberately out of scope on this branch.