A campaign from a run matrix, with workspace pre-flight¶
This example shows the campaign side of the library end to end, without a FlightStream license:
- the run matrix as the input format (one row per simulation);
- converting a matrix to the canonical
campaign.toml; - the managed workspace and a zero-solver pre-flight
(
plan_campaign) that builds every script and checks every geometry before any run.
Everything here is pure Python; the only licensed step is the final
run_campaign, shown but not executed.
import sys
import tempfile
from pathlib import Path
from pyflightstream.cases import Campaign, SimCase, SweepAxis
from pyflightstream.cases.matrix import convert_matrix, read_matrix
from pyflightstream.run import plan_campaign
from pyflightstream.script import helpers
from pyflightstream.workspace import CampaignWorkspace
workdir = Path(tempfile.mkdtemp(prefix="pyfs_campaign_"))
1. The run matrix as input¶
The run matrix is a pipe-delimited table, one row per simulation:
aircraft, the flight condition, the sweep type and values, the
reference/setup/entry/script codes that resolve against the input
library, the WORKFLOW type, and a free VAR_NAMES_VALUES cell.
read_matrix parses it into typed rows; by default only the rows with
RUN = 1 come back.
Three things in the header are newer than the format itself.
FLIGHT_CONDITION states the whole flow condition of a row in one
cell, as comma-separated KEY:value pairs from a closed set; it
REPLACED the RE and
MACH columns at 0.9.0, and which quantity the resolver solves for
follows from which keys are present rather than from which columns are
mandatory. The cell also says WHICH VARIABLE THE ROW SWEEPS, by
carrying the word sweep where that key's value would be: ALPHA:sweep
below means the values in SWEEP_VALUES are incidences, and BETA:0.0
beside it is the sideslip held at every one of them. Exactly one key may
carry the word, which is why the SWEEP_TYPE column that named it a
second time went at 0.15.0. WORKFLOW
names which workflow builds the row's script, in a column of its own
so the type never competes with the free KEY:VALUE case data;
LEGACY is the workflow every row written before the column existed
asks for. upgrade_matrix converts a file written under any older
layout and touches no other byte. And the REF, SET and PPROC codes carry a
letter naming their kind (r, s, e), so a number mistyped between
the three columns cannot resolve to another artifact's file.
_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"
)
# HIDDEN and RUN come first, straight after POL, so the two cells deciding
# whether a row runs are the first thing read. A column a row does not use
# reads `-`; nothing here is mandatory that was optional before.
_ROW_1 = (
"9001|0|1|TestWing|-|POLAR|MACH:0.1441, REmi:4.38, ALPHA:sweep, BETA:0.0|0.0,2.0,4.0"
"|-|r003|s003|p001|-|-|-|-|MANUAL|LEGACY"
"|FSM_FILE:wing_clean / OUTPUTS: loads_{point}.txt / RECIPE: 003"
)
_ROW_2 = (
"9002|0|0|TestWing|-|PARKED|MACH:0.0890, REmi:3.10, ALPHA:sweep, BETA:0.0|0.0"
"|-|r003|s002|p001|-|-|-|-|MANUAL|LEGACY"
"|FSM_FILE:wing_clean / OUTPUTS: loads_{point}.txt / RECIPE: 003"
)
MATRIX = "\n".join([_HEADER, "-" * len(_HEADER), _ROW_1, _ROW_2]) + "\n"
matrix_path = workdir / "campaign.fs"
matrix_path.write_text(MATRIX, encoding="utf-8")
rows = read_matrix(matrix_path) # active_only=True skips the RUN=0 row
print(f"active matrix rows: {[row.pol for row in rows]}")
2. Matrix to canonical campaign.toml¶
convert_matrix renders the same rows as a campaign.toml, the
canonical internal form. The recipes mapping binds each
RECIPE code to a module:function recipe that emits the script
for that row.
campaign_toml = convert_matrix(
matrix_path,
name="wing_steady",
fs_version="26.120",
fs_exe="C:/FlightStream/26.12/FlightStream.exe",
recipes={"003": "examples.campaign_matrix:steady"},
)
print("--- campaign.toml (first lines) ---")
print("\n".join(campaign_toml.splitlines()[:8]))
Conversion carries the output names too. A row declares them in
VAR_NAMES_VALUES as OUTPUTS: loads_{point}.txt, comma-separated
because the slash already separates the KEY:VALUE pairs, and
convert_matrix writes them into the [[sim]] table. The loop
collects only declared outputs, and every point of a case runs in the
same folder, so the name has to carry the point.
3. The recipe¶
A recipe turns one swept point into script emissions. It receives
the per-point case (with case.point filled) and an empty Script
bound to the campaign version; it emits through the curated helpers,
so every line still passes database validation.
READ ONE THING ABOUT case.geometry BEFORE YOU COPY THIS RECIPE. The
recipe below opens case.geometry, and there are three routes to that
field of which only two fill it, which is the whole of what this note
is for.
Section 4 sets it BY HAND, on a SimCase built in Python.
A MATRIX ROW SETS IT with GEOMETRY: <file name> in VAR_NAMES_VALUES,
resolved against inputs/geometries/ the way REF, SET and PPROC
are (v0.8.1). resolve_matrix is what assigns it, for every active row
THAT NAMES THE KEY; a row naming none leaves the field absent, which is
the property the whole release rests on. So a recipe of your own
receives the field exactly as a built-in workflow does, and both
plan_matrix and run_matrix go through there.
convert_matrix, which section 2 uses, resolves no library artifact at
all and leaves the field absent. That is the one route where a recipe
still sees nothing, and it is a property of the CONVERTER rather than
of the matrix.
THIS NOTE SAID THE OPPOSITE until v0.8.1, and the correction is left
visible rather than quietly rewritten: it said a matrix row could not
set the field and that a recipe should read a key of its own and call
workspace.inputs.resolve_geometry, and it told the reader to wait for
a geometry COLUMN. It was true the day it was written. The release that
made it false is the one that shipped the key, and no test READ an
example's prose, so nothing but a reader was ever going to catch it.
One does now: tests/tier1_offline/test_docs_example_currency.py asserts that this
note names the key a row fills the field with, and that the two claims
which went stale cannot come back.
def steady(case: SimCase, script) -> None:
"""Emit the steady one-point script for one matrix row.
Parameters
----------
case : SimCase
The per-point case; ``case.point['alpha']`` is the angle of
attack in degrees for this sweep point.
script : pyflightstream.script.Script
Empty script bound to the campaign FlightStream version.
"""
script.emit("OPEN", case.geometry)
helpers.free_stream(script)
helpers.initialize_solver(script)
helpers.solver_settings(
script,
# Every boundary of this clean wing carries a trailing edge, so
# the vorticity integration applies to all of them. On a mixed
# geometry, list the lifting boundaries instead: a bluff body on
# this list reports zero induced drag (SRC-003 p.202), and
# omitting the argument keeps the solver's pressure integration.
vorticity_drag_boundaries="all",
# NANOMETRES, unlike every other length this helper takes.
# A polished metal skin is a few hundred nm; passing 0.5
# thinking millimetres would be a half-nanometre surface, and
# zero states a smooth wall outright (SRC-003 p.341).
surface_roughness=800.0,
aoa=case.point["alpha"],
velocity=case.velocity,
)
helpers.start_solver(script)
# case.outputs carries the declared names with the point rendered
# in, so what the script exports is what the loop collects, and one
# sweep point cannot overwrite the evidence of another.
script.emit("EXPORT_SOLVER_ANALYSIS_SPREADSHEET", case.outputs[0])
script.emit("CLOSE_FLIGHTSTREAM")
4. Workspace and pre-flight¶
The managed workspace owns the campaign folder tree and the run
manifest. plan_campaign is the pre-flight: it resolves every
recipe, builds every script in dry-run, and checks that every
geometry file exists, all before a single solver call. A broken
recipe or a missing mesh surfaces here as a BLOCKED point instead
of failing mid-run.
geometry = workdir / "wing_clean.fsm"
geometry.write_bytes(b"stand-in geometry for the pre-flight")
campaign = Campaign(
name="wing_steady",
fs_version="26.120",
fs_exe=sys.executable,
sims=[
SimCase(
sim_id="9001",
aircraft="TestWing",
velocity=30.0,
geometry=str(geometry),
sweep=SweepAxis(type="alpha", values=[0.0, 2.0, 4.0]),
recipe="steady",
outputs=["loads_{point}.txt"],
)
],
)
workspace = CampaignWorkspace(workdir / "campaign")
plan = plan_campaign(campaign, workspace, recipes={"steady": steady})
print(f"pre-flight: {plan.summary()}")
for point in plan.points:
print(f" {point.run_id}: {point.status.value}")
5. Running it (licensed)¶
On a licensed machine the same campaign runs with
run_campaign(campaign, LocalExecutor(fs_exe), workspace,
assess=LoadsAssessor()). The assessor is required: the
loop refuses to invent convergence evidence, so you name how each
point is judged. resume=True skips points already in the manifest,
so a sweep can grow point by point across sessions. The pre-flight
above is the
zero-cost rehearsal of exactly that run.