Extracting more from a finished point: the additional post¶
Every point of a row that names a run type leaves its final saved
simulation, the .fsm the solver saves after the solve. A row may name a
SECOND post-processing artifact, ADDITIONAL_PPROC: p<id>, which the run
ignores; afterwards pyfs-matrix post <matrix> --additional-pproc reopens
each recorded point's .fsm and extracts that artifact from it, with no
solve. This example walks it without a solver:
- write a wing, its reference, a setup and two post-processing artifacts: the one the row runs with, and the additional one, which cuts sections;
- write the row, on FlightStream 26.124, naming the additional artifact;
- record the point as a run records it, in a rehearsal copy of the workspace: its script, its outputs with the saved simulation among them, and the run record with every hash;
- plan the additional post and print the extraction script it would run;
- show one refusal, from a matrix of its own: an additional artifact that probes the flow off the body, which a reopened simulation does not give back.
The workspace it prints first is left as a licensed run finds it: no point
recorded, and wing.fs naming p002. The last section runs it.
A reopened saved simulation was measured on 26.124 alone (RPT-062): the loads, the surface solution, the sections and their sectional loads come back as the run left them, and probe points off the body do not. So the additional post runs on that build and extracts only what comes back.
import shutil
import tempfile
from pathlib import Path
import pyflightstream
from pyflightstream.cases import CampaignConfigError, case_at_point, point_name
from pyflightstream.cases.workflows import build_script, workflow_registry
from pyflightstream.qa.geometry import WingSpec, generate_wing_stl
from pyflightstream.run.matrix import plan_additional_post, plan_matrix
from pyflightstream.script import Script
from pyflightstream.workspace import CampaignWorkspace, RunRecord, RunStatus
from pyflightstream.workspace.matrix import resolve_matrix
from pyflightstream.workspace.naming import MATRIX_POINT_NAME, NamingTemplate, PointName
BUILD = "26.124" # the one build a reopened saved simulation was measured on
ALPHA_DEG = 4.0
workdir = Path(tempfile.mkdtemp(prefix="pyfs_additional_post_"))
workspace = CampaignWorkspace.init(workdir / "wing_study")
# `pyfs-matrix` names every file of a point P<POL>-<point>; so does this example.
workspace = CampaignWorkspace(workspace.root, naming=NamingTemplate(point_name=MATRIX_POINT_NAME))
inputs = workspace.inputs_dir
print(f"workspace: {workspace.root}")
1. The wing and its artifacts¶
The synthetic NACA 0012 wing of pyflightstream.qa.geometry, chord 1 m and
span 8 m, written as an STL in metres with its surface named Wing, and a
sidecar stating the unit and marking the trailing edge by the solver's
detection. The reference places the moment point at the quarter chord, so
the run creates the frame MRP.
Two post-processing artifacts. p001 is the one the row RUNS with: it sums
the wing's loads and cuts nothing. p002 is the ADDITIONAL one: a
distribution of twelve sections across the span, cut in the MRP frame,
and the VTK surface export on top of the Tecplot one. That is all an
additional artifact may ask for: sections and their sectional loads, the
surface solution and the total loads, which a reopened file gives back.
generate_wing_stl(
WingSpec(naca="0012", chord_m=1.0, span_m=8.0, n_chord=12, n_span=16),
inputs / "geometries" / "wing.stl",
name="Wing",
)
(inputs / "geometries" / "wing.boundaries.toml").write_text(
'boundaries = ["Wing"]\n\n[import]\nunits = "METER"\n\n[trailing_edges]\ndetect = "auto"\n',
encoding="utf-8",
)
(inputs / "references" / "r001.toml").write_text(
"area_m2 = 8.0\nchord_m = 1.0\nspan_m = 8.0\n\n"
"[moment_point]\nx_m = 0.25\ny_m = 0.0\nz_m = 0.0\n",
encoding="utf-8",
)
(inputs / "setups" / "s001.toml").write_text("NITER = 300\n", encoding="utf-8")
(inputs / "pproc" / "p001.toml").write_text('[groups]\n"1" = "Wing"\n', encoding="utf-8")
(inputs / "pproc" / "p002.toml").write_text(
"[exports]\nvtk = true\n\n"
'[[sections.distributions]]\nfamilies = ["Wing"]\nframe = "MRP"\nplanes = ["XZ"]\n'
"count = 12\n",
encoding="utf-8",
)
# The installation's path is machine configuration; nothing here launches it,
# so a placeholder serves.
(inputs / "executables.toml").write_text(f'"{BUILD}" = "FlightStream.exe"\n', encoding="utf-8")
2. The row¶
One steady point at 4 degrees, on 26.124, running with p001 and naming
p002 as its additional artifact. The key changes nothing the run does: no
builder reads it, and the run record never carries it.
HEADER = (
"POL | HIDDEN | RUN | AIRCRAFT | CONFIGURATION | DESCRIPTION | FLIGHT_CONDITION "
"| SWEEP_VALUES | GEOMETRY | REF | SET | PPROC | SYMMETRY | SYMMETRY_LOADS | NCPUS "
"| WALLTIME | FS_BUILD | WORKFLOW | VAR_NAMES_VALUES"
)
def write_matrix(path: Path, additional: str, pol: int = 3001) -> Path:
"""Write the one-row matrix naming ``additional`` as its additional pproc.
A POL is stated once in a whole workspace, so a second matrix beside the
first takes a POL of its own.
"""
row = (
f"{pol} | 0 | 1 | Wing | - | sections_after_the_run | MACH:0.1, REmi:2.3, ALPHA:sweep "
f"| {ALPHA_DEG} | wing.stl | r001 | s001 | p001 | NONE | - | - | - | {BUILD} | steady "
f"| ADDITIONAL_PPROC: {additional}"
)
path.write_text("\n".join([HEADER, "-" * len(HEADER), row]) + "\n", encoding="utf-8")
return path
matrix = write_matrix(workspace.root / "wing.fs", "p002")
plan = plan_matrix(
matrix, workspace, name="wing_study", recipes={}, recipe_registry=workflow_registry()
)
print(plan.summary())
(point_plan,) = plan.points
3. The point, recorded as a run records it¶
On a licensed machine pyfs-matrix run wing.fs builds the point's script,
runs it, collects what it wrote into the point's own folder and appends one
record to runs.json, with the sha256 of every file. Here the same public
calls do it, and the one thing a solver would have written is stood in for:
the loads table and the saved simulation are written as placeholders, since
the plan below reads the saved simulation by its hash and never opens it.
IN A REHEARSAL COPY of the workspace, beside it. A stand-in recorded in the
workspace itself would be a point pyfs-matrix run refuses to run again,
over a saved simulation no solver wrote.
licensed = workspace # left as a licensed run finds it; the last section runs it
workspace = CampaignWorkspace(
Path(shutil.copytree(licensed.root, workdir / "rehearsal")),
naming=NamingTemplate(point_name=MATRIX_POINT_NAME),
)
matrix = workspace.root / matrix.name
resolved = resolve_matrix(matrix, workspace, name="wing_study", fs_version=BUILD, recipes={})
(case,) = resolved.campaign.sims
point = {"alpha": ALPHA_DEG}
stem = Path(str(point_plan.script_name)).stem
names = [output.replace("{name}", stem) for output in case.outputs]
run_case = case_at_point(case, point, outputs=names)
script = Script(BUILD)
build_script(run_case, script)
script_path, script_sha256 = workspace.write_script(case.sim_id, f"{stem}.txt", script.render())
sim_dir = workspace.sim_dir(case.sim_id)
for name in names: # what the solver would have written, beside the script
(sim_dir / name).write_text(f"stand-in for the solver's {name}\n", encoding="utf-8")
tag = PointName(point_name(case, point)) # the name the point's folder takes
collected = workspace.collect_outputs(
case.sim_id, [sim_dir / name for name in names], datapoint=tag
)
workspace.append_record(
RunRecord(
run_id=point_plan.run_id,
sim_id=case.sim_id,
point=point,
point_name=str(tag),
matrix_stem=matrix.stem,
recipe="steady",
pproc="p001",
fs_version_requested=BUILD,
package_version=pyflightstream.__version__,
script_path=script_path.relative_to(sim_dir).as_posix(),
script_sha256=script_sha256,
# the geometry's boundary names in the solver's order, which a run
# records and the extraction cites the surfaces by
inventory=list(script.boundary_inventory or []),
raw_flag=False,
status=RunStatus.CONVERGED,
outputs=collected,
outputs_sha256=workspace.output_digests(case.sim_id, collected),
)
)
saved = next(name for name in collected if name.endswith(".fsm"))
print(f"recorded {point_plan.run_id}, its saved simulation {saved}")
4. The extraction script¶
plan_additional_post is what pyfs-matrix post wing.fs --additional-pproc
runs first: for each recorded point it checks that the row states the key,
that the saved simulation is on disk and hashes as its record says, that the
build is the one the point ran on and that the row still creates the frames
the run created; then it builds the script, and launches nothing.
The script OPENS a copy of the saved simulation, cuts the additional artifact's sections in the frame the run created, updates the sections and computes their sectional loads (the file stores the sections and not their loads, RPT-062), exports, and closes. It never solves and never saves.
(extraction,) = plan_additional_post(matrix, workspace)
print(f"{extraction.run_id}: {extraction.status}, {extraction.message}")
print("--- extraction script ---")
print(extraction.script_text)
commands = [line.split(" ", 1)[0] for line in str(extraction.script_text).splitlines()]
for wanted in (
"OPEN",
"NEW_SURFACE_SECTION_DISTRIBUTION",
"UPDATE_ALL_SURFACE_SECTIONS",
"COMPUTE_SURFACE_SECTIONAL_LOADS",
"EXPORT_SOLVER_ANALYSIS_SPREADSHEET",
"EXPORT_SOLVER_ANALYSIS_VTK",
"EXPORT_ALL_SURFACE_SECTIONS",
"EXPORT_SURFACE_SECTIONAL_LOADS",
"CLOSE_FLIGHTSTREAM",
):
if wanted not in commands:
raise AssertionError(f"the extraction script carries no {wanted}")
for never in ("START_SOLVER", "SAVEAS", "UPDATE_PROBE_POINTS"):
if never in commands:
raise AssertionError(f"the extraction script carries {never}; it must never")
5. What an additional artifact may not ask for¶
Probe points off the body are the one thing a reopened simulation was
measured NOT to give back: updated or created after reopening, they differ
from the run's, by up to 4 percent in speed (RPT-062). An additional
artifact declaring [[probes]] is refused when the matrix is bound, naming
the report, before any seat is spent. So are a volume section, the plots of
a march, a surface averaged in time and a base region.
The refusal is shown on a matrix of its own, wing_probes.fs: the same row
naming the probing artifact. wing.fs keeps naming p002.
(workspace.inputs_dir / "pproc" / "p003.toml").write_text(
'[[probes]]\nframe = "MRP"\n\n'
"[[probes.lines]]\nstart = [1.5, -4.0, 0.0]\nend = [1.5, 4.0, 0.0]\n",
encoding="utf-8",
)
probing = write_matrix(workspace.root / "wing_probes.fs", "p003", pol=3002)
try:
plan_additional_post(probing, workspace)
except CampaignConfigError as error:
print(f"refused, as it should be:\n {error}")
if "RPT-062" not in str(error):
raise AssertionError("the refusal does not name the report that measured it") from error
else:
# An example whose claim is that something is refused goes green the day
# the refusal stops firing. This one says so.
raise AssertionError("an additional pproc with probes was not refused")
Running it (licensed)¶
The workspace printed first, wing_study, is the one to run: no point is
recorded there and wing.fs names p002, which the lines below check. On
a licensed machine with 26.124 registered, point inputs/executables.toml
at that installation, then, from the workspace, run pyfs-matrix run
wing.fs and pyfs-matrix post wing.fs --additional-pproc. The extraction
lands in the point's datapoints/DP-<point>/additional/p002/, is recorded
in additional.json beside runs.json, which it never writes, and its
products are posted under post/wing/additional/p002/.
if licensed.read_raw_manifest():
raise AssertionError("the workspace to run records a point, which pyfs-matrix run refuses")
continuation = plan_matrix(
licensed.root / "wing.fs",
licensed,
name="wing_study",
recipes={},
recipe_registry=workflow_registry(),
write_plan=False,
)
if [entry.status for entry in continuation.points] != [entry.status for entry in plan.points]:
raise AssertionError(f"wing.fs no longer plans as it did:\n{continuation.summary()}")
plan_additional_post(licensed.root / "wing.fs", licensed) # binds p002, refusing nothing
print(f"to run on a licensed machine: {licensed.root}, where wing.fs names p002")