# Architecture
This page is for contributors and engine developers. It shows how the
package is laid out, what happens during a run, and which parts of the code
carry which guarantees. For the rules that bind a change, see
[Policies](policies.md).
## Design principles
- **Example-driven.** Features are added when a real refinement case needs
them. The examples are also the regression references.
- **Schema-first.** Every rule a recipe must obey lives in the schema models.
Validation reports every problem at its field before any engine runs.
- **Honest.** An engine's silent default, floor or conversion is refused at
validation, with the value to write, or reported as a structured warning.
It is never papered over.
- **Engine-native.** Nothing is translated between engines. A recipe states
the numbers its engine computes with, in that engine's units.
- **Reproducible.** The same recipe produces the same refinement, and the
reference outputs are tracked in the repository.
## Package architecture
```mermaid
flowchart TB
U["powderline CLI · powderline.run / validate"]
subgraph core["powderline core: every environment, imports no engine"]
API["api · kicker"]
REG["registry
entry-point group powderline.gateways"]
SCH["schema_core · symmetry · compat"]
OUT["fitstats · reports · results · roundtrip"]
end
U --> API --> REG
REG --> G1["gateways/gsasii"] --> E1["GSAS-II
env gsasii"]
REG --> G2["gateways/topas"] --> E2["tc.exe
a Windows machine with TOPAS 6"]
REG --> G3["gateways/easydiffraction"] --> E3["easydiffraction
env easydiffraction"]
```
The core is installed in every pixi environment and never imports an engine.
Each gateway adapts one engine. Its engine lives in its own environment, or,
for TOPAS, on the machine that has TOPAS installed. The `dev` environment
holds every engine plus the docs and test tooling. See
[Install](../getting-started/install.md) for the environments.
## Package map
### Core (`src/powderline/`)
| Module | Role |
|---|---|
| `__init__.py` | The public API (`__all__`): `run`, `validate`, `ValidationResult`, `load_recipe_asset`, `support_matrix` and the exceptions |
| `api.py` | `validate()` and `run()`: resolve the gateway from `schema_name` and hand the recipe over |
| `kicker.py` | The command line (`powderline`); refuses templates, maps engine flags onto gateway options |
| `doctor.py` | `powderline --doctor`: gateways found, engine versions against their pins, server state |
| `registry.py` | Gateway discovery from entry points, the `Gateway` dataclass, `register_gateway`, `check_engine_version` |
| `schema_core.py` | Core schema: the recipe frame, parameter shapes, data, fit range, Chebyshev background, the phase block, and the building blocks engine schemas are made from |
| `symmetry.py` | gemmi-backed symmetry: canonical space-group names, cell ties, special positions, coupling groups |
| `compat.py` | Declared schema compatibility, `schema_declarations()`, `support_matrix()` |
| `fitstats.py` | The uniform Rwp, Rexp, GoF and reduced χ² |
| `reports.py` | The standardized report writers and the list of fixed file names |
| `results.py` | The result envelope: status values, failure reasons, `raise_if_failed` |
| `roundtrip.py` | The output recipe and the parameter dispositions |
| `recipes.py` | `load_recipe_asset` and recipe block helpers |
| `exceptions.py` | The exception classes and `WARNING_CODES`, the registry of structured-warning codes |
| `config_loader.py` | Reads `.powderline_config.yaml` (simulation defaults) |
| `_status.py` | Console status glyphs |
### Gateways (`src/powderline/gateways//`)
Every gateway has the same two layers.
- **Schema layer:** `schema.py`. It holds the engine schema models built on
`schema_core`, the version declarations (`DECLARATIONS`), `SCHEMA_NAMES` and
`validate_recipe`. It imports no engine. `powderline.validate()` runs only
this layer.
- **Runtime layer:** `gateway.py` and the modules that drive the engine.
`gateway.py` exports the entry-point factory `get_gateway()` and
`ENGINE_VERSION_SPEC`. The engine is imported lazily, from `run()`.
| Gateway | Runtime modules |
|---|---|
| `gsasii/` (GSAS-II 5.7.9) | Frozen: `helpers.py`, `project.py`, `setters.py`, `executors.py`, `extractors.py`, `constraints.py`. Around them: `routing.py`, `fit_report.py`, `sites.py`, `paths.py`, `report_rows.py`, `console.py`, `scattering.py`, `server.py`, `client.py`, `simulation_builder.py` |
| `topas/` (TOPAS 6) | `native_writer.py` (model to INP and `.xye`), `native_run.py` (runs `tc.exe`, reads its files), `peak_list.py`, `runner.py` (finds `tc.exe`), `scattering.py` |
| `easydiffraction/` (0.21.1) | `native_builder.py` (model to an easydiffraction project), `native_run.py`, `space_group_table.py`, `space_group_support.py`, `scattering.py` |
`gsasii/simulation_builder.py` (`SimulationRecipeBuilder`) turns a
crystal-structure dict into a `gsasii.rietveld` simulation recipe, with
`refinement_cycles: 1` and every refine flag `false`:
- **Input:** `space_group`, `unit_cell`, `formula`, an optional
`material_id`, and `sites`, which lists every atom of the full conventional
cell. The builder reduces `sites` to one atom per orbit, and refuses a list
whose atom count does not match each site's multiplicity.
- **Space group:** written as its canonical name. A group with two origins,
given in origin choice 1, is moved to origin choice 2, because GSAS-II
cannot use origin 1.
- **Defaults:** every value is stated in the recipe, in GSAS-II's units. SH/L
is 0.002, GSAS-II's floor. The built-in defaults are overridden by a
`simulation_defaults` dict, which `config_loader.ConfigLoader` reads from
`.powderline_config.yaml`. Unknown keys there are errors.
### No engine at import time
`import powderline`, `powderline.api`, `powderline.kicker` and the schema
layer of every gateway import no engine. Subprocess tests block the engine
imports and fail if one slips in. Keep engine imports inside code that runs
from `run()`.
```{note}
The `pixi run test` suite installs a guard that makes any engine import fail.
`POWDERLINE_CORE_GUARD=1` forces the guard on for a hand-picked selection;
`=0` turns it off.
```
### Entry points
Gateways are discovered from the `powderline.gateways` entry-point group in
`pyproject.toml`:
```toml
[project.entry-points."powderline.gateways"]
gsasii = "powderline.gateways.gsasii.gateway:get_gateway"
topas = "powderline.gateways.topas.gateway:get_gateway"
easydiffraction = "powderline.gateways.easydiffraction.gateway:get_gateway"
```
There is no in-code fallback. The in-repo gateways use exactly the mechanism a
third-party gateway would.
```{important}
Entry-point metadata is written when the package is installed. After you
edit `[project.entry-points]` or `[project.scripts]`, run
`pixi reinstall powderline --all`, because every environment has its own install.
Until then the registry finds nothing ({ref}`KI-13 `).
```
`register_gateway(name, factory)` registers a gateway in code. It is meant
for tests and development, and takes precedence over an entry point of the
same name.
## A run, step by step
```mermaid
flowchart TD
A["powderline recipe.json
or powderline.run(recipe, output_dir)"] --> B["api: schema_name → registry.get(engine)"]
B --> C["gateway.validate
schema layer"]
C --> D["engine importable?
then clear the fixed-name outputs"]
D --> E["gateway.run
engine version check"]
E --> F["runtime layer
runs the engine"]
F --> G["fitstats · roundtrip
reports"]
G --> H["result dict
status ok or generated"]
F -- "failure" --> X["EngineExecutionError or
ExecutionBackendError
+ failure.json"]
```
1. The CLI or `powderline.run()` reads the engine from `schema_name`
(`.`) and asks the registry for that gateway.
2. The recipe is validated through the gateway's schema layer. An invalid
recipe raises one `pydantic.ValidationError` and nothing on disk changes.
3. `run()` checks that the engine is importable. Only then does it remove
PowderLine's fixed-name files from the output directory.
4. The gateway checks the installed engine version against
`ENGINE_VERSION_SPEC` and runs the engine:
- **gsasii:** `routing.run_refinement` in this process or on the GSAS-II
server, then `executors.run_refinement` and GSAS-II's scripting API.
- **topas:** `native_writer` writes the INP and `.xye`, `tc.exe` runs, and
`native_run` reads TOPAS's files. Without `tc.exe` the run is
generate-only.
- **easydiffraction:** `native_builder` builds the project, then a fit or a
calculation runs, and `native_run` reads the result.
5. The gateway computes the uniform statistics, builds the output recipe and
writes the report files.
6. A failed run raises. `run()` writes the failed envelope to `failure.json`
beside the engine's own files and re-raises. No result table is written.
With `validate_only=True` the gateway returns a summary and runs nothing.
See [Outputs](../reference/outputs.md) for the result dict and
[Results](../concepts/results.md) for what the failure path means to a user.
## GSAS-II execution modes
```mermaid
flowchart TD
M{"execution_mode"}
M -- "subprocess
(--no-server)" --> IP["in this process"]
M -- "auto (default)" --> C["GSASClient"]
M -- "server
(--use-server)" --> C
C --> Q{"server usable?
started if needed,
current code"}
Q -- "yes" --> SV["GSAS-II server"]
Q -- "no, auto" --> IP
Q -- "no, server" --> F["ExecutionBackendError"]
IP --> R["routing.run_refinement
→ executors"]
SV --> R
```
| Mode | CLI | Behavior |
|---|---|---|
| `auto` | default | Uses the server, starting it if needed; runs in-process if it is unavailable |
| `server` | `--use-server` | Server only; fails if the server cannot be used |
| `subprocess` | `--no-server` | Skips the server; GSAS-II runs in the calling process, despite the name |
The server (`server.py`, with `client.py` as its client) keeps GSAS-II
imported in one process, so a batch of runs pays GSAS-II's import once. Its
security model:
| Aspect | What the code does |
|---|---|
| Owner | One server per user. A kernel-held lock in the state directory allows a single server. |
| Network | Binds the loopback address `127.0.0.1` only, on a free port or `POWDERLINE_SERVER_PORT`. |
| Authentication | A random token, made at each start, signs every request and every response with an HMAC over method, path, nonce, timestamp and body. The token never travels. A client never trusts a listener that cannot sign. |
| Unsigned requests | A request without well-formed signing headers is refused before its body is read. |
| Token storage, Linux and macOS | A private state directory, mode 0700. The server refuses one that another user owns or can reach, or that is a symlink. |
| Token storage, Windows | `%LOCALAPPDATA%\powderline`, protected by the user profile's ACL. PowderLine does not check that ACL. |
| Files | Each job runs in a private scratch directory. Output files travel back inside the signed response, so client and server need no shared filesystem. |
`POWDERLINE_NO_SERVER=1` turns the server off. Operating the server is
covered in [Run at a beamline](../how-to/beamline-operation.md).
```{warning}
The server caches the code it loaded. After editing anything it runs, stop
it with `pixi run -e gsasii gsas-server stop`; the next `auto` or `server` run
starts a fresh one in the background. `gsas-server restart` and `start` run
the server in the foreground of that terminal until you stop it. The client
compares the server's PowderLine version and source-tree hash with its own. A
stale server is skipped in `auto` mode and refused in `server` mode.
```
A server timeout never falls back to an in-process run. The job is still
running on the server, so a second run would double the work.
## The validation model
```mermaid
flowchart TD
R["recipe"] --> V{"each validator"}
V -- "breaks a rule, or the engine
would silently change it" --> E["error naming
the value to write"]
V -- "within tolerance of an
exact symmetry value" --> H["held exactly
info: symmetry_value_derived"]
V -- "left out, documented default" --> W["default applied
+ structured warning"]
V -- "as written" --> OK["validated model"]
H --> OK
W --> OK
E --> X["one ValidationError
listing every problem"]
```
- Every recipe check is a pydantic validator. An invalid recipe raises
**one** `pydantic.ValidationError` listing every problem at its field path.
There is no PowderLine wrapper exception for invalid recipes.
- Validation never changes what a recipe means. The validated model holds the
recipe's values, the documented defaults and the exact symmetry-derived
values. A value an engine would silently replace is an error, never an
adjustment.
- Structured warnings (`{code, message, field_path[, level]}`) are collected
once from the validated model. Re-validating the same recipe reproduces
them. Every code is registered in `exceptions.WARNING_CODES` and listed in
[Messages](../reference/messages.md).
- An engine schema builds on core but cannot redefine, skip or rewrite core's
rules. This is guarded when the class is defined, and
`tests/test_phase_canary.py` runs core's must-fail recipes through every
engine's phase model.
- A schema rule that exists because of an engine quirk cites its public
`EB-NN` ID in the error message. The entries are on the
[Engines](../engines/index.md) pages.
Why validation is this strict is explained in
[Validation](../concepts/validation.md).
## The frozen GSAS-II modules
Six modules in `src/powderline/gateways/gsasii/` hold the hand-written
functions that set up and run a GSAS-II refinement: `helpers.py`,
`project.py`, `setters.py`, `executors.py`, `extractors.py` and
`constraints.py`. They manipulate GSAS-II's project dictionary directly.
These conventions can't be fully pinned down by tests, so the modules are
frozen.
- A behavior-changing edit needs the maintainers' explicit consent.
- Comment and docstring edits need no consent.
- `src/powderline/_code_hash.json` holds a hash of each module's raw bytes,
and `tests/test_code_hash.py` fails when a module changes. After **any**
edit, run `pixi run update-code-hash` and commit the hash file with the
change.
The full rule is on [Policies](policies.md#frozen-gsas-ii-code). New
behavior goes into the modules around the frozen code:
| Module | What it adds |
|---|---|
| `sites.py` | Compares GSAS-II's site symmetry and multiplicity for every atom with core's. Stops with an error when GSAS-II misreads a site, or cannot name one while a coordinate or `Uaniso` is refined |
| `fit_report.py` | Computes the uniform statistics over the fit window. Reads GSAS-II's stored messages into warnings, its convergence flag and its count of varied parameters. Raises on a non-finite calculated pattern |
| `routing.py` | One entry point for in-process, server and client runs |
| `paths.py` | Maps GSAS-II parameter names to recipe paths for the output recipe |
| `console.py` | Keeps the tail of GSAS-II's console text for a failed run's envelope |
| `scattering.py` | GSAS-II's ion table and spelling |
| `gateway.py` | The version check, the execution modes and the result tables |
The documented gsasii defaults (sample broadening, background, cycles) are
schema defaults. The frozen setters always receive explicit values, so
GSAS-II's own defaults are never used. GSAS-II sets refine flags in lumps (one
for the cell, one for all three coordinates of an atom). `constraints.py`
sets the lumped flag and adds GSAS-II "Hold" constraints for the parts the
recipe does not refine. The schema has already refused any flag pattern
GSAS-II could not honor.
## Output generation
Every engine writes the same fixed file names through `powderline.reports`:
`refined_parameters.csv`, `_unit_cell_report.csv`,
`_peak_list_report.csv` and `fit_profile.txt`. A single-peak fit adds
a peak report. A refinement adds `output_recipe.json` and
`parameter_dispositions.csv`. The writers use one column order, UTF-8 and LF
line endings, so the files are byte-stable across platforms.
The engines' own files stay beside them. gsasii keeps `dummy.gpx` and
`dummy.lst`. topas keeps the INP, the `.xye` and every file TOPAS wrote.
`powderline.run()` removes only the names in `reports.FIXED_OUTPUT_NAMES` and
`reports.FIXED_OUTPUT_PATTERNS`, never an engine's files. The full list is on
[Outputs](../reference/outputs.md).
The output recipe is built in core by `roundtrip`. A gateway hands it the
refined values by recipe path, in recipe units, with their ESDs. It also
names the flagged paths the engine did not vary and those that ended at a
limit. Core does the rest: tie propagation, the multiplicity check,
`output_recipe`, `output_recipe_valid` and `parameter_dispositions`.
## Domain essentials
**Rietveld refinement** fits a calculated powder pattern to the observed one
by adjusting structural, profile and background parameters. PowderLine
computes the headline statistics once, in `fitstats`, the same way for every
engine:
$$R_{wp} = 100 \sqrt{\frac{\sum_i w_i (y_{obs,i} - y_{calc,i})^2}{\sum_i w_i y_{obs,i}^2}}, \qquad
R_{exp} = 100 \sqrt{\frac{N - P}{\sum_i w_i y_{obs,i}^2}}, \qquad
\mathrm{GoF} = \frac{R_{wp}}{R_{exp}}$$
The sums run over the points inside the fit window with $w_i > 0$. $N$ is
the number of those points. $P$ is the engine's own count of varied
parameters, read after the run. Reduced χ² is $\mathrm{GoF}^2$. The engine's
own values go under `engine_details`.
```{tip}
A lower Rwp means a better fit of *that model* to *those points*. Across
engines, compare lattice parameters rather than Rwp: the models differ.
```
**Parameter types.** Every refinable is a `[value, flag]` or
`[value, flag, min, max]` list.
- Structural: cell, coordinates, occupancies, ADPs. This is core's phase
block, identical in every engine.
- Intensity: the phase `scale`.
- Peak shape: sample size and strain (gsasii, topas) and the instrument
profile (every engine).
- Background: Chebyshev terms, and background peaks in gsasii and topas.
- Instrument: wavelength, zero shift, polarization, asymmetry, in the engine's
own names and units.
**Phase and histogram parameters.** Phase parameters belong to a crystal
structure: cell, atoms, scale and sample broadening. Histogram parameters
belong to the measurement: instrument profile and background. In a
multi-phase refinement the histogram parameters are shared.
Terms used here (recipe, gateway, tie group, special position) are defined in
the [Glossary](../reference/glossary.md). Contributor terms: a **golden** is a
committed engine output a test compares against; the **schema layer** and
**runtime layer** are the engine-free and engine-importing halves of a
gateway.