cfd-solver-gui — Developer Guide
Table of Contents
1. Overview and Design Philosophy
This guide is the reference for anyone working on the cfd-solver-gui source
code. For installation and day-to-day usage see doc/user-guide.org; for
a quick orientation see README.md.
Within the narrative docs, the Org files in doc/ are the source of truth.
README.md is intentionally a lean first-contact page, and the MkDocs site is
a derived browsing layer built from the Org guides plus selected Python
docstrings.
The application is a thin shell around a Fortran binary. It has two hard invariants that every design decision flows from:
SCHEMAinnamelist_io.pyis the single source of truth for all solver parameters. The UI is generated from it; nothing inconfig_editor.pyormain_window.pyis hard-coded for any individual parameter.- The solver boundary is runner-mediated and file-based. The GUI writes a namelist, the local run manager / worker launch the solver, and durable files plus structured events carry run state back to the GUI.
2. Repository Layout
cfd-solver-gui/ ├── docs/ │ ├── index.md # generated-site landing page source │ ├── api/ │ │ └── index.md # API reference landing page │ ├── guides/ │ │ └── index.md # guide landing page for generated exports │ ├── gui-components.md # curated narrative for UI-heavy modules │ └── solver-docs.md # pointer page to companion solver docs ├── src/cfd_gui/ │ ├── __init__.py # package (version string) │ ├── __main__.py # python -m cfd_gui dispatcher │ ├── entry.py # import-safe dispatcher for GUI vs worker mode │ ├── main.py # GUI-only QApplication setup, high-DPI, theme │ ├── main_window.py # MainWindow: layout, toolbar, menus, run logic │ ├── config_editor.py # ConfigEditor: tabbed widget auto-built from SCHEMA │ ├── plot_canvas.py # PlotCanvas: 4-panel matplotlib figure │ ├── run_manager.py # LocalRunManager: GUI-side worker controller │ ├── run_worker.py # pure-Python worker CLI; manifest/event stream │ ├── _run_util.py # shared run-pipeline helpers (timing schema, JSON I/O) │ ├── solver_runner.py # SolverRunner: QProcess wrapper │ ├── snapshot_monitor.py # Snapshot parser + optional Qt polling helper │ ├── namelist_io.py # SCHEMA, Param, PType; parser; writer │ ├── settings.py # QSettings helpers; solver binary discovery │ └── util/ │ └── exact_sod.py # sod_exact(): analytic Riemann solver (pure NumPy) ├── tests/ │ ├── conftest.py # shared fixtures (sample_nml, sample_dat, …) │ ├── test_entry.py # Tier 1: import-safe dispatcher behavior │ ├── test_namelist_io.py # Tier 1: parser + writer round-trips │ ├── test_snapshot_monitor.py # Tier 1: parsing; Tier 2: signals │ ├── test_config_editor.py # Tier 2: widget ↔ value contracts │ ├── test_exact_sod.py # Tier 1: exact Riemann solver │ ├── test_run_manager.py # Tier 2: local run manager control-plane contract │ ├── test_run_worker.py # Tier 1: worker snapshot-completeness helpers │ └── test_main_window.py # Tier 2: timing log presentation ├── doc/ │ ├── onboarding.org # new-contributor quick start │ ├── developer-guide.org # this file │ └── user-guide.org # end-user documentation ├── CHANGELOG.org # AI-maintained per-commit log ├── AGENT_GUIDE.md # shared agent and contributor workflow guide ├── AGENTS.md # Codex CLI entry point ├── CLAUDE.md # Claude Code entry point ├── README.md # lean quickstart and docs index ├── mkdocs.yml # generated-site configuration └── pyproject.toml # build, deps, ruff, mypy, pytest config
Ephemeral directories (not committed): htmlcov/, .mypy_cache/,
.pytest_cache/, .ruff_cache/. Each solver run now writes to a durable
directory under the application data root:
<AppDataLocation>/runs/YYYYMMDD-HHMMSS[-NN]/. The run directory is not
cleaned up automatically and contains input.nml, run_manifest.json,
events.jsonl, solver output files, and any checkpoints.
3. Architecture
3.1. Data Flow
The runtime sequence for a single solver run:
- Config editor → namelist write.
MainWindow._on_run()resolves the solver path, callsConfigEditor.get_values(), and passes the flat values dict toLocalRunManager.start(). The run manager creates a durable run directory, rewritessnapshot_freqtomax(1, print_freq)when the input value is 0, and writesinput.nmlthere. - Namelist → worker spawn.
LocalRunManagerstarts a helper process viaQProcess. In bundled apps,entry.pychecks for--run-manager-workerbefore importing any GUI modules and dispatches straight torun_worker.py. The worker writesrun_manifest.jsonandevents.jsonl, then launches the solver binary withinput.nmlas the sole CLI argument, using the run directory as the working directory. - Worker poll → structured events. The worker polls the configured
snapshot file and checkpoint files. When a new complete snapshot is
available it appends
progressandsnapshot_readyrecords toevents.jsonl, updatesrun_manifest.json, and mirrors the same events over its stdout pipe. - Structured control plane → GUI updates.
MainWindowlistens toLocalRunManager.event_emitted.progressevents drive the status bar and progress bar.snapshot_readyevents trigger a fresh read ofsnapshot.datfor plot updates. Human log lines are forwarded separately viaLocalRunManager.log_lineand are appended to the log panel without semantic parsing. WhenCFD_GUI_RUN_TIMING=1is present in the environment, the GUI and worker also emitdiagnostic_timingcheckpoints for startup-latency investigation. - Terminal event → final result. When the worker emits
run_finished,run_failed, orrun_stopped,MainWindowloads the configured output file from the run directory (if it exists), callsPlotCanvas.update_numerical()one last time, and restores the UI state.
3.2. Control Plane vs Data Plane
The current architecture deliberately separates what is computed from how the GUI observes and controls the run.
Data plane:
input.nmlis the authoritative run configuration given to the solver.snapshot.dat,result.dat, and checkpoint files are the authoritative solver outputs.- These files are valid without the GUI and are meant to remain useful for debugging, restart, and future remote/HPC workflows.
Control plane:
LocalRunManagerstarts the worker and exposes Qt signals to the GUI.- The worker emits structured JSON events live over stdout and persists the
same events to
events.jsonl. run_manifest.jsonstores the latest known run state so other tools can inspect a run without replaying the full event stream.
This split is the core architectural choice behind the current design:
- human-readable logs stay human-only
- machine-readable state is explicit and versionable
- solver artifacts remain inspectable even if the GUI is closed
- future remote or MPI backends can preserve the same control-plane contract
3.3. Run Directory Layout
A typical run directory looks like:
<AppDataLocation>/runs/20260319-142530/ ├── input.nml ├── run_manifest.json ├── events.jsonl ├── solver_stdout.log ├── snapshot.dat ├── result.dat ├── checkpoint_000100.bin └── latest_checkpoint # written by solver if checkpointing is enabled
Responsibilities by file:
input.nmlis written once before launch.run_manifest.jsonis rewritten in place on every major state transition.events.jsonlis append-only and is the durable event history.solver_stdout.logis the captured merged stdout/stderr stream from the solver process.- solver-generated files remain exactly where the solver expects them relative to its working directory.
3.4. Runtime Roles
There are four runtime actors:
MainWindowowns the user interaction layer: form values, plot updates, status bar text, and log presentation.LocalRunManageris the GUI-side controller: it creates the run directory, writesinput.nml, launches the worker process, and translates the worker's JSON Lines stdout into Qt signals.run_worker.pyis the local orchestration process: it launches the solver, captures logs, watches snapshot/checkpoint files, and updatesrun_manifest.json/events.jsonl.- The solver remains a pure compute process that knows only about the namelist contract and its normal file outputs.
The GUI never talks to the solver process directly on the primary run path. That indirection is intentional: it gives the project one place to evolve future stop/reconnect/remote submission logic.
3.5. Event Protocol
Each line in events.jsonl is a JSON object with:
schema_versiontypetimestamprun_id- event-specific payload fields
Current event types and expected payloads:
| Event type | Purpose | Key payload fields |
|---|---|---|
run_created |
Durable run directory and control files exist | working_dir, manifest_path, event_stream |
run_started |
Solver process was launched | solver_binary, solver_pid |
diagnostic_timing |
Opt-in startup timing checkpoint | process, phase, offset_ms, details |
log_line |
Human log line from solver stdout/stderr | line |
progress |
New snapshot iteration/time became visible | iteration, simulation_time |
snapshot_ready |
GUI may safely reload snapshot.dat |
snapshot_path, iteration, simulation_time |
checkpoint_written |
New checkpoint artifact was discovered | checkpoint_path |
stop_requested |
GUI requested graceful termination | (none beyond common fields) |
run_finished |
Solver exited successfully | exit_code, status_message, result_path |
run_failed |
Solver or worker launch failed / solver failed | exit_code, status_message, result_path |
run_stopped |
Stop request completed | exit_code, status_message, result_path |
Semantics:
diagnostic_timingis emitted only whenCFD_GUI_RUN_TIMING=1. Theprocessfield is eitherguiorworker.phaseis a stable startup checkpoint label,offset_msis elapsed wall-clock time from the shared startup origin, anddetailsis optional extra context.progressandsnapshot_readyare emitted only after the worker sees a complete, parseable snapshot file.log_lineis not part of the machine-control contract; it is preserved only as display/debug text.- terminal events are mutually exclusive; exactly one of
run_finished,run_failed, orrun_stoppedshould close a run.
3.6. Run State Model
The manifest state machine is intentionally small:
| State | Meaning |
|---|---|
created |
Run directory exists; worker has not yet launched solver |
running |
Solver process is active |
stopping |
GUI requested stop; worker has sent termination |
succeeded |
Solver exited with code 0 |
failed |
Solver failed to start or exited unsuccessfully |
stopped |
Stop request completed |
Rules:
run_manifest.jsonis the latest snapshot of truth, not a historical log.events.jsonlis the historical log, not the authoritative latest state.- The GUI should be able to rebuild its user-visible state from either the live event stream or the final manifest plus files in the run directory.
3.7. Why This Scales Toward MPI and Remote Runs
The current implementation is intentionally local only, but the contract is chosen so it can grow without forcing a GUI rewrite.
The intended migration path is:
- Keep the GUI-side
LocalRunManagerinterface stable. - Replace the local worker with a remote runner, scheduler adapter, or MPI launcher when needed.
- Preserve the same high-level event types and manifest semantics, even if
the backing transport changes from local
QProcessstdout to a socket, WebSocket, or RPC stream. - Keep solver-produced artifacts file-based so post-processing and restart logic do not depend on the GUI process.
The important architectural boundary is therefore GUI ↔ runner, not GUI ↔ solver rank 0. That is what keeps the current implementation useful for future 2-D/3-D and cluster-oriented work.
3.8. Component Responsibilities
namelist_io.py owns everything to do with the Fortran namelist contract:
the Param dataclass, the PType enum, the SCHEMA list, the parser
(parse_namelist, flat_from_namelist), and the writer (write_namelist).
It deliberately knows nothing about Qt.
config_editor.py owns the tabbed Qt widget built from SCHEMA. It reads
SCHEMA at construction time to generate one tab per group, one widget per
parameter. Its only public methods are get_values(), set_values(), and
reset_defaults(). It emits value_changed but does not act on it — callers
decide what to do with parameter changes.
entry.py owns the first bootstrap decision for python -m cfd_gui and
bundled app launches. It decides whether --run-manager-worker should go to
the worker path before importing GUI modules.
run_manager.py owns the GUI-side structured run boundary. LocalRunManager
creates durable run directories, writes input.nml, launches the helper worker
process, and surfaces structured events back to the GUI.
run_worker.py owns worker-side orchestration: run_manifest.json,
events.jsonl, stop requests, checkpoint discovery, and progress detection
from complete snapshot files.
_run_util.py is a lightweight shared helper module imported by both
run_manager.py and run_worker.py. It holds the diagnostic_timing event
schema constants (_EVENT_SCHEMA_VERSION, _TIMING_ENV_VAR), JSON utilities,
and _timing_enabled() / _timing_payload() so both processes emit identical
event shapes. It has no Qt or subprocess imports.
solver_runner.py owns the QProcess lifecycle and nothing else. It does
not know about namelists, file paths beyond what it is passed, or the GUI's
structured run protocol. It remains as a low-level helper module, but the
main window now uses run_manager.py for the primary run path.
snapshot_monitor.py owns the polling loop and the file format contract for
snapshot.dat / result.dat. It also exports load_result_file() for
one-shot loading (used by MainWindow on solver exit and on File → Load
Result).
plot_canvas.py owns the matplotlib figure and its style settings. It
knows which physical quantities to plot and how to derive internal energy from
p, rho, and gam. It does not know about Qt signals or solver state.
The "Exact" legend entry is shown only when update_exact() has been called
with data; clear_exact() hides it by setting the line label to
_nolegend_ and rebuilding the axes legends.
util/exact_sod.py is a pure-NumPy module with no Qt dependency. It
implements the analytic exact Riemann solver via Newton iteration and is
called by MainWindow._compute_exact() for Sod runs once t > 0. The
public entry point is sod_exact(x, t, rho_L, u_L, p_L, rho_R, u_R, p_R, gam,
x0), which returns (rho, u, p) arrays at time t.
main_window.py is the orchestrator. It constructs all other components,
wires signals to slots, owns the toolbar/menu actions, and drives the run
lifecycle. Business logic that spans multiple components lives here.
settings.py owns QSettings access. All reads/writes of persistent
application state go through its helper functions — no other module calls
QSettings directly.
3.9. Signal and Slot Contract Table
This table is the primary update target whenever a signal on the main run path is added, removed, renamed, or its parameter types change.
| Signal | Declared in | Parameters | Semantics | Connected to in main_window.py |
|---|---|---|---|---|
LocalRunManager.log_line |
run_manager.py |
(str) |
One human log line from solver or worker | MainWindow._on_log_line |
LocalRunManager.event_emitted |
run_manager.py |
(object) |
Structured event dict from the worker | MainWindow._on_run_event |
ConfigEditor.value_changed |
config_editor.py:52 |
(str, object) |
(key, new_value) on any widget change |
(not connected in MainWindow) |
Notes:
LocalRunManager.event_emittedis typed asSignal(object)because the payload is a plain Pythondictdecoded from JSON Lines.ConfigEditor.value_changedis defined for subclasses or external callers that want per-keystroke notification.MainWindowreads the editor synchronously viaget_values()instead.
3.10. File Format Contracts
3.10.1. snapshot.dat / result.dat
Both files share the same format (SnapshotMonitor handles both):
# iter= 100 t= 1.500000E-02 0.000000000000E+00 1.000000000000E+00 0.000000000000E+00 1.000000000000E+00 1.000000000000E-02 9.950000000000E-01 1.000000000000E-02 9.900000000000E-01 …
- Line 1: comment
# iter=NNN t=T. The_HEADER_REinsnapshot_monitor.pyextractsiterationandtimefrom this line. - Remaining lines: four columns in ES20.12 format —
x,rho,u,p. Comment lines (starting with#) are skipped. load_result_file()usesnp.loadtxton comment-stripped content and requires exactly 4 columns; any other shape raisesValueError.
3.10.2. run_manifest.json / events.jsonl
Each run directory contains two machine-readable control-plane files:
run_manifest.json— the latest run state, includingrun_id, paths, solver PID, last snapshot iteration/time, checkpoint paths, exit code, and final status message.events.jsonl— append-only JSON Lines event stream. The current event types arerun_created,run_started,log_line,progress,snapshot_ready,checkpoint_written,stop_requested,run_finished,run_failed, andrun_stopped.- Both files are authored by
run_worker.py. The GUI consumes the same structured events live over the worker stdout pipe.
3.10.3. Namelist writer sentinel keys
write_namelist() always includes these keys regardless of whether they match
the default, to produce self-documenting namelists:
n_cell, time_stop, flux_scheme, recon_scheme, time_scheme, problem_type, output_file
3.10.4. QSettings keys
| Key | Type | Default | Description |
|---|---|---|---|
solver/binary |
str | "" |
Absolute path to solver executable |
appearance/font_size |
int | 13 (macOS) / 10 | Base font size in pt; range 8–20 |
paths/last_nml_dir |
str | $HOME |
Directory of last .nml file dialog |
paths/last_project_dir |
str | $HOME |
Directory of last project file dialog |
Organisation string: "cfd-solver", application string: "cfd-solver-gui".
Platform storage locations:
- Linux:
~/.config/cfd-solver-gui/cfd-solver-gui.conf - macOS:
~/Library/Preferences/com.cfd-solver-gui.plist - Windows:
HKEY_CURRENT_USER\Software\cfd-solver-gui
The durable run root is not stored in QSettings. It is derived from
QStandardPaths.AppDataLocation with a fixed runs/ suffix.
4. The Parameter Schema
4.1. Schema Structure
SCHEMA in namelist_io.py is a list[Param]. The Param dataclass:
@dataclass
class Param:
key: str # Fortran variable name (lowercase)
group: str # Namelist group, e.g. 'grid', 'schemes'
ptype: PType # INT | FLOAT | BOOL | CHOICE | STRING
default: ScalarValue
label: str # Short GUI label
tooltip: str = ""
choices: list[str] = field(default_factory=list) # CHOICE only
min_val: float | None = None # INT/FLOAT only
max_val: float | None = None # INT/FLOAT only
PType → widget mapping in ConfigEditor._make_widget():
PType |
Qt widget | Notes |
|---|---|---|
INT |
QSpinBox |
min/max from Param; default -999,999/+999,999 if unset |
FLOAT |
QDoubleSpinBox |
8 decimal places; adaptive step |
BOOL |
QCheckBox |
|
CHOICE |
QComboBox |
items populated from choices list |
STRING |
QLineEdit |
free-form text |
GROUPS is derived as list(dict.fromkeys(p.group for p in SCHEMA)), which
preserves insertion order and deduplicates. Tab order in the editor follows
GROUPS order. _GROUP_TITLES in config_editor.py maps group names to
human-readable tab titles:
| Group name | Tab title |
|---|---|
grid |
Grid |
time_ctrl |
Time |
physics |
Physics |
schemes |
Schemes |
initial_condition |
IC / BCs |
output |
Output |
checkpoint |
Checkpoint |
4.2. Adding a Parameter (step-by-step recipe)
- Add a
Param(...)entry toSCHEMAinnamelist_io.pyinside the appropriate group block (maintain the# fmt: off/# fmt: onregion). - If it is
PType.CHOICE, ensure the Fortran solver's namelist parser accepts all values inchoices. Order the list with the recommended default first if possible, then specifydefault=to match. - If a new namelist group is needed, choose a group name matching the
Fortran
&group_nameand add it to_GROUP_TITLESinconfig_editor.py.GROUPSupdates automatically fromSCHEMA— no manual edit needed. - If the parameter must always appear in written namelists regardless of its
default value, add its key to the sentinel tuple in
write_namelist(). - Run the full test suite:
pytest.TestSchemaIntegrity(intest_namelist_io.py) catches structural mistakes automatically (missing choices list for CHOICE type, out-of-range defaults, etc.). - Update the parameter reference table in this guide with a new row.
- If the parameter is user-visible (appears in a config tab), update the
relevant tab section under Configuring a Simulation in
doc/user-guide.org.
4.3. Complete Parameter Reference Table
This table must be kept in sync with SCHEMA in namelist_io.py.
| Key | Group | Type | Default | Choices / Range | GUI label |
|---|---|---|---|---|---|
n_cell |
grid | INT | 500 | 1 … 100 000 | Cells |
x_left |
grid | FLOAT | 0.0 | (unbounded) | x left [m] |
x_right |
grid | FLOAT | 1.0 | (unbounded) | x right [m] |
dt |
time_ctrl | FLOAT | 1.0e-4 | (unbounded) | Δt [s] |
time_start |
time_ctrl | FLOAT | 0.0 | (unbounded) | t start [s] |
time_stop |
time_ctrl | FLOAT | 0.15 | (unbounded) | t stop [s] |
cfl |
time_ctrl | FLOAT | 0.0 | (unbounded) | CFL |
lapack_solver |
time_ctrl | BOOL | True | LAPACK banded solve | |
gam |
physics | FLOAT | 1.4 | ≥ 1.001 | γ |
flux_scheme |
schemes | CHOICE | lax_friedrichs | see below | Flux scheme |
recon_scheme |
schemes | CHOICE | weno5 | see below | Reconstruction |
time_scheme |
schemes | CHOICE | rk3 | see below | Time integrator |
char_proj |
schemes | CHOICE | auto | auto / yes / no | Char. projection |
limiter |
schemes | CHOICE | minmod | see below | MUSCL limiter |
use_positivity_limiter |
schemes | BOOL | False | Positivity limiter | |
use_hybrid_recon |
schemes | BOOL | False | Hybrid reconstruction | |
hybrid_sensor |
schemes | CHOICE | jameson | jameson | Hybrid sensor |
| density_gradient | |||||
| weno_beta | |||||
hybrid_sensor_threshold |
schemes | FLOAT | 0.1 | (unbounded) | Sensor threshold |
problem_type |
initial_condition | CHOICE | sod | see below | Problem type |
ic_file |
initial_condition | STRING | "" | IC file | |
ic_interp |
initial_condition | BOOL | True | Interpolate IC | |
ic_udf_src |
initial_condition | STRING | "" | UDF source | |
bc_left |
initial_condition | CHOICE | dirichlet | see below | BC left |
bc_right |
initial_condition | CHOICE | dirichlet | see below | BC right |
rho_left |
initial_condition | FLOAT | 1.0 | (unbounded) | ρ left [kg/m³] |
u_left |
initial_condition | FLOAT | 0.0 | (unbounded) | u left [m/s] |
p_left |
initial_condition | FLOAT | 1.0 | (unbounded) | p left [Pa] |
rho_right |
initial_condition | FLOAT | 0.125 | (unbounded) | ρ right [kg/m³] |
u_right |
initial_condition | FLOAT | 0.0 | (unbounded) | u right [m/s] |
p_right |
initial_condition | FLOAT | 0.1 | (unbounded) | p right [Pa] |
x_diaphragm |
initial_condition | FLOAT | 0.5 | (unbounded) | x diaphragm [m] |
p_ref_left |
initial_condition | FLOAT | 1.0 | (unbounded) | p ref left [Pa] |
p_ref_right |
initial_condition | FLOAT | 1.0 | (unbounded) | p ref right [Pa] |
sigma_nrbc |
initial_condition | FLOAT | 0.0 | (unbounded) | σ NRBC |
nrbc_mode |
initial_condition | CHOICE | pressure | pressure | NRBC mode |
| characteristic | |||||
u_ref_left |
initial_condition | FLOAT | 0.0 | (unbounded) | u ref left [m/s] |
u_ref_right |
initial_condition | FLOAT | 0.0 | (unbounded) | u ref right [m/s] |
rho_ref_left |
initial_condition | FLOAT | 1.0 | (unbounded) | ρ ref left |
rho_ref_right |
initial_condition | FLOAT | 1.0 | (unbounded) | ρ ref right |
sigma_nrbc_entropy |
initial_condition | FLOAT | 0.0 | (unbounded) | σ entropy |
output_file |
output | STRING | "result.dat" | Output file | |
print_freq |
output | INT | 50 | ≥ 1 | Print freq |
do_timing |
output | BOOL | False | Timing | |
verbosity |
output | INT | 3 | 0 … 4 | Verbosity |
log_file |
output | STRING | "run.log" | Log file | |
snapshot_freq |
output | INT | 0 | ≥ 0 | Snapshot freq |
snapshot_file |
output | STRING | "snapshot.dat" | Snapshot file | |
checkpoint_freq |
checkpoint | INT | 0 | ≥ 0 | Checkpoint freq |
checkpoint_file |
checkpoint | STRING | "checkpoint" | Checkpoint base | |
restart_file |
checkpoint | STRING | "" | Restart file |
Choice lists not shown inline above:
flux_scheme:lax_friedrichs,steger_warming,van_leer,ausm_plus,hll,hllc,roerecon_scheme:upwind1,upwind2,central2,muscl,eno3,weno5,weno5z,weno7,mp5,teno5,linear_wenotime_scheme:euler,ssprk22,rk3,rk4,ssprk54,beuler,bdf2limiter:minmod,superbee,mc,van_leer,korenproblem_type:sod,shu_osher,smooth_wave,linear_advection,woodward_colella,lax,acoustic_pulse,from_file,udfbc_left/bc_right:dirichlet,inflow,outflow,reflecting,periodic,nonreflecting
4.4. Removing or Renaming a Parameter
- Remove or rename the
Paramentry inSCHEMA. - Check the sentinel tuple in
write_namelist()— remove the key if present. - Check
tests/conftest.pyfixtures for hard-coded key names (sample_nml_textusesflux_scheme,recon_scheme,time_scheme,problem_type,output_file,snapshot_freq). - Search for the key string across the test files:
pytesttests that assert specific values may reference it by name. - Update the parameter reference table in this guide.
- If user-visible, update the relevant tab section under Configuring a Simulation in
doc/user-guide.org.
5. Namelist I/O
5.1. Parser Behaviour
parse_namelist() is regex-based and handles scalar values only (no arrays,
no continuation lines).
Processing steps:
- Strip comments:
re.sub(r"!.*", "", text). This is applied to the entire file before group matching. - Match namelist groups:
re.finditer(r"&(\w+)(.*?)/", text, re.DOTALL). Group name is lowercased. - Split group body on
,or newline; partition each token on=. - Cast the raw value string via
_cast_value().
_cast_value() type inference order (first match wins):
- Boolean: raw (lowercased, stripped of trailing comma) in
{".true.", ".t.", "true", "t"}or{".false.", ".f.", "false", "f"}. - Fortran string: wrapped in single or double quotes → strip quotes, return str.
- Float: raw contains
.,e, ord(case-insensitive) → replaced/Dwithe/E, callfloat(). - Integer:
int()on the raw string. - Fallback: return raw string unchanged.
Known limitations:
- Array literals (
a = 1, 2, 3) are not supported. - Continuation lines (
&at end of line) are not supported. - Group name collision: if two
&gridgroups exist, the second overwrites the first (groups[name] = entriesunconditionally).
5.2. Writer Behaviour
write_namelist(values, path) takes a flat {key: value} dict and writes a
grouped namelist.
Logic:
- Compute defaults from
defaults_dict(). - For each
ParaminSCHEMA, include(key, value)in the output group only ifvalue !default=. - Additionally, always include the sentinel keys (regardless of value):
n_cell,time_stop,flux_scheme,recon_scheme,time_scheme,problem_type,output_file. This produces self-documenting namelists. - Groups with no entries to write are omitted entirely.
- Group order follows
GROUPS(derived fromSCHEMAinsertion order).
Float formatting in _format_value():
- If
abs(val) < 1e-3orabs(val) >1e6= (andval !0=): scientific notation with Fortrandexponent (e.g.1.234567d-05). - Otherwise:
%g(shortest representation without trailing zeros).
5.3. Extending the Parser
Safe changes (no risk of breaking existing namelists):
- Widening the comment pattern to also strip
!.. comments starting after non-whitespace (currently the whole line after!is stripped, which is already correct Fortran behaviour). - Adding tolerance for extra whitespace.
Potentially breaking changes:
- Altering the
_cast_valueinference order: code that currently produces an int might produce a float, or vice versa. - Changing string-quote stripping to also handle
"""would be safe only if the solver never produces"""in variable values.
6. Solver Process Integration
6.1. Primary Launch Path
The primary run path is now a two-stage launch:
LocalRunManager.start()writesinput.nmland launches a helper worker viaQProcess. The worker is either the bundled executable re-entered with--run-manager-workerorrun_worker.pywhen running from source.run_worker.pylaunches the solver itself withsubprocess.Popen([binary, nml_path], cwd=run_dir, stdout=PIPE, stderr=STDOUT, stdin=DEVNULL, text=True, bufsize=1).
LocalRunManager keeps worker stdout and stderr separate at the QProcess
boundary because stdout carries structured JSON events and stderr is reserved
for worker-launch failures. The worker then merges the solver's stdout/stderr
into a single captured stream and mirrors human log lines back as log_line
events.
solver_runner.py remains in the repository as a low-level QProcess
wrapper, but it is not the primary integration path for normal GUI runs.
6.2. Progress Detection and Stop Behaviour
On the primary run path, progress comes from the snapshot file rather than from solver stdout parsing.
run_worker.py polls snapshot.dat every 100 ms and emits progress /
snapshot_ready only when all of the following are true:
- the snapshot path exists
stat().st_mtimeadvanced- the file ends with a newline and every data row has exactly four floats
- the header line matches
iter/tmetadata - the iteration number differs from the last emitted snapshot
Stop behaviour is also worker-mediated:
MainWindowcallsLocalRunManager.stop().LocalRunManagerwrites{ "type": "stop" }to the worker stdin.- The worker marks the manifest
stopping, emitsstop_requested, and callsproc.terminate()on the solver. - If the solver is still alive after 3 seconds, the worker calls
proc.kill().
6.3. Work Directory Strategy
Each run creates an isolated directory:
run_dir = create_run_directory()
The worker launches the solver with this as CWD, so relative paths in the
namelist (e.g.
output_file = 'result.dat') resolve inside the work directory without
collision between concurrent or successive runs.
The run directory is not cleaned up automatically after the run finishes.
Its path is printed to the log panel so the user can inspect or copy result
files. The same directory also holds run_manifest.json and events.jsonl,
so the full run record is preserved in one place.
7. Live Snapshot Monitoring
7.1. Polling Design
The primary run path now polls snapshots inside run_worker.py. The polling
logic intentionally mirrors the old
SnapshotMonitor design because it is portable and robust. SnapshotMonitor
remains available as a standalone Qt helper for direct file watching and for
tests covering the file-format parser.
Change detection uses two independent gates (both must advance for a new
snapshot_ready event):
- mtime gate: the current
stat().st_mtimemust advance. This prevents re-parsing an unchanged file. - Iteration dedup: the parsed iteration number must differ from the last emitted snapshot. This prevents emitting the same logical snapshot twice if the file is touched but the solver has not yet advanced an iteration.
Why not inotify/FSEvents/kqueue? Portability. Polling works identically on Linux, macOS, and Windows without platform-specific code or optional dependencies.
7.2. SnapshotData Dataclass
class SnapshotData:
__slots__ = ("iteration", "time", "x", "rho", "u", "p")
__slots__ is used for memory efficiency (the object is created on every
successful poll). Internal energy is not stored in SnapshotData — it is
derived in PlotCanvas.update_numerical() from p, rho, and gam at
render time. This keeps the snapshot format faithful to the file and avoids
coupling the monitor to the physics value of gam.
7.3. snapshot_freq Forcing Logic
LocalRunManager.start() contains:
if values.get("snapshot_freq", 0) == 0:
values["snapshot_freq"] = max(1, int(values.get("print_freq", 50)))
Rationale: the live plot is a core feature of the GUI. If the user (or a
loaded namelist) leaves snapshot_freq at 0, the run manager silently enables
snapshot output for that run by matching print_freq. The override only
affects the namelist written for the current run; the editor widget is not
modified.
8. Plot Canvas
8.1. matplotlib Embedding
PlotCanvas is a QWidget that contains a FigureCanvasQTAgg (the matplotlib
figure) and a NavigationToolbar2QT (zoom, pan, home, back/forward).
The _RC dict in plot_canvas.py mirrors the RC settings from the solver's
post/_common.py script to ensure GUI plots have the same visual style as
offline post-processed figures. Key choices: sans-serif fonts, 8 pt base,
no top/right spines, inward ticks, pdf.fonttype=42 (Type 1 embedding).
Two sets of Line2D handles are pre-created at construction time:
_lines_exact: blue (#0077BB), solid, line only. Exact/reference solution._lines_num: orange (#EE7733), no line, circle markers (ms=2). Numerical solution.
_FIELDS defines the four panels in order: (ρ, Density), (u, Velocity),
(p, Pressure), (e, Internal energy).
8.2. Internal Energy Derivation
Internal energy is not in the data file; it is computed in both
update_numerical() and update_exact():
e = np.where(rho > 0, p / (rho * (gam - 1.0)), 0.0)
gam is read from ConfigEditor.get_values() at each update (in MainWindow
before calling update_numerical()), so the panel stays physically meaningful
even if the user changes gam between runs. The np.errstate guard prevents
divide-by-zero warnings when rho cells are zero (e.g. initial empty
boundaries).
8.3. Extending the Plot
To add a fifth panel (e.g. Mach number):
- Add a tuple to
_FIELDSinplot_canvas.py:(ylabel, title). - Change
self._fig.subplots(2, 2)toself._fig.subplots(2, 3)(orsubplots(3, 2)) and update the layout as needed. - Append a new line handle in the loop that creates
_lines_numand_lines_exact. - Add the derived quantity to
update_numerical()andupdate_exact(), inserting it at the correct index in thezipcall over_lines_num. - Update matplotlib Embedding in this guide and Live Plot Updates in
doc/user-guide.org.
9. Settings Persistence
All access to QSettings goes through helper functions in settings.py.
No other module calls QSettings directly.
| Function | Key read/written | Purpose |
|---|---|---|
solver_binary() |
solver/binary |
Read configured solver path |
set_solver_binary() |
solver/binary |
Persist solver path |
_bundled_solver() |
(none) | Return path to euler_1d inside sys._MEIPASS, or '' |
auto_detect_solver() |
(none) | Bundled binary first, then $PATH for euler_1d / cfd-solver |
ensure_solver_binary() |
solver/binary |
Return usable path; auto-detect and persist if unset |
last_nml_dir() |
paths/last_nml_dir |
Read last .nml dialog directory |
set_last_nml_dir() |
paths/last_nml_dir |
Persist last .nml dialog directory |
font_size() |
appearance/font_size |
Read user's preferred base font size (int pt) |
app_data_dir() |
(none) | Return writable application data directory |
runs_root_dir() |
(none) | Return <AppDataLocation>/runs |
create_run_directory() |
(none) | Create a unique durable directory for one solver run |
set_font_size() |
appearance/font_size |
Persist base font size |
last_project_dir() |
paths/last_project_dir |
Read last project dialog directory |
set_last_project_dir() |
paths/last_project_dir |
Persist last project dialog directory |
_solver_binary_names() returns the platform-appropriate executable names:
("euler_1d", "cfd-solver") on Unix-like systems and
("euler_1d.exe", "cfd-solver.exe") on Windows.
Solver binary detection order (auto_detect_solver() → ensure_solver_binary()):
_bundled_solver()— whensys.frozenis set (PyInstaller app bundle), checksPath(sys._MEIPASS)for each platform candidate name and returns the first matching solver executable.- Stored
QSettingsvalue — ifsolver/binarywas previously saved and still points to an existing file. $PATHwalk —shutil.which()over_solver_binary_names()in order.
Build scripts populate the dist_binaries/ staging directory before
PyInstaller runs; the spec picks everything up from there:
Bundled worker launches re-enter the same executable with
--run-manager-worker. entry.py handles that flag before importing
main.py so the worker avoids paying for Qt / matplotlib startup on every
solver run.
scripts/build_macos.sh— builds viafpm; recursively bundles transitive.dylibdeps (libgfortran,libquadmath,libgcc_s) with@executable_path/@loader_pathinstall-name fixup; then calls PyInstaller and wraps the result in a DMG.scripts/build_linux.sh— Ubuntu 22.04; staticlibgfortran/libstdc++;patchelf --set-rpath '$ORIGIN'for bundled.sofiles; then PyInstaller + tarball (AppImage ifappimagetoolis available).scripts/build_windows.sh— MSYS2UCRT64; buildseuler_1d.exewithfpm, stages solver DLL dependencies intodist_binaries/vialdd, then runs PyInstaller and archives the onedir bundle ascfd-solver-gui-windows-x86_64.zip.
To add a new setting: add a helper pair (reader + writer) to settings.py,
using _settings().value(key, default) / _settings().setValue(key, val).
Update Settings Persistence in this guide and Application Settings in doc/user-guide.org.
10. Test Suite
10.1. Tier Structure
| Tier | Marker | Runs with | What it guards |
|---|---|---|---|
| 1 | (none) | pytest, no display |
Pure Python logic: parser, writer, file format |
| 2 | @pytest.mark.qt |
pytest, offscreen Qt |
Widget construction, signal emission, UI ↔ value contracts |
pytest-qt sets QT_QPA_PLATFORM=offscreen automatically when DISPLAY is
unset, so Tier 2 tests run headless in CI without xvfb-run.
When to add to each tier:
- Tier 1: any test that does not construct a
QWidgetorQApplication. Namelist round-trips, schema validation, snapshot file parsing, mtime logic (withtime.time()monkeypatching). - Tier 2: any test that constructs a
ConfigEditor,PlotCanvas, orMainWindow, or that asserts on signal emission (useqtbot.waitSignal).
10.2. Conftest and Fixtures
Fixtures defined in tests/conftest.py:
| Fixture | Returns | Description |
|---|---|---|
sample_nml_text |
str | Minimal valid namelist covering all group types |
sample_nml |
pathlib.Path |
sample_nml_text written to a tmp_path file |
sample_dat_text |
str | Three-row snapshot file with valid header |
sample_dat |
pathlib.Path |
sample_dat_text written to a tmp_path file |
Note: sample_nml_text uses time_scheme = 'rk2' which is not in the
current SCHEMA choices list. This is intentional — it tests that the parser
handles unknown choice values gracefully (they are stored as strings and
trigger no error at parse time; only ConfigEditor.set_values() silently
ignores unknown values).
qtbot is provided automatically by pytest-qt; it does not appear in
conftest.py.
10.3. Adding Tests for a New Parameter
- Schema integrity: covered automatically by
TestSchemaIntegrityintest_namelist_io.py(checks that every CHOICE param has a non-empty choices list, every FLOAT param withmin_valhasdefault >min_val=, etc.). - Round-trip (writer → parser): add a test in
TestWriteNamelistthat sets the new value, writes a namelist, parses it back, and asserts equality. - Widget ↔ value contract: add a
@pytest.mark.qttest intest_config_editor.pythat constructs aConfigEditor, callsset_values({key: value}), thenget_values(), and asserts the round-trip.
10.4. Coverage Targets
mypy is run in strict mode for pure-Python modules (namelist_io,
snapshot_monitor except the signal-wired parts, settings). Qt widget
modules (config_editor, plot_canvas, main_window) have
ignore_errors = true in pyproject.toml due to the limitations of PySide6
stubs.
11. Contributing and Commit Discipline
See AGENT_GUIDE.md — the Commit Checklist and Documentation Maintenance
sections govern when and how to update CHANGELOG.org, this guide, and the
user guide. doc/onboarding.org is the short contributor entry point, and
the MkDocs site is derived from the Org sources plus selected Python
docstrings. Those rules are not duplicated here to avoid drift.