style!: replace British with American spelling
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / lint (pull_request) Failing after 37s
CI / test (3.11) (pull_request) Skipped
CI / test (3.12) (pull_request) Skipped
CI / test (3.13) (pull_request) Skipped
CI / lint (push) Skipped
CI / test (3.11) (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / lint (pull_request) Failing after 37s
CI / test (3.11) (pull_request) Skipped
CI / test (3.12) (pull_request) Skipped
CI / test (3.13) (pull_request) Skipped
- especially centre->center
This commit is contained in:
@@ -19,10 +19,10 @@ from numpy.typing import ArrayLike, NDArray
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
def fit_beam_centre_model(
|
||||
def fit_beam_center_model(
|
||||
det_z_mm: ArrayLike, det_y_mm: ArrayLike, beam_x_px: ArrayLike, beam_y_px: ArrayLike
|
||||
) -> BeamCenterFromDetectorStage:
|
||||
"""Fit the model to measured stage positions and beam centre pixel coordinates."""
|
||||
"""Fit the model to measured stage positions and beam center pixel coordinates."""
|
||||
stage_mm = np.column_stack([np.ravel(det_z_mm), np.ravel(det_y_mm)]).astype(float)
|
||||
beam_px = np.column_stack([np.ravel(beam_x_px), np.ravel(beam_y_px)]).astype(float)
|
||||
|
||||
@@ -52,7 +52,7 @@ def fit_beam_centre_model(
|
||||
|
||||
|
||||
class BeamCenterFromDetectorStage(BaseModel):
|
||||
"""Least-squares fit of beam centre pixel position against the two stage axes."""
|
||||
"""Least-squares fit of beam center pixel position against the two stage axes."""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ if __name__ == "__main__":
|
||||
print(f"pixel_size: {det_cfg.pixel_size_mm:.4f} mm")
|
||||
print(f"Detector height: {det_cfg.height:.2f} pixel")
|
||||
print(f"Detector width: {det_cfg.width:.2f} pixel")
|
||||
print(f"beam centre = ({cfg.beam_center[0]:.2f}, {cfg.beam_center[1]:.2f})")
|
||||
print(f"beam center = ({cfg.beam_center[0]:.2f}, {cfg.beam_center[1]:.2f})")
|
||||
geom = DiffractionGeometry(
|
||||
energy_keV=12.4,
|
||||
dtz_mm=200.0,
|
||||
|
||||
@@ -297,7 +297,7 @@ def get_com_image_number(com, images):
|
||||
def _to_com_model(coords, images, label: str) -> CenterOfMassModel | None:
|
||||
"""Validate (n_x, n_y) grid coords and wrap them in a CenterOfMassModel.
|
||||
|
||||
Shared by raster_centre_of_mass and raster_highest_score, which differ only
|
||||
Shared by raster_center_of_mass and raster_highest_score, which differ only
|
||||
in how they pick the target cell.
|
||||
"""
|
||||
if coords and not np.isnan(coords[0]) and not np.isnan(coords[1]):
|
||||
@@ -312,7 +312,7 @@ def _to_com_model(coords, images, label: str) -> CenterOfMassModel | None:
|
||||
return None
|
||||
|
||||
|
||||
def raster_centre_of_mass(result_array, images) -> CenterOfMassModel | None:
|
||||
def raster_center_of_mass(result_array, images) -> CenterOfMassModel | None:
|
||||
# grid_mm_x and grid_mm_y are relative to the top left corner of raster grid
|
||||
com = ndimage.center_of_mass(result_array)
|
||||
return _to_com_model(com, images, "Center of mass")
|
||||
@@ -322,13 +322,13 @@ def raster_highest_score(images, min_low_res_spots: float = 10.0) -> CenterOfMas
|
||||
"""Target the grid cell with the highest crystal score.
|
||||
|
||||
If the whole grid is too weak to hold a crystal — max spots_low_res below
|
||||
min_low_res_spots — target the geometric centre of the grid instead of
|
||||
collecting at a noisy cell, so a 'nothing here' result is centred and
|
||||
min_low_res_spots — target the geometric center of the grid instead of
|
||||
collecting at a noisy cell, so a 'nothing here' result is centered and
|
||||
deliberate rather than random noise.
|
||||
|
||||
spots_indexed is deliberately not part of the guard: indexing was dropped
|
||||
from the crystal score (w_indexed=0.00), so a crystal that diffracts but
|
||||
fails to index must still be targeted, not routed to centre.
|
||||
fails to index must still be targeted, not routed to center.
|
||||
"""
|
||||
score_array = compute_crystal_score_array(images)
|
||||
max_low_res = max((getattr(img, "spots_low_res", 0) or 0 for img in images), default=0)
|
||||
@@ -336,10 +336,10 @@ def raster_highest_score(images, min_low_res_spots: float = 10.0) -> CenterOfMas
|
||||
n_nx, n_ny = score_array.shape
|
||||
logger.info(
|
||||
f"No crystal in loop (max spots_low_res={max_low_res} < {min_low_res_spots}); "
|
||||
f"targeting grid centre of {n_nx}x{n_ny} grid"
|
||||
f"targeting grid center of {n_nx}x{n_ny} grid"
|
||||
)
|
||||
# get_com_mm maps index i -> (i+0.5)*step, so index (N-1)/2 is the true
|
||||
# geometric centre of the grid for both odd and even N (and N==1).
|
||||
# geometric center of the grid for both odd and even N (and N==1).
|
||||
return CenterOfMassModel(n_x=(n_nx - 1) / 2.0, n_y=(n_ny - 1) / 2.0)
|
||||
return _to_com_model(_max_cell(score_array), images, "Highest score")
|
||||
|
||||
@@ -364,7 +364,7 @@ def compute_crystal_score_array(
|
||||
) -> np.ndarray:
|
||||
"""Combine bkg (25%), spots_low_res (75%), and spots_indexed (00%) into a 0–100 score.
|
||||
|
||||
Each field is min-max normalised to [0, 100] within the grid before weighting,
|
||||
Each field is min-max normalized to [0, 100] within the grid before weighting,
|
||||
so the final score is the probability (0–100) that a pixel belongs to a crystal.
|
||||
"""
|
||||
arr_bkg = rebuild_array_from_scan_results(scan_results, "bkg")
|
||||
@@ -513,7 +513,7 @@ def crystal_mask_dbscan(
|
||||
"""DBSCAN spatial clustering on cells with signal > min_signal.
|
||||
|
||||
Groups adjacent diffracting cells into clusters; the largest cluster
|
||||
(by total signal weight) is labelled as the crystal.
|
||||
(by total signal weight) is labeled as the crystal.
|
||||
eps=1.5 connects cells that are one grid step apart (including diagonal).
|
||||
"""
|
||||
from sklearn.cluster import DBSCAN
|
||||
@@ -618,7 +618,7 @@ def compare_crystal_methods_scored(
|
||||
"""Plot each crystal-detection method applied to the composite 0–100 crystal score.
|
||||
|
||||
The score combines bkg (w_bkg), spots_low_res (w_low_res), and spots_indexed
|
||||
(w_indexed), each min-max normalised. All six methods are shown side-by-side.
|
||||
(w_indexed), each min-max normalized. All six methods are shown side-by-side.
|
||||
|
||||
Args:
|
||||
results: scan result list (used to build score if score_arr is None)
|
||||
@@ -678,7 +678,7 @@ if __name__ == "__main__":
|
||||
com = raster_highest_score(strong)
|
||||
assert (round(com.n_x), round(com.n_y)) == (1, 1), com
|
||||
|
||||
# All noise (low-res < 10, nothing indexed) -> centre of 3x3 grid == (1,1).
|
||||
# All noise (low-res < 10, nothing indexed) -> center of 3x3 grid == (1,1).
|
||||
noise = [_cell(x, y, 3, 0) for x in range(3) for y in range(3)]
|
||||
com = raster_highest_score(noise)
|
||||
assert (com.n_x, com.n_y) == (1.0, 1.0), com
|
||||
|
||||
@@ -16,8 +16,8 @@ algorithms.
|
||||
|
||||
Seed Scenario
|
||||
---- --------
|
||||
1 Single crystal, centred – baseline positive detection
|
||||
2 Single crystal, off-centre – tests COM accuracy near an edge
|
||||
1 Single crystal, centered – baseline positive detection
|
||||
2 Single crystal, off-center – tests COM accuracy near an edge
|
||||
3 Two well-separated crystals – multi-crystal detection
|
||||
4 Two overlapping crystals – segmentation challenge
|
||||
5 Two clusters at ~90 ° – twinned / differently oriented crystal
|
||||
@@ -43,10 +43,10 @@ from aarecommon.models.raster_grid import RasterGridRequest
|
||||
class _ClusterParams:
|
||||
"""Fractional coordinates and shape of one elliptical crystal cluster.
|
||||
|
||||
cx, cy – cluster centre as a fraction of (n_x, n_y) [0..1]
|
||||
cx, cy – cluster center as a fraction of (n_x, n_y) [0..1]
|
||||
ax, ay – semi-axes as a fraction of (n_x, n_y)
|
||||
theta – rotation of the ellipse in radians
|
||||
peak – peak spots_low_res at cluster centre (integer)
|
||||
peak – peak spots_low_res at cluster center (integer)
|
||||
"""
|
||||
|
||||
cx: float
|
||||
@@ -63,15 +63,15 @@ class _SeedConfig:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-defined seed catalogue
|
||||
# Pre-defined seed catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEED_CATALOGUE: dict[int, _SeedConfig] = {
|
||||
# --- 1: single crystal, centred, compact, strong signal ----------------
|
||||
SEED_CATALOG: dict[int, _SeedConfig] = {
|
||||
# --- 1: single crystal, centered, compact, strong signal ----------------
|
||||
1: _SeedConfig(
|
||||
clusters=[_ClusterParams(cx=0.50, cy=0.50, ax=0.15, ay=0.15, theta=0.0, peak=120)]
|
||||
),
|
||||
# --- 2: single crystal, off-centre, slightly elongated -----------------
|
||||
# --- 2: single crystal, off-center, slightly elongated -----------------
|
||||
2: _SeedConfig(
|
||||
clusters=[_ClusterParams(cx=0.25, cy=0.70, ax=0.12, ay=0.18, theta=0.35, peak=90)]
|
||||
),
|
||||
@@ -82,7 +82,7 @@ SEED_CATALOGUE: dict[int, _SeedConfig] = {
|
||||
_ClusterParams(cx=0.75, cy=0.72, ax=0.15, ay=0.10, theta=0.2, peak=80),
|
||||
]
|
||||
),
|
||||
# --- 4: two overlapping crystals (centres ~1 sigma apart) --------------
|
||||
# --- 4: two overlapping crystals (centers ~1 sigma apart) --------------
|
||||
4: _SeedConfig(
|
||||
clusters=[
|
||||
_ClusterParams(cx=0.40, cy=0.45, ax=0.18, ay=0.15, theta=0.0, peak=110),
|
||||
@@ -135,7 +135,7 @@ def generate_no_beam_scan_result(request: RasterGridRequest, seed: int | None =
|
||||
request:
|
||||
The grid request whose n_x, n_y, and file_prefix are used.
|
||||
seed:
|
||||
Integer 1-6 selects a pre-defined scenario from SEED_CATALOGUE.
|
||||
Integer 1-6 selects a pre-defined scenario from SEED_CATALOG.
|
||||
Any other value (or None) draws cluster parameters randomly using
|
||||
the seed as an RNG seed (None → fully random).
|
||||
|
||||
@@ -159,8 +159,8 @@ def generate_no_beam_scan_result(request: RasterGridRequest, seed: int | None =
|
||||
# Crystal signal layer (spots_low_res)
|
||||
signal = np.zeros((n_x, n_y), dtype=float)
|
||||
|
||||
if seed in SEED_CATALOGUE:
|
||||
cfg = SEED_CATALOGUE[seed]
|
||||
if seed in SEED_CATALOG:
|
||||
cfg = SEED_CATALOG[seed]
|
||||
clusters = cfg.clusters
|
||||
else:
|
||||
# Random fallback: 1 or 2 clusters
|
||||
|
||||
@@ -8,7 +8,7 @@ class BatonRequestStatus(Enum):
|
||||
ACCEPTED = "accepted"
|
||||
REFUSED = "refused"
|
||||
TIMEOUT = "timeout"
|
||||
CANCELLED = "cancelled"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
class BatonHolderInfo(BaseModel):
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Literal
|
||||
|
||||
class WorkflowStateKind(str, Enum):
|
||||
MOUNT = "mount"
|
||||
LOOP_CENTRE = "loop_centre"
|
||||
LOOP_CENTER = "loop_center"
|
||||
RASTER = "raster"
|
||||
DATA_COLLECTION = "data_collection"
|
||||
FINAL = "Paused/Finished"
|
||||
|
||||
@@ -4,14 +4,14 @@ from typing import Any
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from aarecommon.math.beam_center import BeamCenterFromDetectorStage, fit_beam_centre_model
|
||||
from aarecommon.math.beam_center import BeamCenterFromDetectorStage, fit_beam_center_model
|
||||
|
||||
|
||||
class BeamCentreMeasurements(BaseModel):
|
||||
class BeamCenterMeasurements(BaseModel):
|
||||
"""Raw stage positions and the beam pierce point measured at each of them.
|
||||
|
||||
The short aliases (det_z, det_y, beam_x, beam_y) are the key names used in
|
||||
config/beamline_configs/beam_centres.json.
|
||||
config/beamline_configs/beam_centers.json.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
@@ -22,8 +22,8 @@ class BeamCentreMeasurements(BaseModel):
|
||||
beam_y_px: list[float] = Field(validation_alias=AliasChoices("beam_y_px", "beam_y"))
|
||||
|
||||
|
||||
class BeamCentre(BaseModel):
|
||||
measured: BeamCentreMeasurements
|
||||
class BeamCenter(BaseModel):
|
||||
measured: BeamCenterMeasurements
|
||||
model: BeamCenterFromDetectorStage
|
||||
|
||||
@model_validator(mode="before")
|
||||
@@ -31,10 +31,10 @@ class BeamCentre(BaseModel):
|
||||
def _generate_model(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict) or "model" in data or "measured" not in data:
|
||||
return data
|
||||
measured = BeamCentreMeasurements.model_validate(data["measured"])
|
||||
measured = BeamCenterMeasurements.model_validate(data["measured"])
|
||||
return {
|
||||
"measured": measured,
|
||||
"model": fit_beam_centre_model(
|
||||
"model": fit_beam_center_model(
|
||||
det_z_mm=measured.det_z_mm,
|
||||
det_y_mm=measured.det_y_mm,
|
||||
beam_x_px=measured.beam_x_px,
|
||||
@@ -73,7 +73,7 @@ class CenterOfMassModel(BaseModel):
|
||||
class CompletedRasterGridElem(BaseModel):
|
||||
request: RasterGridRequest
|
||||
result: ScanResult
|
||||
centre_of_mass: CenterOfMassModel | None
|
||||
center_of_mass: CenterOfMassModel | None
|
||||
|
||||
|
||||
class CompletedRasterGrid(BaseModel):
|
||||
@@ -85,7 +85,7 @@ class RasterPayloadModel(BaseModel):
|
||||
result: ScanResult
|
||||
sample_id: int
|
||||
attach_image: bool = True
|
||||
centre_of_mass: CenterOfMassModel | None = None
|
||||
center_of_mass: CenterOfMassModel | None = None
|
||||
raster_score: list[float | None] | None = None
|
||||
center_pxl: Coordinate | None
|
||||
start_pxl: Coordinate
|
||||
|
||||
@@ -5,12 +5,12 @@ import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from aarecommon.math.beam_center import BeamCenterFromDetectorStage, fit_beam_centre_model
|
||||
from aarecommon.models.beam_centre import BeamCentre, BeamCentreMeasurements
|
||||
from aarecommon.math.beam_center import BeamCenterFromDetectorStage, fit_beam_center_model
|
||||
from aarecommon.models.beam_center import BeamCenter, BeamCenterMeasurements
|
||||
|
||||
BEAM_CENTRES_JSON = files("aarecommon.config") / "beamline_configs" / "beam_centres.json"
|
||||
BEAM_CENTERS_JSON = files("aarecommon.config") / "beamline_configs" / "beam_centers.json"
|
||||
|
||||
# Ground truth used to synthesise noiseless measurements:
|
||||
# Ground truth used to synthesize noiseless measurements:
|
||||
# beam_px = TRUE_MATRIX @ (det_z_mm, det_y_mm) + TRUE_OFFSET
|
||||
# Rows index the pixel axes (x, y), columns the stage axes (z, y).
|
||||
TRUE_MATRIX = np.array([[0.35, -1.20], [-0.80, 26.50]])
|
||||
@@ -35,21 +35,21 @@ def stage_grid():
|
||||
def synthetic_fit(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
return fit_beam_centre_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
return fit_beam_center_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def beam_centres_config():
|
||||
return json.loads(BEAM_CENTRES_JSON.read_text())
|
||||
def beam_centers_config():
|
||||
return json.loads(BEAM_CENTERS_JSON.read_text())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def x10sa_measured(beam_centres_config):
|
||||
return beam_centres_config["x10sa"]["measured"]
|
||||
def x10sa_measured(beam_centers_config):
|
||||
return beam_centers_config["x10sa"]["measured"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# fit_beam_centre_model
|
||||
# fit_beam_center_model
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_fit_recovers_known_matrix(synthetic_fit):
|
||||
|
||||
|
||||
def test_fit_recovers_known_offset(synthetic_fit, stage_grid):
|
||||
# offset_px is referenced to stage_mean_mm, not to the origin, so undo the centring
|
||||
# offset_px is referenced to stage_mean_mm, not to the origin, so undo the centering
|
||||
# before comparing against the absolute ground-truth offset.
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
expected = TRUE_OFFSET + TRUE_MATRIX @ np.array([det_z_mm.mean(), det_y_mm.mean()])
|
||||
@@ -83,7 +83,7 @@ def test_noiseless_fit_has_zero_residuals(synthetic_fit, stage_grid):
|
||||
def test_fit_accepts_lists_not_just_arrays(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
from_lists = fit_beam_centre_model(
|
||||
from_lists = fit_beam_center_model(
|
||||
det_z_mm.tolist(), det_y_mm.tolist(), beam_x_px.tolist(), beam_y_px.tolist()
|
||||
)
|
||||
np.testing.assert_allclose(from_lists.matrix_px_per_mm, TRUE_MATRIX, atol=1e-9)
|
||||
@@ -93,7 +93,7 @@ def test_fit_ravels_nested_inputs(stage_grid):
|
||||
"""2D stage inputs are flattened rather than rejected."""
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
nested = fit_beam_centre_model(
|
||||
nested = fit_beam_center_model(
|
||||
det_z_mm.reshape(3, 4), det_y_mm.reshape(3, 4), beam_x_px, beam_y_px
|
||||
)
|
||||
np.testing.assert_allclose(nested.matrix_px_per_mm, TRUE_MATRIX, atol=1e-9)
|
||||
@@ -113,7 +113,7 @@ def test_non_finite_samples_are_dropped(stage_grid, column, bad_value):
|
||||
}
|
||||
columns[column][0] = bad_value
|
||||
|
||||
fit = fit_beam_centre_model(**columns)
|
||||
fit = fit_beam_center_model(**columns)
|
||||
|
||||
np.testing.assert_allclose(fit.matrix_px_per_mm, TRUE_MATRIX, atol=1e-9)
|
||||
# The dropped row is gone from the residuals and from the stage mean.
|
||||
@@ -123,12 +123,12 @@ def test_non_finite_samples_are_dropped(stage_grid, column, bad_value):
|
||||
|
||||
def test_fit_rejects_mismatched_lengths():
|
||||
with pytest.raises(ValueError, match="same length"):
|
||||
fit_beam_centre_model([170.0, 250.0, 320.0], [62.0, 62.0, 62.0], [1.0, 2.0], [3.0, 4.0])
|
||||
fit_beam_center_model([170.0, 250.0, 320.0], [62.0, 62.0, 62.0], [1.0, 2.0], [3.0, 4.0])
|
||||
|
||||
|
||||
def test_fit_rejects_too_few_samples():
|
||||
with pytest.raises(ValueError, match="at least 3 finite samples"):
|
||||
fit_beam_centre_model([170.0, 250.0], [62.0, 100.0], [2090.0, 2095.0], [2200.0, 3250.0])
|
||||
fit_beam_center_model([170.0, 250.0], [62.0, 100.0], [2090.0, 2095.0], [2200.0, 3250.0])
|
||||
|
||||
|
||||
def test_fit_rejects_too_few_samples_after_dropping_non_finite(stage_grid):
|
||||
@@ -138,7 +138,7 @@ def test_fit_rejects_too_few_samples_after_dropping_non_finite(stage_grid):
|
||||
beam_x_px = beam_x_px.copy()
|
||||
beam_x_px[2:] = np.nan
|
||||
with pytest.raises(ValueError, match="at least 3 finite samples"):
|
||||
fit_beam_centre_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
fit_beam_center_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
|
||||
|
||||
def test_fit_is_least_squares_on_noisy_data(stage_grid):
|
||||
@@ -146,7 +146,7 @@ def test_fit_is_least_squares_on_noisy_data(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
noise = np.linspace(-3.0, 3.0, det_z_mm.size)
|
||||
fit = fit_beam_centre_model(det_z_mm, det_y_mm, beam_x_px + noise, beam_y_px - noise)
|
||||
fit = fit_beam_center_model(det_z_mm, det_y_mm, beam_x_px + noise, beam_y_px - noise)
|
||||
|
||||
design = BeamCenterFromDetectorStage.design_matrix(fit.stage_mean_mm, det_z_mm, det_y_mm)
|
||||
np.testing.assert_allclose(design.T @ fit.residuals_px, 0.0, atol=1e-9)
|
||||
@@ -156,8 +156,8 @@ def test_fit_is_insensitive_to_sample_order(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
order = np.arange(det_z_mm.size)[::-1]
|
||||
forward = fit_beam_centre_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
reversed_ = fit_beam_centre_model(
|
||||
forward = fit_beam_center_model(det_z_mm, det_y_mm, beam_x_px, beam_y_px)
|
||||
reversed_ = fit_beam_center_model(
|
||||
det_z_mm[order], det_y_mm[order], beam_x_px[order], beam_y_px[order]
|
||||
)
|
||||
np.testing.assert_allclose(forward.matrix_px_per_mm, reversed_.matrix_px_per_mm, atol=1e-9)
|
||||
@@ -169,7 +169,7 @@ def test_fit_is_insensitive_to_sample_order(stage_grid):
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_design_matrix_is_stage_centred_with_intercept():
|
||||
def test_design_matrix_is_stage_centerd_with_intercept():
|
||||
det_z_mm = np.array([170.0, 250.0, 400.0])
|
||||
det_y_mm = np.array([62.0, 100.0, 120.0])
|
||||
design = BeamCenterFromDetectorStage.design_matrix(np.array([250.0, 100.0]), det_z_mm, det_y_mm)
|
||||
@@ -225,14 +225,14 @@ def test_predict_accepts_lists(synthetic_fit):
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# BeamCentreMeasurements / BeamCentre
|
||||
# BeamCenterMeasurements / BeamCenter
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_measurements_accept_canonical_field_names(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
measurements = BeamCentreMeasurements(
|
||||
measurements = BeamCenterMeasurements(
|
||||
det_z_mm=det_z_mm.tolist(),
|
||||
det_y_mm=det_y_mm.tolist(),
|
||||
beam_x_px=beam_x_px.tolist(),
|
||||
@@ -242,7 +242,7 @@ def test_measurements_accept_canonical_field_names(stage_grid):
|
||||
|
||||
|
||||
def test_measurements_accept_json_aliases(x10sa_measured):
|
||||
measurements = BeamCentreMeasurements.model_validate(x10sa_measured)
|
||||
measurements = BeamCenterMeasurements.model_validate(x10sa_measured)
|
||||
assert measurements.det_z_mm == x10sa_measured["det_z"]
|
||||
assert measurements.beam_y_px == x10sa_measured["beam_y"]
|
||||
|
||||
@@ -250,13 +250,13 @@ def test_measurements_accept_json_aliases(x10sa_measured):
|
||||
def test_measurements_reject_missing_column(x10sa_measured):
|
||||
incomplete = {k: v for k, v in x10sa_measured.items() if k != "beam_y"}
|
||||
with pytest.raises(ValidationError):
|
||||
BeamCentreMeasurements.model_validate(incomplete)
|
||||
BeamCenterMeasurements.model_validate(incomplete)
|
||||
|
||||
|
||||
def test_beam_centre_fits_model_from_measurements(stage_grid):
|
||||
def test_beam_center_fits_model_from_measurements(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
beam_centre = BeamCentre.model_validate(
|
||||
beam_center = BeamCenter.model_validate(
|
||||
{
|
||||
"measured": {
|
||||
"det_z_mm": det_z_mm.tolist(),
|
||||
@@ -266,15 +266,15 @@ def test_beam_centre_fits_model_from_measurements(stage_grid):
|
||||
}
|
||||
}
|
||||
)
|
||||
assert isinstance(beam_centre.model, BeamCenterFromDetectorStage)
|
||||
np.testing.assert_allclose(beam_centre.model.matrix_px_per_mm, TRUE_MATRIX, atol=1e-9)
|
||||
assert isinstance(beam_center.model, BeamCenterFromDetectorStage)
|
||||
np.testing.assert_allclose(beam_center.model.matrix_px_per_mm, TRUE_MATRIX, atol=1e-9)
|
||||
|
||||
|
||||
def test_beam_centre_keeps_an_explicitly_supplied_model(synthetic_fit, stage_grid):
|
||||
def test_beam_center_keeps_an_explicitly_supplied_model(synthetic_fit, stage_grid):
|
||||
"""A caller-provided model must be used verbatim, not silently refitted."""
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
beam_centre = BeamCentre.model_validate(
|
||||
beam_center = BeamCenter.model_validate(
|
||||
{
|
||||
"measured": {
|
||||
"det_z_mm": det_z_mm.tolist(),
|
||||
@@ -285,13 +285,13 @@ def test_beam_centre_keeps_an_explicitly_supplied_model(synthetic_fit, stage_gri
|
||||
"model": synthetic_fit,
|
||||
}
|
||||
)
|
||||
np.testing.assert_allclose(beam_centre.model.offset_px, synthetic_fit.offset_px)
|
||||
np.testing.assert_allclose(beam_center.model.offset_px, synthetic_fit.offset_px)
|
||||
|
||||
|
||||
def test_beam_centre_revalidation_is_idempotent(stage_grid):
|
||||
def test_beam_center_revalidation_is_idempotent(stage_grid):
|
||||
det_z_mm, det_y_mm = stage_grid
|
||||
beam_x_px, beam_y_px = synth(det_z_mm, det_y_mm)
|
||||
beam_centre = BeamCentre.model_validate(
|
||||
beam_center = BeamCenter.model_validate(
|
||||
{
|
||||
"measured": {
|
||||
"det_z_mm": det_z_mm.tolist(),
|
||||
@@ -301,13 +301,13 @@ def test_beam_centre_revalidation_is_idempotent(stage_grid):
|
||||
}
|
||||
}
|
||||
)
|
||||
again = BeamCentre.model_validate(beam_centre)
|
||||
np.testing.assert_allclose(again.model.offset_px, beam_centre.model.offset_px)
|
||||
again = BeamCenter.model_validate(beam_center)
|
||||
np.testing.assert_allclose(again.model.offset_px, beam_center.model.offset_px)
|
||||
|
||||
|
||||
def test_beam_centre_propagates_fit_errors():
|
||||
def test_beam_center_propagates_fit_errors():
|
||||
with pytest.raises(ValueError, match="same length"):
|
||||
BeamCentre.model_validate(
|
||||
BeamCenter.model_validate(
|
||||
{
|
||||
"measured": {
|
||||
"det_z_mm": [170.0, 250.0, 320.0],
|
||||
@@ -320,23 +320,23 @@ def test_beam_centre_propagates_fit_errors():
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# beam_centres.json
|
||||
# beam_centers.json
|
||||
# --------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_file_is_importable_package_data():
|
||||
"""Guards the pyproject package-data glob -- a missing json entry breaks the wheel."""
|
||||
assert BEAM_CENTRES_JSON.is_file()
|
||||
assert BEAM_CENTERS_JSON.is_file()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("beamline", ["x10sa", "x06da"])
|
||||
def test_config_has_measurements(beam_centres_config, beamline):
|
||||
assert beamline in beam_centres_config
|
||||
assert set(beam_centres_config[beamline]["measured"]) == {"det_z", "det_y", "beam_x", "beam_y"}
|
||||
def test_config_has_measurements(beam_centers_config, beamline):
|
||||
assert beamline in beam_centers_config
|
||||
assert set(beam_centers_config[beamline]["measured"]) == {"det_z", "det_y", "beam_x", "beam_y"}
|
||||
|
||||
|
||||
def test_config_columns_are_equal_length_and_finite(beam_centres_config):
|
||||
for beamline, entry in beam_centres_config.items():
|
||||
def test_config_columns_are_equal_length_and_finite(beam_centers_config):
|
||||
for beamline, entry in beam_centers_config.items():
|
||||
columns = entry["measured"]
|
||||
lengths = {name: len(values) for name, values in columns.items()}
|
||||
assert len(set(lengths.values())) == 1, f"{beamline} has ragged columns: {lengths}"
|
||||
@@ -344,64 +344,64 @@ def test_config_columns_are_equal_length_and_finite(beam_centres_config):
|
||||
assert np.isfinite(values).all(), f"{beamline}.{name} has non-finite entries"
|
||||
|
||||
|
||||
def test_config_has_enough_samples_to_fit(beam_centres_config):
|
||||
for beamline, entry in beam_centres_config.items():
|
||||
def test_config_has_enough_samples_to_fit(beam_centers_config):
|
||||
for beamline, entry in beam_centers_config.items():
|
||||
assert len(entry["measured"]["det_z"]) >= 3, f"{beamline} cannot be fitted"
|
||||
|
||||
|
||||
def test_config_sweeps_both_stage_axes(beam_centres_config):
|
||||
def test_config_sweeps_both_stage_axes(beam_centers_config):
|
||||
"""A single-axis sweep leaves the fit rank-deficient and the matrix meaningless."""
|
||||
for beamline, entry in beam_centres_config.items():
|
||||
for beamline, entry in beam_centers_config.items():
|
||||
columns = entry["measured"]
|
||||
assert len(set(columns["det_z"])) > 1, f"{beamline} does not sweep det_z"
|
||||
assert len(set(columns["det_y"])) > 1, f"{beamline} does not sweep det_y"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def x10sa_beam_centre(x10sa_measured):
|
||||
return BeamCentre.model_validate({"measured": x10sa_measured})
|
||||
def x10sa_beam_center(x10sa_measured):
|
||||
return BeamCenter.model_validate({"measured": x10sa_measured})
|
||||
|
||||
|
||||
def test_x10sa_fit_matches_a_direct_fit(x10sa_beam_centre, x10sa_measured):
|
||||
direct = fit_beam_centre_model(
|
||||
def test_x10sa_fit_matches_a_direct_fit(x10sa_beam_center, x10sa_measured):
|
||||
direct = fit_beam_center_model(
|
||||
det_z_mm=x10sa_measured["det_z"],
|
||||
det_y_mm=x10sa_measured["det_y"],
|
||||
beam_x_px=x10sa_measured["beam_x"],
|
||||
beam_y_px=x10sa_measured["beam_y"],
|
||||
)
|
||||
np.testing.assert_allclose(x10sa_beam_centre.model.matrix_px_per_mm, direct.matrix_px_per_mm)
|
||||
np.testing.assert_allclose(x10sa_beam_centre.model.offset_px, direct.offset_px)
|
||||
np.testing.assert_allclose(x10sa_beam_center.model.matrix_px_per_mm, direct.matrix_px_per_mm)
|
||||
np.testing.assert_allclose(x10sa_beam_center.model.offset_px, direct.offset_px)
|
||||
|
||||
|
||||
def test_x10sa_beam_y_tracks_det_y(x10sa_beam_centre):
|
||||
def test_x10sa_beam_y_tracks_det_y(x10sa_beam_center):
|
||||
"""det_y moves the detector across the beam: ~26.5 px/mm on the y pixel axis."""
|
||||
d_beam_y_d_det_y = x10sa_beam_centre.model.matrix_px_per_mm[1, 1]
|
||||
d_beam_y_d_det_y = x10sa_beam_center.model.matrix_px_per_mm[1, 1]
|
||||
assert d_beam_y_d_det_y == pytest.approx(26.5, abs=1.0)
|
||||
|
||||
|
||||
def test_x10sa_is_nearly_decoupled_along_the_beam(x10sa_beam_centre):
|
||||
def test_x10sa_is_nearly_decoupled_along_the_beam(x10sa_beam_center):
|
||||
"""det_z is along the beam, so it barely moves the pierce point in either axis."""
|
||||
matrix = x10sa_beam_centre.model.matrix_px_per_mm
|
||||
matrix = x10sa_beam_center.model.matrix_px_per_mm
|
||||
assert abs(matrix[0, 0]) < 0.5
|
||||
assert abs(matrix[1, 0]) < 0.5
|
||||
|
||||
|
||||
def test_x10sa_offset_sits_on_the_detector(x10sa_beam_centre):
|
||||
def test_x10sa_offset_sits_on_the_detector(x10sa_beam_center):
|
||||
"""offset_px is the pierce point at the mean stage position -- must be plausible."""
|
||||
beam_x_px, beam_y_px = x10sa_beam_centre.model.offset_px
|
||||
beam_x_px, beam_y_px = x10sa_beam_center.model.offset_px
|
||||
assert 1500.0 < beam_x_px < 2500.0
|
||||
assert 2500.0 < beam_y_px < 3500.0
|
||||
|
||||
|
||||
def test_x10sa_residuals_are_within_measurement_scatter(x10sa_beam_centre):
|
||||
def test_x10sa_residuals_are_within_measurement_scatter(x10sa_beam_center):
|
||||
"""The affine model should explain the real data to a few tens of pixels."""
|
||||
rms_px = np.sqrt((x10sa_beam_centre.model.residuals_px**2).mean(axis=0))
|
||||
rms_px = np.sqrt((x10sa_beam_center.model.residuals_px**2).mean(axis=0))
|
||||
assert rms_px[0] < 30.0, f"beam_x residual too large: {rms_px[0]:.1f} px"
|
||||
assert rms_px[1] < 30.0, f"beam_y residual too large: {rms_px[1]:.1f} px"
|
||||
|
||||
|
||||
def test_x10sa_predict_reproduces_the_measurements(x10sa_beam_centre, x10sa_measured):
|
||||
beam_x_px, beam_y_px = x10sa_beam_centre.model.predict(
|
||||
def test_x10sa_predict_reproduces_the_measurements(x10sa_beam_center, x10sa_measured):
|
||||
beam_x_px, beam_y_px = x10sa_beam_center.model.predict(
|
||||
x10sa_measured["det_z"], x10sa_measured["det_y"]
|
||||
)
|
||||
np.testing.assert_allclose(beam_x_px, x10sa_measured["beam_x"], atol=60.0)
|
||||
@@ -409,42 +409,42 @@ def test_x10sa_predict_reproduces_the_measurements(x10sa_beam_centre, x10sa_meas
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def x06da_measured(beam_centres_config):
|
||||
return beam_centres_config["x06da"]["measured"]
|
||||
def x06da_measured(beam_centers_config):
|
||||
return beam_centers_config["x06da"]["measured"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def x06da_beam_centre(x06da_measured):
|
||||
return BeamCentre.model_validate({"measured": x06da_measured})
|
||||
def x06da_beam_center(x06da_measured):
|
||||
return BeamCenter.model_validate({"measured": x06da_measured})
|
||||
|
||||
|
||||
def test_x06da_beam_y_tracks_det_y(x06da_beam_centre):
|
||||
def test_x06da_beam_y_tracks_det_y(x06da_beam_center):
|
||||
"""~5.35 px/mm -- a coarser pixel pitch than x10sa, hence the smaller gradient."""
|
||||
d_beam_y_d_det_y = x06da_beam_centre.model.matrix_px_per_mm[1, 1]
|
||||
d_beam_y_d_det_y = x06da_beam_center.model.matrix_px_per_mm[1, 1]
|
||||
assert d_beam_y_d_det_y == pytest.approx(5.35, abs=0.2)
|
||||
|
||||
|
||||
def test_x06da_is_nearly_decoupled_along_the_beam(x06da_beam_centre):
|
||||
matrix = x06da_beam_centre.model.matrix_px_per_mm
|
||||
def test_x06da_is_nearly_decoupled_along_the_beam(x06da_beam_center):
|
||||
matrix = x06da_beam_center.model.matrix_px_per_mm
|
||||
assert abs(matrix[0, 0]) < 0.1
|
||||
assert abs(matrix[1, 0]) < 0.1
|
||||
|
||||
|
||||
def test_x06da_offset_sits_on_the_detector(x06da_beam_centre):
|
||||
beam_x_px, beam_y_px = x06da_beam_centre.model.offset_px
|
||||
def test_x06da_offset_sits_on_the_detector(x06da_beam_center):
|
||||
beam_x_px, beam_y_px = x06da_beam_center.model.offset_px
|
||||
assert 500.0 < beam_x_px < 1000.0
|
||||
assert 800.0 < beam_y_px < 1500.0
|
||||
|
||||
|
||||
def test_x06da_residuals_are_small(x06da_beam_centre):
|
||||
def test_x06da_residuals_are_small(x06da_beam_center):
|
||||
"""x06da was measured cleanly -- the affine model holds to ~1 px."""
|
||||
rms_px = np.sqrt((x06da_beam_centre.model.residuals_px**2).mean(axis=0))
|
||||
rms_px = np.sqrt((x06da_beam_center.model.residuals_px**2).mean(axis=0))
|
||||
assert rms_px[0] < 2.0, f"beam_x residual too large: {rms_px[0]:.2f} px"
|
||||
assert rms_px[1] < 2.0, f"beam_y residual too large: {rms_px[1]:.2f} px"
|
||||
|
||||
|
||||
def test_x06da_predict_reproduces_the_measurements(x06da_beam_centre, x06da_measured):
|
||||
beam_x_px, beam_y_px = x06da_beam_centre.model.predict(
|
||||
def test_x06da_predict_reproduces_the_measurements(x06da_beam_center, x06da_measured):
|
||||
beam_x_px, beam_y_px = x06da_beam_center.model.predict(
|
||||
x06da_measured["det_z"], x06da_measured["det_y"]
|
||||
)
|
||||
np.testing.assert_allclose(beam_x_px, x06da_measured["beam_x"], atol=5.0)
|
||||
@@ -11,7 +11,7 @@ from aarecommon.math.find_xtal import (
|
||||
get_xtal_size,
|
||||
has_sufficient_low_res_spots,
|
||||
identify_crystal_raster,
|
||||
raster_centre_of_mass,
|
||||
raster_center_of_mass,
|
||||
raster_highest_score,
|
||||
rebuild_array_from_scan_results,
|
||||
)
|
||||
@@ -99,10 +99,10 @@ def test_get_best_res(mock_raster_results):
|
||||
assert get_best_res([]) is None
|
||||
|
||||
|
||||
def test_raster_centre_of_mass(mock_raster_results):
|
||||
def test_raster_center_of_mass(mock_raster_results):
|
||||
arr = np.zeros((3, 3))
|
||||
arr[1, 1] = 10.0
|
||||
com = raster_centre_of_mass(arr, mock_raster_results)
|
||||
com = raster_center_of_mass(arr, mock_raster_results)
|
||||
assert com.n_x == 1.0
|
||||
assert com.n_y == 1.0
|
||||
|
||||
@@ -137,7 +137,7 @@ def mock_score_results():
|
||||
def test_compute_crystal_score_array(mock_score_results):
|
||||
score = compute_crystal_score_array(mock_score_results)
|
||||
assert score.shape == (2, 2)
|
||||
# weights sum to 1.0 and each input is min-max normalised to [0, 100]
|
||||
# weights sum to 1.0 and each input is min-max normalized to [0, 100]
|
||||
assert score.min() >= 0.0 and score.max() <= 100.0
|
||||
# (1, 1) is max in all three inputs -> 100; weak corner (0, 0) is min in all -> 0
|
||||
assert score[1, 1] == pytest.approx(100.0)
|
||||
|
||||
Reference in New Issue
Block a user