Python API
Signatures and docstrings of the public functions and classes in reflectolearn at docs/site (dddcc54), read from the source without importing it. The guides on this site say how the pieces fit together; this page is for looking up an argument.
reflectolearn.benchmark
Benchmark: the full predict pipeline on two sets of synthetic curves with known truth. "synthetic": random films made by an independent float64 Parratt recursion written here in numpy, not by the package's torch engine, so the benchmark does not grade the pipeline with its own forward model (the "inverse crime"). Each curve mimics a lab scan: 2θ 0.2–6° in 0.02° steps, a Gaussian beam footprint, Gaussian resolution with constant dq/q, a background and Poisson counts. The stack is a film on a thin SiO2 layer on Si, so the native oxide the pipeline treats as a nuisance is present. "synthetic_fixed": a few films with the arbitrary parameters in FIXED_CASES, simulated by the training forward model (AbelesMatrix, same stack layout as the generator) with the training augmentor's footprint, resolution smearing, background and Poisson noise at stated values. These curves come from the model's own simulator, so they check the pipeline in its training domain, not its transfer to independent physics.
parratt
python
def parratt(q: np.ndarray, layers: list[tuple[complex, float, float]]) -> np.ndarray|r|^2 for [(sld, thickness, roughness of its top interface)], ambient first, substrate last. SLD in 1e-6 Å^-2 with positive imaginary part for absorption; Névot–Croce roughness. The SLD is conjugated so that the principal square root gives Im k > 0 (decay into absorbers) in every layer.
smeared
python
def smeared(q: np.ndarray, layers, dq_over_q: float, n: int=17) -> np.ndarrayReflectivity convolved with a Gaussian of sigma = dq_over_q · q (Gauss–Hermite-like grid, ±3σ).
SyntheticCase
python
@dataclass
class SyntheticCase:
name: str
thickness: float
roughness: float
sld: float
isld: float
oxide_thickness: float
oxide_roughness: float
substrate_roughness: float
sample_length_mm: float
dq_over_q: float
i0: float
background: floatmake_synthetic
python
def make_synthetic(
n: int,
seed: int = 2026,
wavelength: float = 1.5406,
tth: np.ndarray | None = None,
) -> list[tuple[SyntheticCase, np.ndarray, np.ndarray]]n synthetic curves: list of (case, 2θ [deg], counts).
FixedCase
python
@dataclass
class FixedCase:
name: str
thickness: float
roughness: float
sld: float
oxide_thickness: float
oxide_sld: float
oxide_roughness: float
sub_sld: float
sub_roughness: float
beam_width: float
sample_length: float
res_sigma: float
bkg: float
poisson_scale: floatA film with stated parameters plus the instrument effects applied to its curve. Units as in the config: Å, 1e-6 Å^-2; beam_width and sample_length as in augmentation.beam_width_range / sample_len_range; res_sigma in Å^-1; bkg and poisson_scale as log10 values (augmentation.bkg / poisson_log_scale). oxide_thickness 0 means no oxide.
make_fixed
python
def make_fixed(
cfg,
seed: int = 2026,
cases=FIXED_CASES,
tth_range: tuple[float, float] = (0.2, 8.0),
) -> list[tuple[FixedCase, np.ndarray, np.ndarray]]Synthetic curves of `cases` from the training forward model: list of (case, q [Å^-1], intensity). Same media layout as data.generator.simulate_reflectivity, then the XRRAugmentor footprint and smearing, background and Poisson noise at the case's values, cropped to `tth_range` [deg]. The seed only drives the Poisson draw.
score
python
def score(kind: str, name: str, res: dict, truth: dict, seconds: float) -> dictOne benchmark row from a predict_curve result and the known truth.
summarize
python
def summarize(rows: list[dict]) -> dictPer-set error statistics of the rows from `score`.
run_benchmark
python
def run_benchmark(
cfg,
checkpoint,
*,
stats_path=None,
n_synthetic: int = 12,
seed: int = 2026,
alias_check: bool = False,
mcmc_steps: int = 0,
out_dir: str | Path = 'benchmark_out',
device=None,
) -> dictRun predict on both synthetic sets and score them; writes JSON and CSV.
reflectolearn.cli
ReflectoLearn CLI (typer): train / evaluate / config. Run via: uv run -m reflectolearn (or) reflectolearn (installed script)
config
python
@app.command()
def config(
out: Path = typer.Option('config.yaml', '--out', '-o', help='Output YAML path.'),
)Write a default configuration YAML.
train
python
@app.command()
def train(
config_path: Path | None = typer.Option(None, '--config', '-c', help='Config YAML.'),
save_dir: Path | None = typer.Option(None, '--save-dir', '-s', help="Output dir. Defaults to config's save_root/experiment_name."),
override: list[str] = typer.Option([], '--set', help='Override e.g. training.lr=1e-3'),
resume: Path | None = typer.Option(None, '--resume', help='Checkpoint to resume.'),
seed: int = typer.Option(0, '--seed', help='Random seed.'),
)Train the XRR model on streaming synthetic data.
tune
python
@app.command()
def tune(
config_path: Path | None = typer.Option(None, '--config', '-c', help='Base config YAML. Defaults to cwd/config.yaml.'),
n_trials: int = typer.Option(30, '--trials', '-n', help='Number of trials to search.'),
epochs: int = typer.Option(12, '--epochs', help='Proxy training epochs per trial (reduced).'),
steps: int = typer.Option(400, '--steps', help='Steps per epoch per trial (reduced).'),
val_steps: int = typer.Option(80, '--val-steps', help='Validation steps per trial (reduced).'),
seed: int = typer.Option(0, '--seed', help='Sampler/data seed (for fair comparison).'),
out: Path = typer.Option('config_best.yaml', '--out', '-o', help='Path to save the best config.'),
storage: str | None = typer.Option(None, '--storage', help="Optuna storage URL. Defaults to auto-created <out>_study.db SQLite (resumable). Use ':memory:' to run in-memory."),
study_name: str = typer.Option('reflectolearn', '--study-name', help='Optuna study name.'),
param: list[str] = typer.Option([], '--param', '-p', help='Search only these parameters (repeatable). Defaults to searching all; the rest are fixed at base-config values. e.g. -p model.fourier_features'),
override: list[str] = typer.Option([], '--set', help='Override fixed base values e.g. simulation.q_points=2000'),
)Hyperparameter search (Optuna): find the best config via reduced training and save it as YAML.
evaluate
python
@app.command()
def evaluate(
checkpoint: Path | None = typer.Argument(None, help="Checkpoint path. Defaults to config's save_dir/best.pt."),
config_path: Path | None = typer.Option(None, '--config', '-c', help='Config YAML. Defaults to cwd/config.yaml or config.yaml next to the checkpoint.'),
stats: Path | None = typer.Option(None, '--stats', help='stats.pt. Defaults to stats.pt next to the checkpoint.'),
n_samples: int = typer.Option(2000, '--n-samples', help='Number of synthetic samples for evaluation.'),
batch_size: int = typer.Option(200, '--batch-size', help='Inference batch size.'),
refine: bool = typer.Option(False, '--refine/--no-refine', help='Physics-refine AI predictions against the measured curve and compare before/after.'),
trust_margin: float = typer.Option(0.4, '--trust-margin', help='Trust-region half-width ratio about the AI estimate during refine.'),
)Full checkpoint evaluation: metrics (MAE/MAPE/RMSE/R2) + CSV + visualization report.
predict
python
@app.command()
def predict(
data: Path = typer.Argument(..., help='Measured curve file (2 columns: 2theta[deg] or q, intensity).'),
checkpoint: Path | None = typer.Option(None, '--checkpoint', '-k', help="Checkpoint. Defaults to config's save_dir/best.pt."),
config_path: Path | None = typer.Option(None, '--config', '-c', help='Config YAML. Defaults to cwd or config.yaml next to the checkpoint.'),
stats: Path | None = typer.Option(None, '--stats', help='stats.pt. Defaults to stats.pt next to the checkpoint.'),
x_axis: str = typer.Option('tth', '--x-axis', help="Input x-axis: 'tth' (2theta deg) or 'q' (Å⁻¹)."),
refine: bool = typer.Option(True, '--refine/--no-refine', help='Physics-refine AI predictions against the measured curve.'),
trust_margin: float = typer.Option(0.4, '--trust-margin', help='Refine trust-region half-width ratio.'),
alias_check: bool = typer.Option(False, '--alias-check/--no-alias-check', help='Experimental: also refine from d/2, 2d and the FFT thickness; pick by FOM plus FFT prior.'),
mcmc_steps: int = typer.Option(0, '--mcmc-steps', help="emcee steps for the posterior after refine (0 = off; needs the 'mcmc' extra)."),
out_dir: Path | None = typer.Option(None, '--out-dir', '-o', help='Output folder. Defaults to next to the input file.'),
)Infer one measured XRR curve, optionally refine the physics, and save the prediction + overlay plot.
benchmark
python
@app.command()
def benchmark(
checkpoint: Path = typer.Option(..., '--checkpoint', '-k', help='Checkpoint to benchmark.'),
config_path: Path | None = typer.Option(None, '--config', '-c', help='Config YAML. Defaults to config.yaml next to the checkpoint.'),
stats: Path | None = typer.Option(None, '--stats', help='stats.pt. Defaults to next to the checkpoint.'),
n_synthetic: int = typer.Option(12, '--n', help='Synthetic curves (independent numpy Parratt, known truth).'),
seed: int = typer.Option(2026, '--seed', help='Seed of the synthetic sets.'),
alias_check: bool = typer.Option(False, '--alias-check/--no-alias-check', help='Refine from d/2, d, 2d and the FFT thickness (experimental).'),
mcmc_steps: int = typer.Option(0, '--mcmc-steps', help='emcee steps per curve (0 = off).'),
out_dir: Path = typer.Option(Path('benchmark_out'), '--out-dir', '-o', help='Where benchmark.json/.csv go.'),
)Score predict on synthetic curves with known truth: random films and a few fixed example films.
stitch
python
@app.command()
def stitch(
scans: list[Path] = typer.Argument(..., help='Scan files (x, intensity, error) in increasing 2theta order.'),
out: Path = typer.Option(..., '--out', '-o', help='Output text file.'),
)Stitch attenuator-segmented scans into one curve (last scan is the intensity reference).
main
python
def main()reflectolearn.config
ReflectoLearn Configuration (Pydantic, Streaming + 1-Layer) =========================================================== Single-film 3-parameter (thickness/roughness/sld) prediction, for streaming Abeles data generation.
ParamRanges
python
class ParamRanges(BaseModel):
thickness: Range = [10.0, 1200.0]
roughness: Range = [0.0, 15.0]
sld: Range = [5.0, 100.0]Range [min, max] of the film parameters to predict.
SubstrateConfig
python
class SubstrateConfig(BaseModel):
sld: Range = [18.0, 22.0]
roughness: Range = [1.0, 6.0]Substrate: not predicted, used only in simulation. Set min==max to fix it.
SimulationConfig
python
class SimulationConfig(BaseModel):
wavelength: float = 1.54
q_min_tth: float = 0.05
q_max_tth: float = 15.0
q_points: int = 2000
n_samples: int = 50000ModelConfig
python
class ModelConfig(BaseModel):
use_fourier: bool = True
fourier_scale: float = 15.0
fourier_features: int = 32
n_channels: int = 64
depth: int = 6
mlp_hidden: int = 512
dropout: float = 0.11:1 correspondence with XRRPhysicsModel arguments.
TrainingConfig
python
class TrainingConfig(BaseModel):
batch_size: int = 64
lr: float = 0.0002
weight_decay: float = 0.0001
epochs: int = 150
steps_per_epoch: int = 3000
val_steps: int = 300
patience: int = 50AugmentationConfig
python
class AugmentationConfig(BaseModel):
prob: float = 0.9
i0: Range = [0.8, 1.2]
bkg: Range = [-8.0, -5.0]
poisson_log_scale: Range = [5.0, 8.0]
intensity_noise_scale: float = 0.25
res_sigma_range: Range = [0.0001, 0.006]
do_footprint: bool = True
beam_width_range: Range = [0.005, 0.015]
sample_len_range: Range = [5.0, 30.0]
tth_offset: Range = [-0.02, 0.02]
oxide_prob: float = 0.7
oxide_thickness: Range = [0.0, 40.0]
oxide_sld: Range = [5.0, 25.0]
oxide_roughness: Range = [0.0, 8.0]
kalpha2_ratio: float = 0.5
kalpha2_wavelength: float = 1.54439Batch torch augmentation parameters (read by XRRAugmentor).
Config
python
class Config(BaseModel):
experiment_name: str = 'exp'
save_root: Path = Path('runs')
device: torch.device = Field(default_factory=lambda: torch.device('cuda' if torch.cuda.is_available() else 'cpu'))
param_ranges: ParamRanges = Field(default_factory=ParamRanges)
substrate: SubstrateConfig = Field(default_factory=SubstrateConfig)
simulation: SimulationConfig = Field(default_factory=SimulationConfig)
model: ModelConfig = Field(default_factory=ModelConfig)
training: TrainingConfig = Field(default_factory=TrainingConfig)
augmentation: AugmentationConfig = Field(default_factory=AugmentationConfig)Config.save_dir
python
@property
def save_dir(self) -> PathPath for training artifacts = save_root / experiment_name.
Config.q_grid
python
@property
def q_grid(self) -> torch.Tensor
@q_grid.setter
def q_grid(self, value: torch.Tensor)Config.save_yaml
python
def save_yaml(self, path: str | Path)Config.load_yaml
python
@classmethod
def load_yaml(cls, path: str | Path) -> Selfreflectolearn.data.augmentations
Realistic XRR augmentations: instrument effects applied to linear R curves. Contract: forward(q, R) takes linear-scale reflectivity R of shape (B, Q) and the q grid (Q,), and returns linear-scale R of shape (B, Q). Randomness is drawn fresh here, since the streaming generator calls this once per batch.
XRRAugmentor
python
class XRRAugmentor:
def __init__(self, config: Config, q: torch.Tensor)Apply realistic instrument effects to reflectivity curves. All ops operate on linear-scale R of shape (B, Q) -> (B, Q). Effects: - footprint attenuation at low angles (erf geometric correction) - resolution (Gaussian q-smearing) - i0 : per-sample intensity scaling (R * i0) - bkg : additive background (+ 10**bkg) - Poisson counting noise - low/high angle masking (sets points to 0 -> masked downstream) Each effect is gated independently by `config.augmentation.prob`.
XRRAugmentor.forward
python
def forward(self, q: torch.Tensor, R: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]Apply instrument effects.
R: (B, Q) linear -> returns (R, mask):
R : (B, Q) linear, instrument effects applied (still > 0)
mask : (B, Q) in {0, 1}; 0 marks angles that were NOT measured
(low/high-angle cutoff). The generator uses this to fill the
masked region with the log-floor (-15) consistently, exactly
like the offline exp07 pipeline.reflectolearn.data.generator
On-the-fly streaming XRR data generator using the Abeles physics engine.
param_stats
python
def param_stats(config: Config) -> dict[str, torch.Tensor]Analytic mean/std for the uniform parameter ranges. For U(lo, hi): mean=(lo+hi)/2, std=(hi-lo)/sqrt(12). Returns tensors of shape (3,) ordered [thickness, roughness, sld].
simulate_reflectivity
python
def simulate_reflectivity(
config: Config,
q: Tensor,
thickness: Tensor,
roughness: Tensor,
sld: Tensor,
sub_sld: Tensor,
sub_rough: Tensor,
device,
) -> TensorMap film parameters to reflectivity R(q) with forward-model domain randomization. For robustness on real measurements, the following are applied per-sample at simulation time as nuisance effects (not prediction targets): * tth_offset : q-axis zero-point (alignment) error * oxide : native oxide / surface layer (nuisance layer between ambient and film) * Kalpha2 : Cu K-alpha2 doublet (weighted sum) Each effect is controlled by the range/probability in config.augmentation and is disabled when set to 0. All input film parameters are (B,) tensors; the return is (B, Q) linear R.
generate_synthetic_batch
python
def generate_synthetic_batch(
config: Config,
batch_size: int,
augmentor: XRRAugmentor | None = None,
device=None,
) -> tuple[torch.Tensor, torch.Tensor]Generate one batch of synthetic XRR curves and normalized targets.
Returns:
x: (B, 2, Q) model input [log10-normalized R, valid-q mask]
y: (B, 3) normalized targets [thickness, roughness, sld]XRRStreamingDataset
python
class XRRStreamingDataset(IterableDataset):
def __init__(self, config: Config, augmentor: XRRAugmentor | None=None, steps: int=200)Infinite streaming dataset that generates XRR batches on-the-fly.
get_xrr_dataloader
python
def get_xrr_dataloader(
config: Config,
augmentor: XRRAugmentor | None = None,
steps: int = 200,
) -> DataLoaderDataLoader over streaming XRR data (batching happens inside the generator).
reflectolearn.evaluate
ReflectoLearn Evaluator
=======================
Analyze the performance of a trained XRRPhysicsModel on a synthetic test set and
generate a report.
Ideas are borrowed from the PINN (XRefine) evaluator but simplified for this
project's structure:
- No ParamSet -> target is a normalized (B, 3) tensor [thickness, roughness, sld]
- Model output is a single (B, 3) (no tuple/fixed_values)
- Re-simulate with Abeles using the predicted parameters to assess curve quality
(best/worst)
Outputs (all saved in the checkpoint's parent directory):
- eval_metrics_full.csv per-sample true/pred/error
- eval_summary.csv per-parameter MAE/MAPE/RMSE/R2
- eval_1_best_worst.png best/worst curves + parameter table
- eval_2_learning_curves.png train/val loss, lr
- eval_3_error_vs_value.png error (bias) vs physical value scatter
- eval_4_parity.png true vs pred parity (R2)XRREvaluator
python
class XRREvaluator:
def __init__(
self,
checkpoint_path: str | Path,
config: Config,
stats_path: str | Path | None = None,
n_samples: int = 2000,
batch_size: int = 200,
device: torch.device | None = None,
refine_enabled: bool = False,
refine_config: RefineConfig | None = None,
)XRREvaluator.setup
python
def setup(self)XRREvaluator.run_inference
python
@torch.no_grad()
def run_inference(self)XRREvaluator.calculate_metrics
python
def calculate_metrics(self)XRREvaluator.plot_best_worst
python
def plot_best_worst(self, n: int=3)XRREvaluator.plot_learning_curves
python
def plot_learning_curves(self)XRREvaluator.plot_error_vs_value
python
def plot_error_vs_value(self)XRREvaluator.plot_parity
python
def plot_parity(self)XRREvaluator.report
python
def report(self)XRREvaluator.evaluate_all
python
def evaluate_all(self)reflectolearn.genx_fit
Classical GenX fit (differential evolution via bumps) seeded with a CNN estimate. Optional: needs the ``genx`` extra (``uv sync --extra genx``). Model: film + oxide layer on a fixed substrate, with I0, background and footprint (sample length) fitted in two stages (oxide + I0, then everything).
genx_fit
python
def genx_fit(
q: np.ndarray,
intensity: np.ndarray,
thickness: float,
roughness: float,
sld: float,
*,
wavelength: float = 1.54,
sub_sld: float = 20.07,
sub_rough: float = 3.0,
beam_width: float = 0.1,
sample_len: float = 10.0,
film_margin: float = 0.1,
pop_size: int = 40,
steps_scale: float = 1.0,
) -> dictFit a measured curve with GenX, starting from (thickness, roughness, sld). Film parameters are bounded to ±``film_margin`` of the guess. ``intensity`` is normalized to its max. Returns fitted parameters plus ``r_fit`` and ``fom`` (logR2).
reflectolearn.model
FourierFeatureMapping
python
class FourierFeatureMapping(nn.Module):
def __init__(self, input_dim=1, mapping_size=64, scale=10.0)Random Fourier Features. Maps the coordinate (q) into a high-dimensional frequency space to help the model learn high-frequency patterns.
FourierFeatureMapping.forward
python
def forward(self, x)XRRPhysicsModel
python
class XRRPhysicsModel(nn.Module):
def __init__(
self,
q_len: int,
input_channels: int = 2,
output_dim: int = 3,
n_channels: int = 64,
depth: int = 5,
mlp_hidden: int = 256,
dropout: float = 0.1,
use_fourier: bool = True,
fourier_scale: float = 10.0,
fourier_features: int = 32,
)XRRPhysicsModel.forward
python
def forward(self, x)reflectolearn.physics.abeles
Abeles matrix method. Physics engine for X-ray reflectivity calculation, optimized for batch processing and gradient descent.
abeles_core
python
def abeles_core(q: Tensor, thickness: Tensor, roughness: Tensor, sld: Tensor) -> TensorCore Abeles matrix calculation.
Length convention (important):
There are N media and N-1 interfaces.
thickness / roughness are per-interface, so they are 1 shorter than sld.
Args:
q: (B, Q, 1) complex - scattering vector (k_z = q/2)
sld: (B, 1, N) complex - relative SLD per medium (ambient=0 reference)
thickness: (B, 1, N-1) real - thickness of each finite layer/interface
roughness: (B, 1, N-1) real - roughness of each interface (Nevot-Croce)
Returns:
(B, Q) real - reflectivity R(q) = |M_10 / M_00|^2AbelesMatrix
python
class AbelesMatrix(nn.Module):
def __init__(self, device: torch.device | None=None)AbelesMatrix.forward
python
def forward(
self,
q: Tensor,
thickness: Tensor,
roughness: Tensor,
sld: Tensor,
sample_length: Tensor | None = None,
beam_width: Tensor | None = None,
wavelength: float = 1.5406,
) -> TensorPre/post-processing layer wrapping the physics kernel (abeles_core).
Responsibilities:
1) Input preparation: dtype casting, relative-SLD conversion, unit scaling, dimension alignment
2) Call abeles_core (pure physics calculation)
3) Footprint correction (optional)
As an nn.Module, its operations are recorded on the autograd graph and are differentiable.
Args:
q: (B, Q) real - scattering vector [1/Angstrom]
thickness: (B, N-1) - thickness of the upper medium at each interface [Angstrom];
the first entry is the ambient's and has no effect
roughness: (B, N-1) - rms roughness of each interface, top first [Angstrom]
sld: (B, N) - SLD per medium, ambient first [1e-6/Angstrom^2],
real or complex; computed in complex64
sample_length, beam_width: for footprint correction (optional)
wavelength: X-ray wavelength [Angstrom], defaults to Cu K-alpha
Returns:
(B, Q) real - reflectivityabeles
python
def abeles(*args, **kwargs)Functional interface wrapper.
reflectolearn.physics.utils
tth2q
python
def tth2q(tth: float, wavelen: float=1.54) -> float
def tth2q(tth: NDArray[np.float64], wavelen: float=1.54) -> NDArray[np.float64]Convert 2θ (in degrees) and wavelength (in Å) to scattering vector q (in 1/Å). Formula: q = (4 * pi / lambda) * sin(theta)
apply_tth_offset
python
def apply_tth_offset(q: Tensor, offset_deg: Tensor, wavelength: float) -> TensorApply a q-axis zero-point (alignment) offset: add offset[deg] to the measured 2θ, then convert back to q. Differentiable (torch). q: (Q,) or (B, Q); offset_deg: (B, 1) -> returns (B, Q).
reflectolearn.predict
Inference on real measured XRR curves plus physics refinement.
Training uses synthetic data only, but inference targets real measured files.
The model only works correctly if the measured input goes through exactly the
same preprocessing as the synthetic training input:
measured I(2theta) -> convert to q -> interpolate onto fixed q_grid
-> per-curve max normalize -> log10 -> clamp(<=0)
-> unmeasured region: mask=0 + log-floor
There are no ground-truth parameters, so evaluation centers on the refinement
FOM (LogMSE): how well the measured curve is reproduced when the AI prediction
is used as the initial guess for physics fitting. The substrate is unknown for
real measurements, so it is fixed to the midpoint of the config.substrate range.load_measured_curve
python
def load_measured_curve(
path: str | Path,
*,
x_axis: str,
wavelength: float,
) -> tuple[np.ndarray, np.ndarray]Read a 2-column text file (2theta or q, intensity) into (q[Å⁻¹], intensity). Comment/header lines (non-numeric) are skipped automatically. Both whitespace- and comma-separated formats are supported.
preprocess_measured
python
def preprocess_measured(
q_meas: np.ndarray,
intensity: np.ndarray,
cfg: Config,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]Convert a measured curve into model input (1, 2, Q).
Returns:
x : (1, 2, Q) [log10-normalized R, mask] — model input
r_norm : (1, Q) reflectivity-like normalized linear values (for refine's log_r_obs)
mask : (1, Q) {0,1} valid-q maskload_predictor
python
def load_predictor(
cfg: Config,
checkpoint: str | Path,
stats_path: str | Path | None,
device: torch.device,
) -> tuple[XRRPhysicsModel, torch.Tensor, torch.Tensor]Model in eval mode plus the target de-normalisation (mean, std).
predict_curve
python
def predict_curve(
q_meas: np.ndarray,
intensity: np.ndarray,
cfg: Config,
predictor: tuple[XRRPhysicsModel, torch.Tensor, torch.Tensor],
*,
refine_enabled: bool = True,
refine_config: RefineConfig | None = None,
alias_check: bool = False,
mcmc_steps: int = 0,
device: torch.device | None = None,
name: str = 'curve',
) -> tuple[dict, dict]AI prediction, refinement (from the AI thickness and, with alias_check, from half and double of it) and, with mcmc_steps > 0, posterior sampling for one measured curve. Returns (result, curves): result holds the parameters, FOMs and posterior; curves holds the q grid, observed, AI and refined curves and the mask, for plotting.
predict_file
python
def predict_file(
path: str | Path,
cfg: Config,
checkpoint: str | Path,
*,
stats_path: str | Path | None = None,
x_axis: str = 'tth',
refine_enabled: bool = True,
refine_config: RefineConfig | None = None,
alias_check: bool = False,
mcmc_steps: int = 0,
device: torch.device | None = None,
out_dir: str | Path | None = None,
) -> dictInfer a single measured file, optionally physics-refine, then save results/plots/summary.
reflectolearn.refine.losses
Curve-fitting loss functions for physics refinement.
All losses follow the same signature over the (B, Q) tensor convention:
r_sim: (B, Q) simulated reflectivity (linear, > 0)
log_r_obs: (B, Q) log10 of observed reflectivity
mask: (B, Q) valid-q mask (1 = valid, 0 = ignore; float or bool accepted)
Returns a scalar loss over the batch (per-sample masked mean -> batch mean).log_mse_loss
python
def log_mse_loss(r_sim: Tensor, log_r_obs: Tensor, mask: Tensor) -> TensorMSE in log10 space. Sensitive to dynamic-range (fringe) shape such as roughness/attenuation.
linear_mse_loss
python
def linear_mse_loss(r_sim: Tensor, log_r_obs: Tensor, mask: Tensor) -> TensorMSE in linear space. Sensitive to absolute intensity (scale) near the critical angle.
correlation_loss
python
def correlation_loss(r_sim: Tensor, log_r_obs: Tensor, mask: Tensor) -> Tensor1 - Pearson correlation. Invariant to scale/attenuation, so strong at matching fringe frequency (period).
log_gradient_loss
python
def log_gradient_loss(r_sim: Tensor, log_r_obs: Tensor, mask: Tensor) -> TensorValue MSE + gradient (Sobolev) MSE. Strongly penalizes solutions that flatten the curve (vanishing slope).
poisson_deviance_loss
python
def poisson_deviance_loss(counts_scale: float) -> LossFnPoisson deviance per point for counts = counts_scale · R (counts_scale: counts at R = 1). Unlike the log losses this is the likelihood of counting data: each point is weighted by its counts, and points with zero counts inside the mask contribute too. Built per curve set because it needs the count scale, so it is not in LOSS_PRESETS; a stage selects it with loss="poisson". It is the likelihood only if counts_scale · 10**log_r_obs are the raw per-point counts on the measured points. Values interpolated onto another grid (as `predict.preprocess_measured` does for the network) or averaged over equal q are not Poisson counts, and the deviance of such values is only an approximation. Pass refine the measured q grid and the raw counts instead.
hybrid_linear_log_loss
python
def hybrid_linear_log_loss(r_sim: Tensor, log_r_obs: Tensor, mask: Tensor) -> TensorMix of linear (intensity/critical angle) + log (fringe/dynamic range), linear:log = 20:1.
reflectolearn.refine.mcmc
Posterior sampling after refinement: emcee on the same forward model refine fits with. refine gives one best fit and no uncertainty. Sampling the posterior around it gives the spread of each parameter and exposes degeneracies (thickness against SLD, roughness against resolution). Likelihood: Gaussian in log10 R over the valid q points with an unknown spread exp(lnsigma) that is fitted with the rest. Without it the curve's own scatter has to be assumed, and when the model does not describe the data to within that scatter the posterior comes out absurdly narrow. The measured curve is interpolated onto the fixed q grid, so grid points are not independent; the log-likelihood is scaled to the number of measured points (``n_independent``), or the intervals would shrink by about sqrt(grid points / measured points). Priors are flat within the same bounds refine used (trust region for the film, global ranges for the nuisances). All walkers are evaluated in one batch on the refine device. The kept half of the chain is checked against its integrated autocorrelation time tau: estimates are usable when it spans at least 50 tau. The check is necessary, not sufficient: on a short chain tau itself is underestimated, so a flag set near the boundary can be optimistic; "tau_margin" (kept steps / max tau) shows how far from the boundary the chain is. With refine's twelve parameters free a 2000-step chain is often several times shorter than that (ReflectoLearn#29); the result reports tau and a warning is logged.
sample_posterior
python
def sample_posterior(
info: dict,
substrate: Tensor,
q_grid: Tensor,
log_r_obs: Tensor,
mask: Tensor,
*,
wavelength: float,
config: RefineConfig | None = None,
n_independent: int | None = None,
steps: int = 2000,
walkers_per_dim: int = 4,
seed: int = 0,
device: torch.device | None = None,
) -> dictSample the posterior of every parameter refine fitted, for one curve.
Args:
info: refine's (or refine_multistart's) info for a single curve (batch of 1).
n_independent: measured points inside the q grid; defaults to the number of valid grid points.
steps: emcee steps; the first half is discarded as burn-in.
Returns:
{"params": {key: {"median", "lo", "hi"}}, "lnsigma": {...}, "acceptance", "steps", "walkers",
"samples": (N, K) array of the kept samples, "keys": list, "log_prob_start", "log_prob_max",
"best": the highest-probability point visited, "tau": integrated autocorrelation time of
each key and of lnsigma over the kept half, "tau_margin": kept steps / max(tau),
"converged": tau_margin >= 50}.
lo/hi are the 16th/84th percentiles (68 % interval).reflectolearn.refine.pipeline
Gradient-based physics refinement of AI parameter predictions.
Refines CNN predictions (initial guess) by directly fitting a physics-simulated
curve to the measured curve. The ``AbelesMatrix`` forward model is fully
differentiable via autograd, so the refinement runs as **Adam gradient descent**.
Core elements:
* Multi-stage strategy: scale/alignment -> film structure -> fine tuning
* Per-stage loss choice (linear / correlation / log, see
:mod:`reflectolearn.refine.losses`)
* Trust-region bounds that narrow the search around the AI estimate
* Valid-q masking + FOM (LogMSE) to track improvement
To fit the measured curve accurately, **nuisance parameters** (values that affect
the curve shape but are not of interest) are fitted simultaneously. This prevents
forward-model mismatch from leaking into the parameters of interest (thickness etc.):
* i0 : intensity scale, bkg : background (log10)
* tth_offset : q-axis zero (alignment) offset
* dq : resolution (Gaussian q-smearing) — disentangles from roughness
* dq_rel : q-proportional resolution term, sigma(q) = sqrt(dq² + (dq_rel·q)²); off by default
* footprint : low-angle beam footprint attenuation (beam > sample) —
disentangles from low-angle amplitude
* oxide : native oxide / surface layer (nuisance layer between ambient and film)
Bound constraints map a latent variable ``z`` through a sigmoid onto [lo, hi],
keeping parameters within their physical range throughout gradient descent.RefineStage
python
@dataclass
class RefineStage:
name: str
optimize: tuple[str, ...]
loss: str = 'log_mse'
steps: int = 200
lr: float = 0.03
q_min: float | None = None
q_max: float | None = NoneOne refinement stage: which parameters to optimize, with which loss, for how many steps.
likelihood_stages
python
def likelihood_stages(budget: int=10) -> list[RefineStage]Stages that fit counting data to its Poisson maximum likelihood (ReflectoLearn#25).
Changes to the default strategy, each found necessary in the R2b cause separation (removing any
one of them stops refine short of the optimum on curves from its own forward model):
* every stage runs `budget` times as many steps;
* no correlation stage: its loss ignores the level of the curve and accepts other fringe orders;
* the fine stage minimizes the Poisson deviance instead of the log10 MSE, whose optimum is not
the maximum-likelihood point;
* the fine stage is followed by two stages over the same keys at lr 1e-3 and 1e-4, so that
Adam settles instead of jittering around the optimum.
About 30 times the default number of steps.RefineConfig
python
@dataclass
class RefineConfig:
trust_margin: float = 0.4
fit_i0: bool = True
fit_bkg: bool = True
fit_tth_offset: bool = True
tth_offset_last_stage: bool = False
fit_dq: bool = True
fit_dq_rel: bool = False
fit_kalpha2: bool = True
fit_footprint: bool = True
fit_oxide: bool = True
nuisance_reg: float = 0.2
i0_bounds: tuple[float, float] = (0.5, 2.0)
bkg_bounds: tuple[float, float] = (-10.0, -4.0)
tth_offset_bounds: tuple[float, float] = (-0.08, 0.08)
tth_offset_bound_tol: float = 0.01
offset_off_margin: float = 0.05
dq_bounds: tuple[float, float] = (0.0, 0.012)
dq_rel_bounds: tuple[float, float] = (0.0, 0.05)
kalpha2_ratio_bounds: tuple[float, float] = (0.0, 0.6)
kalpha2_wavelength: float = 1.54439
footprint_bounds: tuple[float, float] = (150.0, 8000.0)
oxide_thickness_bounds: tuple[float, float] = (0.0, 60.0)
oxide_sld_bounds: tuple[float, float] = (15.0, 22.0)
oxide_roughness_bounds: tuple[float, float] = (0.0, 12.0)
bkg_init: float = -7.0
dq_init: float = 0.002
dq_rel_init: float = 0.01
kalpha2_ratio_init: float = 0.5
footprint_init: float = 2000.0
oxide_thickness_init: float = 5.0
oxide_sld_init: float = 18.0
oxide_roughness_init: float = 3.0
counts_scale: float | None = None
stages: list[RefineStage] = field(default_factory=_default_stages)RefineConfig.likelihood
python
@classmethod
def likelihood(cls, counts_scale: float, **overrides) -> RefineConfigThe configuration that reaches the Poisson maximum-likelihood fit (ReflectoLearn#25): likelihood_stages() with the count scale of the data. Other fields keep their defaults unless overridden. Use it on raw counts on the measured q points (refine's q_grid = the measured q, log_r_obs = log10(counts / counts_scale)), not on a curve interpolated onto the model grid: see `poisson_deviance_loss`.
make_forward
python
def make_forward(
q_grid: Tensor,
substrate: Tensor,
*,
wavelength: float,
config: RefineConfig,
device: torch.device,
) -> Callable[[dict[str, Tensor], set[str]], Tensor]Build the forward model refine fits with: film (+oxide) on a fixed substrate, plus the active nuisance effects. The returned function takes a dict of (W,) parameter tensors for any batch size W (refine's batch, or every MCMC walker at once) and returns (W, Q) reflectivity.
refine
python
def refine(
p_init: Tensor,
substrate: Tensor,
q_grid: Tensor,
log_r_obs: Tensor,
mask: Tensor,
param_ranges,
*,
wavelength: float,
config: RefineConfig | None = None,
device: torch.device | None = None,
verbose: bool = False,
) -> tuple[Tensor, dict]Refine AI-estimated film parameters to the measured curve via gradient descent
(with simultaneous nuisance fitting).
Returns:
p_refined: (B, 3) refined [thickness, roughness, sld]
info: {"fom_before", "fom_after" (scalar LogMSE), fitted nuisances (B,)}
fom_before is measured with film parameters fixed at AI-init and only
i0/bkg (global scale/offset) aligned, so the gap to fom_after reflects
film-parameter improvement rather than scale matching.per_curve_log_mse
python
def per_curve_log_mse(r: Tensor, log_r_obs: Tensor, mask: Tensor) -> Tensor(B,) log10 MSE over each curve's valid points (the refine FOM, per curve).
fft_thickness
python
def fft_thickness(
q_grid: Tensor,
log_r_obs: Tensor,
mask: Tensor,
d_range: tuple[float, float],
sld: Tensor | None = None,
d_min: float = 60.0,
) -> Tensor(B,) film thickness from the Kiessig period, independent of the network: the strongest peak of the real-space transform of the detrended log R (the Patterson-type peak at z = d). Fringes are periodic in the film's own wavevector sqrt(q^2 - q_c^2), not in q; transforming over q overestimates d by several percent near the edge. With ``sld`` (B,) [1e-6/Å^2] the transform runs over the film wavevector, q_c = 4 sqrt(pi sld 1e-6); a rough SLD is enough. Fresnel decay is removed with a quintic fit over the valid region above the critical edge, the rest is Hann-windowed and transformed; the estimate is the highest local maximum with z in [max(d_min, lo), hi]. d_min keeps out the native oxide and the leakage tail at small z. NaN where there are fewer than 40 valid points or no such maximum (films thinner than d_min).
refine_multistart
python
def refine_multistart(
p_init: Tensor,
substrate: Tensor,
q_grid: Tensor,
log_r_obs: Tensor,
mask: Tensor,
param_ranges,
*,
wavelength: float,
config: RefineConfig | None = None,
device: torch.device | None = None,
verbose: bool = False,
thickness_factors: tuple[float, ...] = (0.5, 1.0, 2.0),
fom_tolerance: float = 0.25,
fft_weight: float = 1.5,
) -> tuple[Tensor, dict]refine from several thickness starts and keep the one the fringe period supports. A single refine cannot correct a guess off by about a factor of two: the trust region keeps thickness within ±trust_margin of its start. So refine also runs from multiples of the network's thickness and from the FFT estimate of the Kiessig period (fft_thickness), all in one batch. The lowest log-MSE alone is not a safe choice: with the oxide, resolution and Kα2 nuisances free, a film two or four times as thick often fits as well or better. The FFT estimate alone is not safe either: now and then it locks onto a wrong peak. So each start is scored by ln FOM + fft_weight · |ln(d / d_fft)|, a soft prior on the fringe period, and the lowest wins. On the benchmark the right start wins for weights between about 0.9 and 2.5. Without an estimate, the network's own start wins when its FOM is within ``fom_tolerance`` of the best, else the best FOM. info["alias"] lists every start's label, refined thickness and FOM, the FFT estimate and the choice.
reflectolearn.report.figures
Figures for the data slides.
Slide 2 ("Domain Randomization"): a 3x3 panel grid that takes one clean simulated
XRR curve and overlays each randomized effect (oxide layer, 2theta offset, Cu Kalpha2
doublet, footprint, resolution, intensity scale, background, Poisson noise) plus a
final "all combined" realistic curve.
All effects reuse the production code paths (AbelesMatrix, apply_tth_offset,
XRRAugmentor) so the figure stays faithful to what the generator actually does.
Everything is deterministic (fixed film params + fixed seed) for reproducible slides.FilmCase
python
@dataclass
class FilmCase:
thickness: float = 150.0
roughness: float = 5.0
sld: float = 40.0
sub_sld: float = 20.0
sub_rough: float = 3.0
oxide_thickness: float = 30.0
oxide_sld: float = 18.0
oxide_roughness: float = 3.0Representative single film on a substrate (units match config).
domain_randomization_figure
python
def domain_randomization_figure(
cfg: Config | None = None,
film: FilmCase | None = None,
out: str | Path = 'domain_randomization.png',
*,
seed: int = 0,
) -> PathRender the slide-2 domain-randomization panel grid and save it as PNG.
sim_to_real_figure
python
def sim_to_real_figure(
cfg: Config | None = None,
film: FilmCase | None = None,
out: str | Path = 'sim_to_real.png',
*,
seed: int = 0,
) -> PathSingle-axes figure: clean simulation -> + systematic distortions -> + stochastic noise. Visualizes the sim-to-real gap as two stacked layers: deterministic (systematic) distortions reshape the curve, and Poisson (stochastic) noise adds randomness.
model_architecture_figure
python
def model_architecture_figure(
cfg: Config | None = None,
out: str | Path = 'model_architecture.png',
) -> PathLeft-to-right block diagram of XRRPhysicsModel (curve -> film parameters). Stages and shapes are derived from the config so the diagram stays accurate: Input (2,Q) -> Fourier features -> 7x Conv block -> Global Avg Pool -> MLP -> 3 params.
write_synthetic_curve
python
def write_synthetic_curve(cfg: Config, path: str | Path, case: str='fixed_mid') -> PathWrite the benchmark's synthetic `case` as a 2-column (q, intensity) text file.
refine_figure
python
def refine_figure(
cfg: Config | None = None,
data_path: str | Path | None = DEFAULT_REFINE_DATA,
out: str | Path = 'refine.png',
*,
checkpoint: str | Path | None = None,
x_axis: str = 'tth',
) -> PathPhysics-refinement figure: run the full inference + refine pipeline on one curve and produce the data / AI-predict / refined overlay (with FOM). Without `data_path` the curve is the benchmark's synthetic "fixed_mid" film, written to synthetic_fixed_mid.txt next to `out`. Mirrors the CLI 'predict' resolution: checkpoint defaults to config's save_dir/best.pt, and config/stats next to that checkpoint are auto-discovered. The plot is rendered by predict_file; this wrapper just resolves paths and copies the PNG to `out`.
refine_process_figure
python
def refine_process_figure(out: str | Path='refine_process.png') -> PathBlock diagram of the refinement gradient-descent loop. AI init -> [Abeles forward -> LogMSE vs measured -> backprop -> Adam update] -> theta*. Schematic only (no computation), for the 'refinement principle' slide.
reflectolearn.stitch
Stitch XRR scans measured in consecutive 2θ ranges with different attenuators. The last scan is the intensity reference. Going backwards, each earlier scan is scaled so its last point equals the first point of the following scan, and that duplicate boundary point is dropped. Adjacent scans must share one x value.
stitch_scans
python
def stitch_scans(scans: list[np.ndarray]) -> np.ndarrayCombine scans of shape (N, 2+) [x, intensity, (error, ...)] in increasing-x order. Every column after x is rescaled together.
read_scan
python
def read_scan(path: str | Path) -> np.ndarrayRead a whitespace-separated scan file (x, intensity, error), no header.
reflectolearn.train
Trainer
python
class Trainer:
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
save_dir: str | Path,
lr: float = 0.0002,
weight_decay: float = 0.0001,
patience: int = 20,
verbose: bool = True,
)Trainer.train
python
def train(
self,
epochs: int,
resume_from: str | Path | None = None,
on_epoch_end: Callable[[int, float, float], None] | None = None,
)Training loop. on_epoch_end(epoch, train_loss, val_loss): hook called after validation on every epoch. During hyperparameter search it can report the intermediate val_loss and raise an exception to prune an unpromising trial.
reflectolearn.tune
Hyperparameter search (Optuna) for the XRR inverse-problem model. Search targets (all independent of `q_len`/physical ranges, so they are safe to vary freely because the encoder folds the length with ``AdaptiveAvgPool1d(1)``): * training : lr, weight_decay, batch_size * model : n_channels, depth, mlp_hidden, dropout, fourier_scale, fourier_features The objective is the **normalized validation loss** (the MSE used by Trainer). Target normalization is analytic and not a search target, so trials are directly comparable. Each trial runs a reduced "proxy" training over fewer epochs/steps and returns the best val_loss; ``MedianPruner`` watches the val_loss reported each epoch and prunes unpromising trials early. Data generation uses the same seed per trial to reduce noise. Checkpoints are written to a per-trial temporary directory and deleted on exit, so they do not clutter the disk. Finally the best hyperparameters are applied to the base config and saved as YAML, and the full trial history is saved as CSV — just run ``reflectolearn train`` with the saved YAML.
suggest_overrides
python
def suggest_overrides(
trial: optuna.Trial,
cfg: Config,
only: list[str] | None = None,
) -> NoneApply the search space directly to cfg (in-place). Key names match dotted paths.
only: list of dotted-keys to search. None searches everything. If given, only
those keys are searched and the rest stay fixed at the base config values
(e.g. ["model.fourier_features"]).objective
python
def objective(
trial: optuna.Trial,
base_cfg: Config,
*,
epochs: int,
steps: int,
val_steps: int,
seed: int,
search_params: list[str] | None = None,
) -> floatOne trial: run reduced training and return the best validation loss; TrialPruned if unpromising.
run_search
python
def run_search(
base_cfg: Config,
*,
n_trials: int = 30,
epochs: int = 12,
steps: int = 400,
val_steps: int = 80,
seed: int = 0,
out: str | Path = 'config_best.yaml',
storage: str | None = None,
study_name: str = 'reflectolearn',
search_params: list[str] | None = None,
) -> optuna.StudyRun the Optuna search and save the best config (YAML) + trial history (CSV). When storage is unset, results are written to ``<out>_study.db`` SQLite, which supports interrupt/resume by default (re-running with the same study_name picks up existing trials and continues searching). To run in-memory, pass ``storage=":memory:"`` explicitly.