This guide provides comprehensive context for developers working on PowderLine (and for AI coding assistants used alongside them). Read this first to understand the project architecture, design decisions, and development workflows.
What is PowderLine? PowderLine automates crystallographic refinement from a declarative JSON "recipe" and drives interchangeable refinement engines — GSAS-II by default, with Bruker TOPAS v7 and open-source easydiffraction as optional alternatives. It takes a recipe describing refinement parameters and produces standardized output reports. The goal is to make powder diffraction refinements reproducible, automatable, and accessible.
Scope:
- Command-line tool that runs refinements from JSON recipes, dispatching to interchangeable engines (GSAS-II by default; TOPAS v7 and easydiffraction optional)
- Schema 0.26.0: Simplified two-workflow system (Rietveld refinement or single peak fitting)
- Schema validation using Pydantic
- Single-phase and multi-phase Rietveld refinements; phase-less single peak fitting
- Standard output reports (fit profiles, unit cell parameters with ESDs, peak lists,
refined_parameters.csv) - Programmatic Python API:
powderline.run()with DataFrame returns - Example-driven design: schema evolves based on real refinement cases
Design Philosophy:
- Example-driven: Don't build features speculatively - add them when real examples need them
- Schema-first: Validate inputs before any GSAS-II operations
- Fail fast: Detect errors early with helpful messages
- Reproducible: Same JSON should produce identical refinement results
Current schema: 0.26.0 (payload-based structure with fixed output filenames)
All recipes use a three-key structure:
{
"schema_name": "GSASII_Rietveld", // or "GSASII_SPF"
"schema_version": "0.26.0",
"payload": {
// All refinement parameters here
}
}Key field: schema_name - Selects the workflow executor:
"GSASII_Rietveld": Full Rietveld structural refinement"GSASII_SPF": Single peak fitting (peak-based analysis without crystal structure)
The payload contains all refinement parameters organized by concern:
"payload": {
"xrd_data": {...}, // Required: Diffraction pattern
"instrument": {...}, // Required: Beamline/instrument config
"phases": {...}, // Required for Rietveld, null for SPF
"background": {...}, // Optional: Background model (defaults to empty if omitted)
"refinement_controls": {...}, // Optional: Cycles, convergence
"fit_range": [min, max], // Optional: 2θ range limits
"single_peaks": {...}, // Required for SPF, optional otherwise
"single_peak_fitting_mode": {...} // For SPF workflows
}Required fields vary by schema_name:
- Rietveld (
GSASII_Rietveld): Must havephasesdefined - SPF (
GSASII_SPF): Must havesingle_peaksandsingle_peak_fitting_mode
PowderLine uses fixed output filenames independent of sample identity:
dummy.gpx: GSAS-II project file (reopenable in GUI)dummy.lst: Human-readable refinement log (Rwp, parameter tables)<phase>_unit_cell_report.csv: Refined unit cell (one per phase)<phase>_peak_list_report.csv: Reflection list (one per phase)fit_profile.txt: Observed/calculated/difference intensities
Rationale: Fixed filenames eliminate variability in automation/testing. Sample identity should be managed externally (directory names, databases, metadata systems).
File: refined_parameters.csv (9 columns)
PowderLine exports all refined parameters with estimated standard deviations (ESDs) after each refinement. This provides a structured, machine-readable record of refinement results with uncertainty quantification.
Column structure:
parameter_name: GSAS-II internal parameter identifier (e.g.,::0:Scale,::A0,0::A0)descriptive_name: Human-readable description (e.g., "Scale factor for phase LaB6", "Background coefficient A0")phase_name,phase_idx: Phase associations for phase-specific parameters (null otherwise)atom_name,atom_idx: Atom associations for atom-specific parameters (null otherwise)value: Refined parameter valueesd: Estimated standard deviation from covariance matrixcategory: Parameter classification (instrument, background, cell, atom_position, atom_displacement, HAP, peak_broadening)
Implementation: export_refined_parameters_csv() in kicker.py
ESD calculation:
- Primary source: GSAS-II covariance matrix (
proj.data['Covariance']) - Fallback: A
.lst-parsing fallback exists inkicker.py(extract_refined_params_from_lst) but is currently disabled at its call site. It is preserved pending a test that confirms covariance matrix ESDs match what.lstparsing previously produced. Until that test exists, the covariance matrix is the sole ESD source.- Note: The
.lstfallback was incomplete — it only covered instrument, background, and unit cell parameters, not atom or HAP parameters.
- Note: The
- Unit cell ESDs: Uses
G2lat.getCellEsd()to convert reciprocal metric tensor ESDs to direct lattice parameters- Function:
calculate_cell_esds_from_A_matrix()in kicker.py - GSAS-II stores cell as reciprocal metric tensor (A-matrix); ESDs require conversion
- Function:
Parameter name parsing:
parse_parameter_associations()in kicker.py: Extracts phase/atom indices from GSAS-II parameter namesget_descriptive_param_name()in kicker.py: Generates human-readable descriptionsbuild_phase_name_mapping()in kicker.py: Maps phase indices to namesbuild_atom_name_mapping()in kicker.py: Maps atom indices to labels
Phase-specific unit cell reports:
In addition to the comprehensive refined_parameters.csv, each phase generates <phase>_unit_cell_report.csv with 3 columns:
parameter: a, b, c, alpha, beta, gamma, volumevalue: Refined unit cell parameteresd: Estimated standard deviation
Note on simulations: Simulation mode (all refine_flag=false) does not generate refined_parameters.csv since no parameters are refined. Unit cell reports still generated with esd=0.0.
See SCHEMA_HISTORY.md for historical context.
IMPORTANT — Schema Version Discipline:
The GSAS-II server caches kicker.py at startup. If kicker.py changes (even without recipe format changes), a running server will use stale code. The schema_version in recipes is validated against EXPECTED_SCHEMA_VERSION in schema.py, so bumping the version forces a server restart. You MUST bump EXPECTED_SCHEMA_VERSION whenever kicker.py output behavior changes (new output files, format changes, column additions, etc.), even if the recipe input format is unchanged. Update all input.json files to match.
What is Rietveld refinement? A method for refining crystal structures against powder diffraction data by fitting a calculated pattern to the observed pattern. The refinement adjusts parameters (unit cell, atom positions, peak shapes, background) to minimize the difference between calculated and observed intensities.
Key Quality Metric: Rwp (weighted residual)
Lower Rwp indicates better fit. Typical values: 5-15% for good refinements.
Peak Profiles: GSAS-II uses Thompson-Cox-Hastings pseudo-Voigt profiles - a convolution of Gaussian and Lorentzian functions. Profile shape controlled by:
- Instrument broadening (U, V, W parameters) - due to instrumental effects
- Sample broadening (size, strain) - due to crystallite size and microstrain
PowderLine organizes refinement parameters into logical groups:
-
Structural Parameters (Phase-specific):
- Unit cell: a, b, c, α, β, γ define crystal lattice dimensions
- Atom parameters: positions (x, y, z), occupancy, thermal parameters
- Space group: symmetry operations (defined in CIF, not refined)
-
Intensity Parameters (Phase-specific):
- Scale factor: Converts calculated intensities to absolute scale
- Preferred orientation: Non-random crystallite orientations (not implemented yet)
-
Peak Shape Parameters (Phase-specific):
- Sample broadening: Crystallite size, microstrain
-
Background Parameters:
- Chebyshev polynomials: Smooth curved background
- Single peaks: Individual pseudo-Voigt peaks for known impurities
The background field is optional in the payload. If omitted (background: null):
- No Chebyshev coefficients are initialized
- No background single peaks are added
- GSAS-II uses an empty background (effectively zero)
This is useful when background has been pre-subtracted from XRD data.
- Instrument Parameters:
- Instrument broadening: U, V, W (Gaussian), X, Y, Z (Lorentzian)
- Profile asymmetry: Axial divergence, sample transparency
- Wavelength: X-ray or neutron wavelength
- Zero shift: 2θ offset correction
- Polarization: For synchrotron data
Throughout the JSON schema and code, you'll see parameters represented as 4-element lists:
"wavelength": [0.45236, false, null, null]- value: Current parameter value
- refine_flag:
true= refine this parameter,false= hold fixed - min/max: Parameter bounds (placeholders for future - GSAS-II may not support bounds)
This mirrors GSAS-II's internal parameter representation.
Critical distinction (confusing in GSAS-II):
- Phase parameters: Belong to the crystal structure (scale, unit cell, atoms, space group, sample broadening)
- Histogram parameters: Belong to the measurement (instrument broadening, background)
In multi-phase refinements, each phase has its own structural parameters, but histogram parameters are shared across phases (except scale factors, which are phase-histogram pairs).
Refine Flags String Format: GSAS-II uses a single string at atom record index 2 to encode which parameters are refined:
- 'F': Refine occupancy (fraction)
- 'X': Refine fractional coordinates (x, y, z) - a single lumped flag for all three
- 'U': Refine displacement parameters (isotropic or anisotropic)
- Combinations: 'FXU' (all), 'XU' (coordinates + displacement), 'F' (occupancy only), etc.
Per-Parameter Semantics (Schema 0.26): Although GSAS-II exposes only the lumped 'X' flag (and one 'U' flag for all six anisotropic components), PowderLine honors the recipe's per-parameter flags: a coordinate or Uij component refines iff it is present with refine_flag=true; absent or false means fixed. This uses the same lumped-flag + "Hold"-constraint mechanism as unit-cell parameters — see Unit Cell Refinement Semantics (Schema 0.26) and src/powderline/constraints.py. For atoms, only site-symmetry-linked parameters remain coupled: e.g. x and y on an (x,x,z) site form one degree-of-freedom group that refines if either is requested; flags on symmetry-fixed parameters have no effect.
Displacement Parameter Types (ADP field):
Each atom has an atom-specific ADP field indicating which displacement type to use:
- Structure:
phases[phase]['structure']['atoms'][label]['ADP']indicates which type is used - Parameterization:
phases[phase]['parameterization']['atoms'][label]['ADP']indicates which type to refine
Values:
- 'Uiso': Isotropic displacement (single scalar U value)
- 'Uaniso': Anisotropic displacement (6 values: U11, U22, U33, U12, U13, U23)
The parameterization ADP controls atom_record[9] ('I' or 'A') in GSAS-II.
Dictionary Structure Examples:
Structure (embedded in payload):
"phases": {
"LaB6": {
"structure": {
"atoms": {
"La": {
"element": "La",
"x": 0.0, "y": 0.0, "z": 0.0,
"occupancy": 1.0,
"ADP": "Uiso",
"Uiso": 0.00858,
"Uaniso": null
}
}
}
}
}Parameterization (refinement settings):
"phases": {
"LaB6": {
"parameterization": {
"atoms": {
"La": {
"x": [null, true, null, null],
"y": [null, false, null, null],
"z": [null, false, null, null],
"occupancy": [null, true, null, null],
"ADP": "Uiso",
"Uiso": [null, true, null, null],
"Uaniso": null
}
}
}
}
}Anisotropic Displacement Parameters:
If using ADP="Uaniso", all 6 anisotropic U values (U11, U22, U33, U12, U13, U23) must be provided in both structure and parameterization:
"atoms": {
"Li": {
"ADP": "Uaniso",
"Uiso": 0.01, // Can provide both - ADP dictates which is used
"Uaniso": {
"U11": 0.012, "U22": 0.011, "U33": 0.010,
"U12": 0.001, "U13": 0.001, "U23": 0.001
}
}
}Mixed ADP Types: It is common (and allowed) for one atom to have ADP="Uaniso" while another has ADP="Uiso" in the same phase.
- CIF: Crystallographic Information File - standard format for crystal structures (In PowderLine, structural information is provided as embedded dictionaries, not CIF files)
- GPX: GSAS-II project file (binary format)
- LST: GSAS-II refinement log file with Rwp and parameter tables
- CHI: Two-column ASCII file (2θ, intensity) for powder diffraction data (Note: PowderLine uses embedded data arrays, not files. This format is for reference only)
- INSTPRM: GSAS-II instrument parameter file (Note: PowderLine uses embedded instrument parameters, not files. This format is for reference only)
- Recipe: PowderLine's JSON input describing a refinement workflow
- Multiplicity: Number of symmetry-equivalent reflections
- d-spacing: Interplanar spacing for an hkl reflection
- 2θ (two-theta): Diffraction angle in degrees
- hkl: Miller indices describing a crystallographic plane
PowderLine manipulates the proj.data dictionary directly rather than using GSAS-II's phase/histogram objects. This decision was made because:
- Phase object ambiguity: Unclear how G2Phase initialization maps to parameter setting
- Explicit control: Direct dictionary access makes parameter locations clear
- Less abstraction: Easier to debug when you can see exact data structure
- Trade-off: More brittle if GSAS-II internal structure changes (acceptable risk for Phase 1)
GSAS-II stores all project data in a nested dictionary. Key access patterns:
# Instrument parameters (list format: [current_value, value, refine_flag]). See kicker.py for setting functions
curr_wavelength = proj.data[hist.name]['Instrument Parameters'][0]['Lam'][1] # before setting, can get value from initialization (e.g., when xrd_data is added)
proj.data[hist.name]['Instrument Parameters'][0]['Lam'][1] = wavelength
proj.data[hist.name]['Instrument Parameters'][0]['Lam'][2] = refine_flag
# Background parameters
proj.data[hist.name]['Background'] = [
[chebyshev, no_peaks, background_type], # Background metadata
{'nDebye': 0, 'debyeTerms': [], ...}, # Debye-Scherrer params
{'nPeaks': n, 'peaksList': [...]}, # Single peaks
]
# Histogram-atom parameters (HAP)
# Change phase intensity scale given a value and refine_flag from payload for a phase's parameterization:
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Scale'][0] = value
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Scale'][1] = refine_flag
# Change isotropic size broadening for a given phase from the payload for a phase's parameterization:
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][0] = model # assume model is "isotropic" in this example
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][1][0] = value # set isotropic crystallite size value
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][2][0] = refine_flag # set isotropic size value refinement flag (if not None)
# Change isotropic mustrain broadening for a given phase from the payload for a phase's parameterization:
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][0] = model # assume model is "isotropic" in this example
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][1][0] = value # set isotropic mustrain value
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Mustrain'][2][0] = refine_flag # set isotropic mustrain value refinement flag (if not None)
# Phase parameters
proj.data['Phases'][phase_name]['General']['Cell'] = [refine_cell, a, b, c, alpha, beta, gamma, volume]These inconsistencies exist in GSAS-II (as of 2025) and are documented in kicker.py comments:
-
Size/Strain Documentation Conflict:
- GSAS-II API does not document structure of dicts / lists well
- PowderLine uses correct extended format
-
Unit Cell Refinement:
- GSAS-II natively exposes a single refine flag for all 6 unit cell parameters; since schema 0.26 PowderLine honors per-parameter flags instead.
- See Unit Cell Refinement Semantics (Schema 0.26) for the full mechanism and symmetry-coupling rules.
-
Reflection List Headers:
- GSAS-II provides 15-column reflection arrays without headers
- PowderLine adds headers manually: h, k, l, multiplicity, d_spacing, 2theta, etc.
Schema Enhancement: Size and strain broadening support model branching with schema-level validation.
PowderLine supports three size broadening models matching GSAS-II's capabilities:
| Model | Parameters | GSAS-II Structure | Status |
|---|---|---|---|
| isotropic | isotropic_size, LG_eta |
Size[0] = 'isotropic'Size[1][0] = sizeSize[1][2] = LG_eta |
✅ Implemented |
| uniaxial | uniaxial_equatorial, uniaxial_axial, hkl_direction, LG_eta |
Size[0] = 'uniaxial'Size[1][0:2] = eq/ax sizesSize[3] = [h,k,l] |
|
| ellipsoidal | S11, S22, S33, S12, S13, S23, LG_eta |
Size[0] = 'ellipsoidal'Size[4][0:6] = tensor params |
Usage (isotropic only):
"size_broadening": {
"model": "isotropic",
"isotropic_size": [100.0, true, null, null],
"LG_eta": [0.5, false, null, null]
}Future models raise NotImplementedError at schema validation:
# This will fail during recipe validation:
"size_broadening": {
"model": "uniaxial", # Error: Not yet implemented
...
}Strain broadening supports three models with increasing complexity:
| Model | Parameters | GSAS-II Structure | Status |
|---|---|---|---|
| isotropic | isotropic_strain, LG_eta |
Mustrain[0] = 'isotropic'Mustrain[1][0] = strainMustrain[1][2] = LG_eta |
✅ Implemented |
| uniaxial | uniaxial_equatorial, uniaxial_axial, hkl_direction, LG_eta |
Mustrain[0] = 'uniaxial'Mustrain[1][0:2] = eq/ax strainsMustrain[3] = [h,k,l] |
|
| generalized (Stephens) | Symmetry-dependent parameters | Complex Laue class-specific | (Phase 2) |
Usage (isotropic only):
"strain_broadening": {
"model": "isotropic",
"isotropic_strain": [0.001, true, null, null],
"LG_eta": [0.8, false, null, null]
}Note on Stephens Model: The generalized strain model uses Stephens formalism where the number and meaning of parameters depend on the crystal's Laue class (cubic, hexagonal, etc.). Implementation deferred to Phase 2 due to complexity.
Model Branching (kicker.py):
def set_phase_size_broadening(proj, hist, phase_name, size_dict, print_info=False):
"""Set size broadening with model-specific branching."""
model = size_dict.get('model', 'isotropic') # Default for backward compat
# Set model type in GSAS-II
proj.data['Phases'][phase_name]['Histograms'][hist.name]['Size'][0] = model
# Branch on model
if model == 'isotropic':
_set_isotropic_size_broadening(...) # Current implementation
elif model in ['uniaxial', 'ellipsoidal']:
raise NotImplementedError(f"{model} size broadening not yet supported")Schema Validation (schema.py):
class SizeBroadening(BaseModel):
model: Literal["isotropic", "uniaxial", "ellipsoidal"] = "isotropic"
# ... parameters for all models ...
@model_validator(mode='after')
def validate_model_implementation(self) -> Self:
if self.model != 'isotropic':
raise NotImplementedError(
f"{self.model} size broadening not yet implemented. "
"Use 'isotropic' model instead."
)
return selfDesign Rationale:
- Schema documents complete API (all models) even if not implemented
- Clear error messages guide users to supported features
- Foundation for Phase 2 implementation without breaking changes
- Validation happens early (recipe parsing), not during refinement
src/powderline/kicker.py (the refinement engine):
- CLI entry point via argparse
- Recipe loading, template detection, validation
- GSAS-II project initialization
- All parameter setting functions (background, phases, instrument, unit cell, etc.)
- Refinement execution
- Output generation (CSV reports, fit profiles)
- Role: Complete workflow orchestration + implementation
src/powderline/schema.py:
- Pydantic models matching JSON recipe structure
- Nested validation: RecipeModel → InstrumentModel, PhaseModel, BackgroundModel
- Custom validators for coefficient counts, list lengths, fit range
- Role: Input validation and type safety
src/powderline/gsas_server.py / gsas_client.py:
- Persistent FastAPI server keeping GSAS-II loaded in memory, and the HTTP client that talks to it (with in-process subprocess fallback)
- Role: Execution backends for
run()'sserver/automodes
src/powderline/topas/ + src/powderline/easydiff/ + src/powderline/engine.py:
- Alternate translation engines — TOPAS v7 (recipe →
.inp/.xye,tc.exerunner, results round-trip; standalonetopas-kickerCLI) and easydiffraction (recipe → easydiffractionProject, lmfit fit) — plus the engine dispatcher behindpowderline.run(recipe, output_dir, engine="gsasii"|"topas"|"easydiffraction"). Both alternate paths import zero GSAS-II; thegsasiibranch is a transparent pass-through tokicker.run. - Role: Alternate refinement engines + engine dispatch
tests/:
test_example_LaB6_regression.py: End-to-end regression test with exact output matchingconftest.py: Pytest fixtures for paths and test data- Role: Quality assurance - detect refinement result changes
Monolithic Design Rationale:
Phase 1 keeps all implementation in kicker.py intentionally:
- Simplifies development during schema evolution
- Easy to understand complete workflow in one file
- Refactoring to modules planned for Phase 2 when API stabilizes
- Comments saying "upstream" or "will be handled elsewhere" refer to future refactoring
PowderLine's engine seam makes new backends cheap to add. The dispatcher
(src/powderline/engine.py) is a small _ENGINES tuple plus one lazy-import
branch per engine; every engine returns the same result-dict shape, locked
by tests/test_api.py. The TOPAS (src/powderline/topas/) and easydiffraction
(src/powderline/easydiff/) subpackages are the two reference implementations.
The established pattern:
- New subpackage
src/powderline/<engine>/holding all engine logic: translation (builder/writer), execution (runner), result parsing, unit conversions, and arun_<engine>_recipe(recipe, output_dir, ...)adapter that returns the locked result dict. - One lazy-import branch in
powderline/engine.py(add the name to_ENGINES). Import the engine module only inside that branch so machines without the engine's dependencies can still use the others. - No schema changes — engines translate the existing
GSASII_*recipes; they do not define their own recipe format. - Reject unsupported features loudly — a recipe feature the engine cannot represent raises a translation error (or, if it's a fixed unmappable value, is dropped with a recorded warning). Never silently ignore a refine flag.
- Optional pixi feature/environment if the engine needs dependencies the
default environment shouldn't carry (e.g. easydiffraction needs Python ≥3.12,
so it lives in the optional
easydiffenvironment). - Keep it GSAS-II-free if it can be — the alternate engines import zero GSAS-II, a property enforced by subprocess import-block tests.
Simulation-mode semantics across engines. The GSAS-II engine treats
refinement_cycles == 1 as simulation and rejects any refine flag set in that
mode (validate_simulation_mode_parameters in kicker.py). The alternate
engines (TOPAS, easydiffraction) instead treat "no parameters flagged for
refinement" as simulation and do not enforce the cycles == 1 lock. For
well-formed recipes the behavior matches; the two definitions only diverge on
inconsistent input (cycles == 1 with a stray refine flag), which GSAS-II
refuses and the alternate engines would run. Uniform input-contract enforcement
across all engines is a tracked follow-up (see the PowderLine-devkit
easydiffraction dossier — a private maintainers' repo, access on request).
Nearly all Pydantic models use ConfigDict(extra='allow') to permit fields not explicitly defined (the one exception is RefinementParameterModel, which uses extra='forbid'):
class RecipeModel(BaseModel):
model_config = ConfigDict(extra='allow')Rationale:
- Schema evolution: Example-driven design means new fields emerge as examples are added
- Backwards compatibility: Old recipes continue working when new fields are added
- Flexibility: Users can add custom metadata without validation errors
- Trade-off: Typos in field names won't be caught (acceptable for Phase 1 iteration speed)
Future phases may tighten validation to extra='forbid' once schema stabilizes.
You'll see several TODO markers like # TODO: validation will be handled upstream throughout kicker.py. This means:
- Currently: Validation happens in helper functions (e.g., check coefficient count in
set_chebyshev_background) - Future: Validation moves to Pydantic schema (separation of concerns)
- Not implemented yet: Still evolving what validation logic belongs where
The pattern reflects Phase 1's iterative approach - implement in helper functions first, refactor to schema later.
PowderLine uses file-less JSON payloads. All data (XRD patterns, structures, instrument parameters) is embedded directly in JSON. File handling (loading XRD data from .chi files, parsing CIF structures, reading instrument parameters) occurs upstream of PowderLine.
Embedded Data Format:
- XRD data: "xrd_data" contains "tth", "Itth", "Itth_weights" arrays
- Structure: "structure" contains "space_group", "unit_cell", "atoms" dictionaries
- Instrument: "instrument" contains "initialization" and optional "parameterization" dictionaries
This design enables PowderLine to focus on refinement logic while delegating file I/O to upstream tools.
In PowderLine, phase names come from the phase_name field in the embedded structure dictionary:
# Phase name is explicitly specified in structure
"phases": {
"LaB6": { # This becomes the phase identifier
"structure": {
"phase_name": "LaB6", # Must match the key above
"space_group": "P m -3 m",
...
}
}
}The phase name is used consistently throughout:
- Phase dictionary keys
- Structure
phase_namefield (must match key) - Parameterization references
- Output file naming
This ensures consistent phase identification throughout refinement and output generation.
powderline.run() dispatches through three layers:
powderline.run() / CLI
│ validates recipe, normalises DataFrames
▼
GSASClient.submit_simulation()
│ routes: HTTP server ──or── in-process fallback
▼
run_refinement()
│ single execution engine, returns JSON-serializable primitives
▼
GSAS-II (GSASIIscriptable)
_submit_via_subprocess is in-process, not an OS subprocess. Despite its name,
this method calls run_refinement() directly in the current interpreter. The name
has been retained for API stability; if crash-isolation is needed in a future phase
it can be upgraded to multiprocessing without changing callers.
execution_mode vs method — two distinct concepts:
powderline.run()acceptsexecution_mode=('auto'|'server'|'subprocess') as its public input parameter.run_refinement()acceptsmethod=as its internal parameter (values:'server'|'subprocess'|'test').- Both
run_refinement()andGSASClientincludemethodas a result dict output key (result['method']) recording which execution path was used. - These are not a renamed pair — they serve different roles at different layers of the stack.
The FastAPI server (gsas_server.py) keeps GSAS-II imported in one persistent
process, amortising the ~2.6 s import cost across all requests. Requests are handled
synchronously (one-at-a-time) by design — concurrency is not needed for the current
single-user CLI and programmatic-API use cases.
After modifying any Python source file (kicker.py, schema.py, or any other
module under src/), restart the server so the running process loads the new code:
pixi run gsas-server restartThe server loads kicker.py at startup and caches it. Changes are invisible to the running server until it is restarted.
The Materials Project integration (mp_interface.py / mp_simulate.py /
simulation_builder.py) targets the current mp-api client (pinned
>=0.46,<1 in pixi.toml) and recipe schema 0.26.0.
Updated for mp-api 0.46.x (previously written against older client behavior):
- Single summary fetch —
get_structure()usesclient.materials.summary.search(material_ids=[...], fields=[...])(one request for structure + symmetry + energetics) instead of the legacyget_structure_by_material_id()convenience method. - Conventional-cell standardization — MP's computed structure is often a
primitive cell; it is now standardized via
SpacegroupAnalyzer(...).get_conventional_standard_structure()so the emitted space-group symbol matches the emitted cell. (Previously a centered-lattice primitive cell was paired with the conventional H-M symbol — wrong phase for anything non-primitive; LaB6 only worked because its primitive and conventional cells coincide.) The symbol is derived from the standardized structure itself (spglib condensed style with screw-axis underscores stripped, e.g.Pm-3m,I41/amd), whichkicker.pynormalizes viaG2spc.StandardizeSpcName. MPIDstringification — search results now returnmaterial_idasstr; previously rawMPIDobjects crashedmaterial_id.startswith('mp-')on the--formulapath.- Polymorph selection —
search_by_formula()returns results sorted byenergy_above_hull(None last); the CLI lists all candidates and auto-selects the most stable one, printing the choice. - Partial occupancy support — sites are extracted per species
(
site.species.items()), carrying real occupancies into the recipe (AtomStructure.occupancy); disordered structures no longer crash onsite.specie.get_structure()reportsis_ordered. - Error taxonomy —
MPInterfaceErrorbase withMPAuthError,MPNotFoundError,MPConnectionError; the CLI prints targeted hints instead of a generic wrapped exception. - Metadata fixes —
crystal_systemnow holds the actual crystal system (previously the space-group number, now underspace_group_number);nelementsnow holds the element count (previously atom count, now undernum_atoms);energy_above_hullandtheoreticaladded. - Key handling — API key resolution is config file first, then the
MP_API_KEYenvironment variable (ConfigLoader.get_mp_api_key());MPInterfaceis a context manager that closes the HTTP session. - Server output-visibility guard — a GSAS-II server can be reachable
over localhost yet have a divergent filesystem view (another node; a
sandbox with a private
/tmp), reporting success while its output files are invisible to the client.GSASClientverifiesfit_profile.txtwas freshly written client-side after a "successful" server run (stat-compare before/after — a stale file from a previous run into the same output dir does not count) and falls back to in-process execution (or returns a structured error when fallback is disabled).mp-simulate --no-serverskips the server entirely, and its.chiexport is built from the in-bandfit_profileresult data rather than re-reading the file. Tests:tests/test_gsas_client_visibility.py,tests/test_mp_simulate_cli.py. KI-12 tracks the follow-up that removes the shared-filesystem assumption altogether (server returns output artifacts in-band). - Strict config validation —
SimulationRecipeBuildervalidates thesimulation_defaultsblock against its allowed shape: unknown keys at any level (typos, the retired flat layout) and invalid values (null from an empty YAML key, strings where numbers belong) raise aValueErrornaming the offending key path; the CLI exits with that message. These previously passedRecipeModelvalidation (the instrument block isAny-typed passthrough) and failed opaquely inside GSAS-II.
Historical (schema 0.21 → 0.26 fixes, retained by the regression suite):
payload wrapper, schema_name, isotropic_size/isotropic_strain renames,
recipe= keyword for GSASClient.submit_simulation().
Regression guard: tests/test_mp_interface.py (mocked MPRester — real
pymatgen Structures, no live API) and tests/test_mp_pipeline.py run as part
of the standard test suite (pixi run test):
pixi run pytest tests/test_mp_interface.py tests/test_mp_pipeline.py -vLimitation: mp_simulate.py requires a live Materials Project API key to
fetch structures. The test suite uses mocks and a synthetic LaB6 fixture
(mp_lab6_structure_data in conftest.py) so no API key is needed for testing.
Currently run_refinement() always writes output files to output_dir. Two
options are under consideration for making file output optional:
Option A — save_files flag (default False)
result = powderline.run(recipe, output_dir=None, save_files=False)
# Returns DataFrames; no files written- Simplest API change; easy to add without breaking existing calls
- Requires
output_dirto be optional throughout the stack
Option B — optional output_dir (default None)
result = powderline.run(recipe) # no files
result = powderline.run(recipe, output_dir=Path("/tmp/out")) # files + DataFrames- Slightly more discoverable ("no
output_dir= no files") - Requires
output_dir=Noneguard at every write site inrun_refinement()
Both options are backwards-compatible additions. This will be evaluated alongside the Phase 2 module refactoring work.
PowderLine uses pixi for reproducible conda environments:
# Install dependencies (first time)
pixi install
# Activate environment
pixi shell
# Run refinement
pixi run kicker examples/example_LaB6/input.json
# Run tests
pixi run test
# Validate recipe only (no refinement)
pixi run kicker --validate-only examples/example_LaB6/input.jsonPYTHONPATH Configuration:
The kicker task in pixi.toml sets PYTHONPATH=$PWD/src:$PYTHONPATH to enable from powderline.schema import RecipeModel imports.
PowderLine supports three execution modes for running refinements:
pixi run kicker input.json- Behavior: Tries server → auto-starts if needed → falls back to subprocess
- Performance: 0.1-0.3s per simulation (if server available), 1-2s (if fallback)
- Use case: Normal development workflow - "just works"
- Output: Shows "🚀 server mode" or "🐢 subprocess mode" at end
pixi run kicker input.json --use-server- Behavior: Uses server only, fails if unavailable, no fallback
- Performance: 0.1s per simulation (10x+ faster than subprocess)
- Use case: Batch processing, CI/CD where performance matters
- Auto-start: Attempts to start server automatically if not running
- Error handling: Exits with clear error if server can't be started
pixi run kicker input.json --no-server- Behavior: Direct in-process execution using loaded GSAS-II libraries, skip server entirely
- Performance: 1-2s per simulation (slower; GSAS-II imported on first use)
- Use case: Debugging, HPC/Slurm batch jobs, environments where the server is not suitable
- Output: Returns the same full structured result (DataFrames) as server mode
Start server manually:
pixi run gsas-server start # Starts in background
pixi run gsas-server status # Check if running
pixi run gsas-server stop # Shutdown serverConfigure server port (if default port 19471 is in use):
# Set custom port before starting server
export POWDERLINE_SERVER_PORT=19472
pixi run gsas-server start
# Client will automatically detect the port from the powderline_gsas_server.port
# file in the platform temp directory (e.g. /tmp on Linux, %TEMP% on Windows)
pixi run kicker input.json # No additional configuration neededView server logs (powderline_gsas_server.log in the platform temp directory):
tail -f /tmp/powderline_gsas_server.log # Linux; on Windows: %TEMP%\powderline_gsas_server.logServer benefits:
- GSAS-II libraries loaded once (2.6s startup amortized across requests)
- Direct function calls using pre-loaded libraries (no subprocess overhead)
- Persistent process (no repeated Python interpreter startup)
- Ideal for batch simulations (10+ recipes)
- Connection retry logic (3 attempts with exponential backoff) handles transient issues
Server caching behavior:
The server keeps Python modules loaded in memory for performance. This means:
- ✅ Production benefit: 4-6x speedup (first run ~12s, subsequent ~2-3s)
⚠️ Development caveat: Code changes require server restart to take effect
During active development:
# After making code changes to kicker.py or other modules:
pixi run gsas-server restart # gracefully restart with updated code
# Next run will use the freshly started server with current code
pixi run kicker input.jsonFor deterministic integration tests:
# Restart the server first so tests run against current code
pixi run gsas-server restart
pixi run pytest tests/test_schema.py -qSee TROUBLESHOOTING.md ("Stale Server (Old Code Running)") for details on stale server issues.
When to use each mode:
- Auto-detect: Default for interactive use
- --use-server: Batch processing, automation, performance-critical
- --no-server: Debugging kicker.py, isolated testing, server issues
| Mode | LaB6 Simulation | DRX_33 Multi-phase | Typical Use |
|---|---|---|---|
| Server (--use-server) | 0.1s | 0.3s | Batch jobs |
| Auto-detect (default) | 0.1-2s | 0.3-3s | Interactive |
| Subprocess (--no-server) | 1.1s | 3s | Debugging |
Speedup: Server mode is 10.67x faster than subprocess for LaB6 simulations.
Times measured on NSLS-II workstation post-fix
Follow this pattern (using size broadening as example):
-
Add to schema (
schema.py):class PeakBroadening(BaseModel): size_broadening: dict[str, RefinementParameter | None] | None = None
-
Create setter function (
kicker.py):def set_phase_size_broadening(proj, hist, phase_name, size_dict, print_info=False): """Set crystallite size broadening for phase-histogram pair.""" # Extract from size_dict size_val = size_dict['size'] # Access proj.data dictionary hap = proj.data[hist.name]['Histograms'][phase_name] hap['Size'] = [size_val[0], size_val[1], 'isotropic', [0, 0], 1.0, size_val[1]]
-
Integrate into parameterization (in main execution):
if phase_param.peak_broadening and phase_param.peak_broadening.size_broadening: set_phase_size_broadening(proj, hist, phase_name, phase_param.peak_broadening.size_broadening)
-
Add example (create directory under
examples/):input.jsonwith new parameterDESCRIPTION.mdexplaining use case- Run refinement and commit output files
-
Add test (if example becomes reference):
def test_example_with_size_broadening(tmp_output_dir): # Run refinement, compare outputs
Examples drive schema evolution. To contribute:
- Create directory:
examples/example_N/ - Add required files:
input.json- complete recipeDESCRIPTION.md- what refinement demonstrates, data source, expected results
- Run refinement:
pixi run kicker examples/example_N/input.json - Commit outputs:
output/directory with .gpx, .lst, .csv files - Update schema if new fields needed
- Add regression test if example becomes quality reference
See examples/CONTRIBUTING_EXAMPLES.md for detailed guidelines.
# All tests
pixi run test
# Specific test file
pixi run pytest tests/test_example_LaB6_regression.py
# With verbose output
pixi run pytest -v
# See stdout/stderr
pixi run pytest -sRegression test strategy:
- Exact matching of Rwp, unit cell parameters, peak counts
- Detects any changes in GSAS-II refinement algorithm
- Reference outputs committed to git for comparison
- If test fails: inspect .lst file to determine if change is expected or bug
Note on test speed: All tests currently run as a single suite (pixi run test). Introducing @pytest.mark.slow / @pytest.mark.integration markers to allow skipping GSAS-II-dependent tests in CI is planned.
PowderLine prevents users from accidentally running template files with three-level detection:
- Path check: Filename/directory contains "template"
- Required fields check: Missing
payload.xrd_data,payload.instrument, orpayload.phases - Placeholder text check: Fields contain
"XXXX"or"TODO"or"REPLACE"
If detected, shows error with helpful message directing to real examples.
After refinement, PowderLine generates (fixed filenames):
dummy.gpx: GSAS-II project file (can reopen in GUI)dummy.bak0.gpx: Backup before refinement starteddummy.lst: Human-readable refinement log with Rwp, parameter tables, correlationsfit_profile.txt: 2θ, observed, calculated, background, difference intensities<phase>_unit_cell_report.csv: Refined unit cell parameters per phase<phase>_peak_list_report.csv: Reflection list (hkl, d-spacing, 2θ, intensities) per phase
For single peak fitting (SPF) mode:
single_peaks_report.txt: Peak fit results (position, width, intensity per peak)peak_convergence_diagnostics.txt: Convergence warnings for aphysical parameters
Note: Output filenames are now fixed (dummy.*) regardless of sample name. This simplifies automation and testing by eliminating filename variability.
GSAS-II may print warnings about highly correlated parameters (e.g., background coefficients):
** Warning: parameters 0 and 1 are 99.8% correlated **
This is normal for over-parameterized models. Common causes:
- Too many Chebyshev background terms
- Refining both instrument and sample broadening simultaneously
- Limited 2θ range
Not necessarily a problem if Rwp is good and parameters are physically reasonable.
GSAS-II natively refines all unit cell parameters together with a single lumped flag, but since schema 0.26 PowderLine honors per-parameter flags: a cell parameter refines iff it is present with refine_flag=true; absent or false means fixed. Internally, PowderLine sets the lumped GSAS-II flag when any parameter is requested and emits "Hold" constraints for the unrefined degrees of freedom (src/powderline/constraints.py):
"unit_cell": {
"a": [11.777, true, null, null], // refines
"c": [3.986, false, null, null] // held fixed (e.g. a tetragonal phase)
}Only symmetry-linked parameters refine together: parameters coupled by the phase's Laue class form one degree-of-freedom group with logical OR within the group. For example, cubic a=b=c is a single group (any true flag refines all three), and the oblique monoclinic parameters a/c/beta form one coupled group (see KI-02 in docs/known_issues.md). Flags on symmetry-fixed parameters (e.g. cubic angles) have no effect. PowderLine does not validate flag/symmetry consistency — producing symmetry-consistent flags is the recipe author's responsibility.
Indexing starts from 0th order (constant term):
"coefficients": [
[100.0, true, null, null], // 0th order (constant)
[-50.0, true, null, null], // 1st order (linear)
[10.0, true, null, null] // 2nd order (quadratic)
]Number of coefficients must match num_coefficients field (validated in schema).
Each single peak is a pseudo-Voigt with 4 parameters:
"background": {
"single_peaks": {
"positions": [[35.5, false, null, null]], // 2θ position
"intensities": [[50.0, true, null, null]], // Peak height
"pv_gaussian_sigma": [[0.1, false, null, null]], // Gaussian width (σ)
"pv_lorentzian_gamma": [[0.05, false, null, null]] // Lorentzian width (γ, HWHM)
}
}All lists must have same length (validated in schema). Useful for known impurity peaks that are part of the background.
Note: pv_mixing_eta parameter not currently implemented in schema.
Earlier schemas introduced dedicated single peak fitting capabilities via the top-level single_peaks field (distinct from background.single_peaks), maintained in the current schema.
This enables refining individual peaks with direct control over position, intensity, and pseudo-Voigt width parameters—useful for:
- Materials without crystal structure (amorphous, liquids, polymers)
- Unknown phases requiring peak characterization before structure solution
- Detailed peak shape analysis independent of structure
- Le Bail fitting (fit peak intensities without structural model)
Key Features:
- Independent width refinement: With
use_instrument_profile: false, peak widths (σ, γ) refine directly rather than being derived from instrument parameters (U, V, W, X, Y, Z) - Coexistence: Both
background.single_peaksandsingle_peakscan be used in same recipe—they populate different GSAS-II structures - Extended output: Generates
single_peaks_report.txtwith refined parameters plus calculated FWHM and integral breadths
JSON Structure:
// use_instrument_profile lives in refinement_controls.single_peak_fitting_mode
// (false = direct width refinement), not inside single_peaks:
"single_peaks": {
"positions": [[21.3, true, null, null], [...]], // 2θ positions
"intensities": [[100.0, true, null, null], [...]], // Peak intensities
"pv_gaussian_sigma_sq": [[0.15, true, null, null], [...]], // Gaussian width variances (σ²)
"pv_lorentzian_gamma": [[0.08, true, null, null], [...]] // Lorentzian widths (γ, HWHM)
}Implementation Details:
- GSAS-II Location: Peaks stored in
proj.data[hist.name]['Peak List']['peaks'](not Background) - Format: Each peak is
[pos, pos_flag, intensity, int_flag, σ², σ²_flag, γ, γ_flag]- Note: GSAS-II uses variance (σ²) internally, not σ
- Instrument mode: Set via
G2pwd.setPeakInstPrmMode(use_instrument_profile)False(default): Peak widths refined independentlyTrue: Widths constrained by instrument parameters (U, V, W, X, Y, Z)
Output File: single_peaks_report.txt
Tab-separated file with 10 columns per peak:
position_2theta intensity sigma gamma fwhm_gaussian fwhm_lorentzian fwhm_pseudovoigt integral_breadth_gaussian integral_breadth_lorentzian integral_breadth_pseudovoigt
Calculated Values:
- FWHM values:
- Gaussian: FWHM_G = 2√(2ln2) × σ ≈ 2.355σ
- Lorentzian: FWHM_L = 2γ
- Pseudo-Voigt: Thompson et al. (1987) approximation
- Integral Breadths: Area under peak / peak height
- Gaussian: IB_G = FWHM_G × √(π/(4ln2)) ≈ 1.065 × FWHM_G
- Lorentzian: IB_L = π × γ = (π/2) × FWHM_L
- Pseudo-Voigt: Weighted combination using mixing parameter η
Verification: FWHM values are cross-checked against GSAS-II's G2pwd.getgamFW(γ, σ) with warning if >1% difference.
Example: See examples/example_LaB6_singlepeakfit/ for complete demonstration.
Schema Classes:
SinglePeaksBackground: For background-associated peaks (background.single_peaks)SinglePeaks: For Peak List fitting (single_peaksat top level) - includesuse_instrument_profile
PowderLine fails fast with actionable error messages:
- File loading errors: Check paths relative to JSON, file permissions
- Template detection: Redirect to real examples
- Validation errors: Show which field failed Pydantic validation
- Histogram creation errors: Check XRD data format, instrument file compatibility
- Refinement errors: Check parameter ranges, correlation warnings in .lst
- Output errors: Check directory permissions, disk space
All errors exit with code 1 for automation compatibility.
Phase 2 (Next): Recipe generation API
- Python functions to build JSON programmatically
- Template system for common refinement types
- Possible FastAPI wrapper for web service
Phase 3: Output parsing
- Structured extraction of all refined parameters to JSON/CSV/HDF5
- Quality metrics beyond Rwp (χ², GOF, R-Bragg)
Phase 4: Dash GUI for recipe building
- Visual parameter selection
- Live preview of refinement setup
Phase 5: Dash GUI for results visualization
- Interactive plots of fit profiles
- Parameter evolution across refinement cycles
- Comparison between refinements
Key principles for future work:
- Add features when real examples need them (not speculatively)
- Schema evolves based on examples
- Maintain backwards compatibility when possible
- Document GSAS-II quirks as you discover them
- Add regression tests for new examples
Questions? Open an issue or discussion on GitHub.