add debugging doc and tests for file path propagation
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
# Rotation scan filename: origin to detector
|
||||
|
||||
Trace of how the rotation-scan file path is built and transformed, from its origin
|
||||
(GUI or aaredb) down to the JFJoch `DatasetSettings` that name the file on disk.
|
||||
|
||||
All line numbers are from the working tree at the time of writing.
|
||||
|
||||
> **Naming correction.** There is no `_execute_rotation_request` in `daq.py`.
|
||||
> The method both paths converge on is **`_execute_rotation_sequence`**
|
||||
> (`daq.py:1431`), called from `measure_rotation` (`daq.py:2071`) and from
|
||||
> `measure` (`daq.py:2654`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Two independent origins produce a `RotationScanRequest.file_prefix`
|
||||
(`aarecommon/models/rotation_scan.py:13`, `str | None`):
|
||||
|
||||
| | Origin A — manual (GUI) | Origin B — automation |
|
||||
|---|---|---|
|
||||
| Entry | `FilePathPanel` widgets | `AareDAQ.measure()` |
|
||||
| Built in | `file_path_panel.py:145` | `daq.py:2469`, `daq.py:2647` |
|
||||
| Sample data from | aaredb via `DAQStatusModel.sample` | aaredb via `SampleShortInfo` argument |
|
||||
| Transport | `POST /scan/rotation` | in-process |
|
||||
| Converges at | `measure_rotation` → `_execute_rotation_sequence` | `_execute_rotation_sequence` |
|
||||
|
||||
Both then pass unchanged through `_execute_rotation_sequence` and the rotation
|
||||
service, and receive their **only server-side path transformation** inside
|
||||
`jfjoch._format_dataset_settings` (`jfjoch.py:189`), which prepends a
|
||||
pgroup- and scan-kind-dependent `data_folder`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Origin A — manual rotation and screening from the GUI
|
||||
|
||||
### 2.1 Widget input (`file_path_panel.py`)
|
||||
|
||||
Three widgets feed the name:
|
||||
|
||||
- `directory_edit` — directory template, may contain `{prefix}` and macros.
|
||||
**Defaults to `"{date}/{puck}/{pos}"`** (line 64) — see §6.6, `{pos}` is never substituted
|
||||
- `file_prefix_edit` — prefix template, **defaults to the literal `"{sample}"`** (line 77)
|
||||
- `run_number_edit` — integer run number, 1..999
|
||||
|
||||
### 2.2 `update_filename()` (`file_path_panel.py:145`)
|
||||
|
||||
```
|
||||
dir_name = directory_edit.text()
|
||||
file_prefix = file_prefix_edit.text()
|
||||
|
||||
dir_name = dir_name.replace("{prefix}", file_prefix) # prefix substituted into directory
|
||||
|
||||
base = "" if dir_name == ""
|
||||
= dir_name if dir_name ends with "/"
|
||||
= dir_name + "/" otherwise
|
||||
|
||||
base += "run" if file_prefix == "" else file_prefix # empty prefix -> literal "run"
|
||||
```
|
||||
|
||||
The name is then exactly what the fields say — `update_filename` does **no**
|
||||
collision handling. The result is stored **without** any scan-kind component:
|
||||
|
||||
```python
|
||||
self._filename = self._expand_macros(base, run_number) # line 165
|
||||
self.path_updated.emit(self._filename) # line 172
|
||||
```
|
||||
|
||||
The comment at line 164 is explicit: *"Store the GUI's base (without applying
|
||||
scan-kind transforms) for wiring into requests later."*
|
||||
|
||||
Collision handling lives in the click-time guard `file_path_error_box(scan_kind)`
|
||||
(`file_path_panel.py:240`), which the Run buttons call. It sets `_scan_kind`,
|
||||
refreshes the name for that kind, and tests it with `_exists_for_run()` →
|
||||
`_effective_dataset_base()` (line 120), which checks
|
||||
`<root>/<pgroup>/raw/<scan-kind>/<base>_master.h5`. On a clash it bumps the run
|
||||
number by one, warns the user, and **refuses the run** (returns `False`), so the
|
||||
next click re-checks. Nothing is silently skipped.
|
||||
|
||||
### 2.3 `_expand_macros(base, rn)` (`file_path_panel.py:106`)
|
||||
|
||||
Appends the zero-padded run number, then substitutes macros:
|
||||
|
||||
```
|
||||
name = f"{base}_{rn:03d}"
|
||||
```
|
||||
|
||||
| Macro | Replaced with |
|
||||
|---|---|
|
||||
| `{date}` | `self._formatted_date` (`yyyymmdd`) |
|
||||
| `{sample}`, `{CrystalName}` | `self._sample_name` |
|
||||
| `{puck}` | `self._puck_name` |
|
||||
| `{position}` | `f"{self._puck_pos:02d}"` |
|
||||
| `{sample_id}` | `self._sample_id` |
|
||||
| `{beamline}` | `self._beamline` |
|
||||
| `{prefix}` | `f"{puck_name}/{puck_pos:02d}/{sample_name}"` |
|
||||
|
||||
There is **no `{pos}` entry**, although both tooltips advertise one
|
||||
(lines 69 and 82) and the default directory template uses it — see §6.6.
|
||||
|
||||
With an empty directory and `file_prefix_edit == "{sample}"` this yields:
|
||||
|
||||
```
|
||||
<sample_name>_001
|
||||
```
|
||||
|
||||
With the *shipped* defaults (`directory_edit == "{date}/{puck}/{pos}"`) it yields:
|
||||
|
||||
```
|
||||
20260922/PUCK1/{pos}/xtal7_001
|
||||
```
|
||||
|
||||
### 2.4 Distribution (`data_collection_settings.py:190-192`)
|
||||
|
||||
```python
|
||||
self.file_path_panel.path_updated.connect(raster_mgr.update_filename)
|
||||
self.file_path_panel.path_updated.connect(self.screening.update_filename)
|
||||
self.file_path_panel.path_updated.connect(self.simple.update_filename)
|
||||
```
|
||||
|
||||
Each consumer stores it verbatim as `self._filename`.
|
||||
|
||||
### 2.5 Scan-kind prefix applied at request build time
|
||||
|
||||
`rotation_data_collection.py:33-40`:
|
||||
|
||||
```python
|
||||
def add_screening_to_path(path): return "screening" / Path(path)
|
||||
def add_data_to_path(path): return "data" / Path(path)
|
||||
```
|
||||
|
||||
| Call site | Transform | Resulting `file_prefix` |
|
||||
|---|---|---|
|
||||
| `rotation_data_collection.py:341` (rotation) | `add_data_to_path` | `data/<base>_001` |
|
||||
| `rotation_data_collection.py:303` (screening, single image) | `add_screening_to_path` | `screening/<base>_001` |
|
||||
| `rotation_data_collection.py:315` (screening, wedges) | `add_screening_to_path` | `screening/<base>_001` |
|
||||
| `smart_rotation_panel.py:523` (smart rotation) | `add_data_to_path` | `data/<base>_001` |
|
||||
|
||||
`str()` is applied to the `PosixPath` when assigning to `file_prefix`.
|
||||
|
||||
### 2.6 Transport
|
||||
|
||||
`rotation_scan` signal → `daq_worker` → `POST /scan/rotation` → `server.py`
|
||||
`rotation` endpoint → `daq.measure_rotation(val)`. The model is serialised and
|
||||
revalidated as JSON; `file_prefix` is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 3. Origin B — automation (`AareDAQ.measure`)
|
||||
|
||||
### 3.1 Base prefix (`daq.py:2469`)
|
||||
|
||||
```python
|
||||
formatted_date = datetime.now().strftime("%Y%m%d")
|
||||
sample_prefix = f"{formatted_date}/{sample.puck_name}/{sample.pin:02d}/{sample.sample_name}"
|
||||
```
|
||||
|
||||
`sample` is the `SampleShortInfo` passed into `measure()`, sourced from aaredb
|
||||
(via TELL/spreadsheet sync). Note this is a **slash-separated directory
|
||||
hierarchy**, not a flat prefix, and is date-first — a different shape from the
|
||||
GUI's `<base>_<run>`.
|
||||
|
||||
### 3.2 Per-run token (`daq.py:2558`)
|
||||
|
||||
```python
|
||||
hex_string = secrets.token_hex(3) # 6 hex chars
|
||||
```
|
||||
|
||||
One token is generated per `measure()` call and reused by **both** the raster and
|
||||
the rotation request, so the two datasets share a run identifier.
|
||||
|
||||
### 3.3 Raster leg (`daq.py:2558-2568`)
|
||||
|
||||
```python
|
||||
raster_params = self.get_auto_raster_params()
|
||||
if raster_params.filename is not None:
|
||||
sample_prefix = f"{raster_params.filename}/{sample.sample_name}" # reassigns sample_prefix
|
||||
|
||||
raster_grid = RasterGridRequest(
|
||||
file_prefix=sample_prefix + f"_{hex_string}", # no "data/" component
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
### 3.4 Rotation leg (`daq.py:2640-2652`)
|
||||
|
||||
```python
|
||||
params, source = self.get_collection_params(prefer_smart=False)
|
||||
...
|
||||
if params.filename is not None:
|
||||
sample_prefix = f"{params.filename}/{sample.sample_name}"
|
||||
|
||||
rotation_request = RotationScanRequest(
|
||||
file_prefix="data/" + sample_prefix + f"_{hex_string}",
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
With both override branches inert (see §6.3), the automation `file_prefix` is:
|
||||
|
||||
```
|
||||
data/<yyyymmdd>/<puck_name>/<pin:02d>/<sample_name>_<hex6>
|
||||
```
|
||||
|
||||
### 3.5 Where `params.filename` would come from
|
||||
|
||||
`get_collection_params` (`daq.py:2396`) picks one of three `SimpleScanParameters`
|
||||
(`filename: str | None = None`, `models.py:699`):
|
||||
|
||||
| Source | Origin | Sets `filename`? |
|
||||
|---|---|---|
|
||||
| `spreadsheet_params()` | aaredb `sample.aaredb_params` | No |
|
||||
| `smart_params` (`cfg.auto_params`) | GUI → `POST /scan/smart_params` → Redis `<BL>:auto_params` | No |
|
||||
| `default_params` | `dispatch.default_rotation_parameters` | No — `{"exp_time_s", "dtz", "incr_omega_deg"}` only |
|
||||
|
||||
---
|
||||
|
||||
## 4. Shared tail: request to detector
|
||||
|
||||
### 4.1 `measure_rotation` (`daq.py:2051`)
|
||||
|
||||
Logs the request, calls `_execute_rotation_sequence`, manages beamline state.
|
||||
**No path transformation.**
|
||||
|
||||
### 4.2 `_execute_rotation_sequence` (`daq.py:1431`)
|
||||
|
||||
Clamps `exp_time_s` to `>= 0.0011`, validates parameters, delegates to
|
||||
`self._create_rotation_service().run(request)`. **No path transformation.**
|
||||
|
||||
### 4.3 Rotation service (`operations/rotation/service.py:118`)
|
||||
|
||||
`run()` contains no reference to `file_prefix`. **No path transformation.**
|
||||
|
||||
### 4.4 `jfjoch._format_dataset_settings` (`jfjoch.py:135-189`)
|
||||
|
||||
The only server-side transformation. `data_folder` is chosen by request type and
|
||||
the `screening` flag (`jfjoch.py:152-169`):
|
||||
|
||||
| Request | `data_folder` |
|
||||
|---|---|
|
||||
| `RasterGridRequest` | `{pgroup}/raw/raster` |
|
||||
| `RotationScanRequest`, `screening=True` | `{pgroup}/raw/screening` |
|
||||
| `RotationScanRequest`, `screening=False` | `{pgroup}/raw` |
|
||||
|
||||
`pgroup` is `s.sample.user`, falling back to the hard-coded `"p16371"` when
|
||||
`s.sample is None` (`jfjoch.py:143-145`).
|
||||
|
||||
```python
|
||||
file_prefix=f"{data_folder}/{r.file_prefix}" # jfjoch.py:189
|
||||
```
|
||||
|
||||
### 4.5 Final values
|
||||
|
||||
| Path | `DatasetSettings.file_prefix` |
|
||||
|---|---|
|
||||
| GUI rotation | `{pgroup}/raw/data/<base>_001` |
|
||||
| GUI screening | `{pgroup}/raw/screening/screening/<base>_001` |
|
||||
| GUI smart rotation | `{pgroup}/raw/data/<base>_001` |
|
||||
| Automation rotation | `{pgroup}/raw/data/<yyyymmdd>/<puck>/<pin>/<sample>_<hex6>` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Write-back to aaredb
|
||||
|
||||
`aaredb.py:195` (rotation) and `aaredb.py:258` (raster):
|
||||
|
||||
```python
|
||||
dataset = Datasets(filepath=r.file_prefix, status="written", written_at=...)
|
||||
```
|
||||
|
||||
This records the **pre-JFJoch** value, i.e. *without* `data_folder` and without
|
||||
the `/sls/mx/data` root. The database `filepath` is therefore not the path the
|
||||
file was written to.
|
||||
|
||||
---
|
||||
|
||||
## 6. Findings
|
||||
|
||||
### 6.1 Screening prefix is applied twice
|
||||
|
||||
The GUI prepends `screening/` to `file_prefix`
|
||||
(`rotation_data_collection.py:303`, `:315`), and JFJoch independently selects
|
||||
`data_folder = {pgroup}/raw/screening` for `screening=True` (`jfjoch.py:162`).
|
||||
The final prefix contains `screening` twice:
|
||||
|
||||
```
|
||||
{pgroup}/raw/screening/screening/<base>_001
|
||||
```
|
||||
|
||||
The GUI's own preview label and collision check
|
||||
(`_effective_dataset_base`, `file_path_panel.py:120`) build
|
||||
`<root>/{pgroup}/raw/screening/<base>` — one level, not two. So for screening
|
||||
the displayed path, the run-number collision check, and the written path
|
||||
disagree. Rotation and raster do not have this problem: the GUI contributes
|
||||
`data/` where JFJoch contributes none, and contributes nothing for raster where
|
||||
JFJoch contributes `raster`.
|
||||
|
||||
Worth confirming against a real screening dataset on disk before changing
|
||||
anything, since the fix differs depending on which of the two is considered
|
||||
authoritative.
|
||||
|
||||
### 6.2 `spreadsheet_params()` never returns a file prefix
|
||||
|
||||
`daq.py:1629` initialises `file_prefix = None` and no code path ever assigns it;
|
||||
all four `return` statements return that `None`. The sole caller discards it:
|
||||
|
||||
```python
|
||||
spreadsheet_params, _file_prefix = self.spreadsheet_params() # daq.py:2397
|
||||
```
|
||||
|
||||
The `tuple[SimpleScanParameters | None, str | None]` return type and the
|
||||
underscore-prefixed unpack suggest a per-sample aaredb-driven prefix was
|
||||
intended but never wired up. **There is currently no aaredb origin for the
|
||||
filename** — aaredb only supplies the sample identity fields that `measure()`
|
||||
formats itself.
|
||||
|
||||
### 6.3 Both filename override branches in `measure()` are dead
|
||||
|
||||
```python
|
||||
if raster_params.filename is not None: # daq.py:2562
|
||||
if params.filename is not None: # daq.py:2640
|
||||
```
|
||||
|
||||
`raster_params` comes from `get_auto_raster_params()` → `get_beamline_default_raster_params()`
|
||||
(`daq.py:2307`), which constructs `SimpleScanParameters(dtz=..., exp_time_s=..., transmission=...)`
|
||||
and never sets `filename`; the aaredb overlay that follows touches only exposure,
|
||||
transmission and dtz.
|
||||
|
||||
`params` comes from `get_collection_params()`, whose three sources all leave
|
||||
`filename` at its `None` default (§3.5) — including `smart_params`, because
|
||||
`SmartRotationPanel` builds its `SimpleScanParameters` at
|
||||
`smart_rotation_panel.py:492` without a `filename=` argument.
|
||||
|
||||
So the automation prefix is always the `sample_prefix` computed at `daq.py:2469`.
|
||||
|
||||
### 6.4 `sample_prefix` leaks from the raster leg into the rotation leg
|
||||
|
||||
`sample_prefix` is a single local rebound in both legs. The raster branch at
|
||||
`daq.py:2562` reassigns it to `f"{raster_params.filename}/{sample.sample_name}"`,
|
||||
and that reassignment is still in effect when the rotation branch reads it at
|
||||
`daq.py:2647`. If `raster_params.filename` were ever set while `params.filename`
|
||||
was not, the rotation dataset would silently inherit the raster-derived prefix
|
||||
and lose the date/puck/pin hierarchy.
|
||||
|
||||
Currently latent because both branches are dead (§6.3), but it becomes live the
|
||||
moment either filename source is wired up.
|
||||
|
||||
### 6.5 Database path is not the written path
|
||||
|
||||
See §5. `Datasets.filepath` is missing `{pgroup}/raw[/screening|/raster]` and the
|
||||
storage root, so it cannot be used to locate the file without re-deriving the
|
||||
`data_folder` rules from `jfjoch.py:152-169`.
|
||||
|
||||
### 6.6 The default directory template contains a macro that is never substituted
|
||||
|
||||
`directory_edit` ships with the default text `"{date}/{puck}/{pos}"`
|
||||
(`file_path_panel.py:64`), and `{pos}` is documented in both tooltips
|
||||
(`file_path_panel.py:69`, `:82`). But `_expand_macros` (`file_path_panel.py:106-118`)
|
||||
substitutes only `{position}` — there is no `{pos}` replacement.
|
||||
|
||||
Replaying the shipped default through the macro table gives:
|
||||
|
||||
```
|
||||
20260922/PUCK1/{pos}/xtal7_001
|
||||
```
|
||||
|
||||
The literal four characters `{pos}` become a directory component, so with stock
|
||||
settings every manually collected dataset is written under a directory named
|
||||
`{pos}` instead of the pin position. It propagates all the way to
|
||||
`DatasetSettings.file_prefix` and into `Datasets.filepath` in aaredb.
|
||||
|
||||
Either add `name.replace("{pos}", f"{self._puck_pos:02d}")` alongside the
|
||||
`{position}` line, or change the default template and both tooltips to
|
||||
`{position}`. The first is the safer fix, since operators may already have
|
||||
`{pos}` saved in their own templates.
|
||||
|
||||
### 6.7 Minor
|
||||
|
||||
- `clean_filename` is disabled in `daq.py:_expand_macros` (`daq.py:1625`) with a
|
||||
TODO about it stripping slashes. Nothing sanitises user-supplied text, so a
|
||||
sample name or prefix containing `/`, `..` or shell-significant characters
|
||||
propagates into the dataset path unaltered.
|
||||
- `daq.py:1613` `_expand_macros` duplicates the GUI's macro table
|
||||
(`file_path_panel.py:106`) but is **called from nowhere** — the server-side copy
|
||||
is unused. The two would need to be kept in sync if it were ever wired up.
|
||||
- The `"p16371"` pgroup fallback (`jfjoch.py:145`) writes into a fixed pgroup
|
||||
when sample identity is missing, rather than failing.
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Debugger walkthrough: how a rotation scan's filename is built.
|
||||
|
||||
Companion to ``docs/rotation_filename_path.md``. These tests are not here to
|
||||
catch regressions so much as to give you somewhere to put a breakpoint. Each
|
||||
one arranges the minimum, then makes exactly **one** call into real production
|
||||
code -- step into that call and you are inside the stage being described.
|
||||
|
||||
Run one stage at a time:
|
||||
|
||||
pytest tests/unit/test_rotation_filename_walkthrough.py -k expand_macros -s
|
||||
|
||||
Two tests are marked ``xfail(strict=True)``: they assert what the code *should*
|
||||
do, and fail because of the bugs recorded in docs sections 6.1 and 6.6. Read
|
||||
their bodies to see the defect reproduced in real code; they will start passing
|
||||
(and so fail as strict xfails) the moment either bug is fixed.
|
||||
|
||||
Stage map, in the order data flows:
|
||||
|
||||
1. FilePathPanel._expand_macros file_path_panel.py:106
|
||||
2. FilePathPanel.update_filename file_path_panel.py:145
|
||||
3. add_data_to_path/_screening_ rotation_data_collection.py:33
|
||||
4. measure() prefix construction daq.py:2469, :2647 (mirrored, see below)
|
||||
5. _format_dataset_settings jfjoch.py:133 <- the only server-side transform
|
||||
6. aaredb write-back aaredb.py:195
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from aarecommon.models.rotation_scan import RotationScanRequest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 -- GUI macro expansion (file_path_panel.py:106)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def panel(qapp):
|
||||
"""A real FilePathPanel.
|
||||
|
||||
Constructing it runs update_filename() once, so by the time you get here
|
||||
``panel._filename`` already holds a fully expanded name. Breakpoint in
|
||||
FilePathPanel.__init__ if you want to watch that first pass.
|
||||
"""
|
||||
from aare.gui.panels.file_path_panel import FilePathPanel
|
||||
|
||||
return FilePathPanel()
|
||||
|
||||
|
||||
def test_expand_macros_substitutes_the_documented_table(panel):
|
||||
"""Step into _expand_macros: file_path_panel.py:106.
|
||||
|
||||
Note the run number is appended *first*, then macros are replaced, so a
|
||||
macro cannot expand into something containing the run number.
|
||||
"""
|
||||
panel._formatted_date = "20260922"
|
||||
panel._sample_name = "xtal7"
|
||||
panel._puck_name = "PUCK1"
|
||||
panel._puck_pos = 3
|
||||
panel._sample_id = 42
|
||||
panel._beamline = "x10sa"
|
||||
|
||||
expanded = panel._expand_macros("{date}/{puck}/{position}/{sample}", 1)
|
||||
|
||||
assert expanded == "20260922/PUCK1/03/xtal7_001"
|
||||
|
||||
|
||||
def test_expand_macros_prefix_macro_expands_to_three_levels(panel):
|
||||
"""``{prefix}`` is the shorthand that expands to puck/pin/sample at once."""
|
||||
panel._sample_name = "xtal7"
|
||||
panel._puck_name = "PUCK1"
|
||||
panel._puck_pos = 3
|
||||
|
||||
expanded = panel._expand_macros("{prefix}", 7)
|
||||
|
||||
# Expanded after the run number is appended, so _007 lands on the sample.
|
||||
assert expanded == "PUCK1/03/xtal7_007"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"docs 6.6: directory_edit ships defaulting to '{date}/{puck}/{pos}' "
|
||||
"(file_path_panel.py:64) and both tooltips advertise {pos}, but "
|
||||
"_expand_macros only substitutes {position}. The literal four "
|
||||
"characters '{pos}' become a directory component."
|
||||
),
|
||||
)
|
||||
def test_expand_macros_substitutes_pos_as_advertised(panel):
|
||||
"""Step into _expand_macros and look for a ``{pos}`` replace -- there is none.
|
||||
|
||||
This is the *shipped default* directory template, so with stock settings
|
||||
every manually collected dataset is written under a directory called
|
||||
literally ``{pos}``.
|
||||
"""
|
||||
panel._formatted_date = "20260922"
|
||||
panel._sample_name = "xtal7"
|
||||
panel._puck_name = "PUCK1"
|
||||
panel._puck_pos = 3
|
||||
|
||||
expanded = panel._expand_macros("{date}/{puck}/{pos}/{sample}", 1)
|
||||
|
||||
assert expanded == "20260922/PUCK1/03/xtal7_001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 -- assembling the base name (file_path_panel.py:145)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_filename_splices_the_prefix_into_the_directory(panel):
|
||||
"""Step into update_filename: file_path_panel.py:145.
|
||||
|
||||
Watch two things happen in order:
|
||||
1. ``{prefix}`` inside the *directory* is replaced with the prefix field
|
||||
2. a "/" is ensured between directory and prefix
|
||||
|
||||
The name is then whatever the fields literally say -- update_filename does
|
||||
no collision handling at all. That moved to the click-time guard exercised
|
||||
in the next two tests.
|
||||
"""
|
||||
panel._sample_name = "xtal7"
|
||||
panel.directory_edit.setText("runs/{prefix}")
|
||||
panel.file_prefix_edit.setText("{sample}")
|
||||
panel.run_number_edit.setValue(1)
|
||||
|
||||
panel.update_filename()
|
||||
|
||||
# "{prefix}" in the directory became "{sample}", which then expanded.
|
||||
assert panel._filename == "runs/xtal7/xtal7_001"
|
||||
|
||||
|
||||
def test_run_is_allowed_when_the_target_path_is_free(panel, monkeypatch):
|
||||
"""Step into file_path_error_box: file_path_panel.py:240.
|
||||
|
||||
This is the click-time guard the Run buttons call. It sets the scan kind,
|
||||
refreshes the name for that kind, and asks _exists_for_run about it. The
|
||||
existence check is stubbed so nothing touches /sls; drop the monkeypatch to
|
||||
watch it probe the real filesystem via _effective_dataset_base.
|
||||
"""
|
||||
monkeypatch.setattr(panel, "_exists_for_run", lambda _base: False)
|
||||
panel._sample_name = "xtal7"
|
||||
panel.directory_edit.setText("")
|
||||
panel.file_prefix_edit.setText("{sample}")
|
||||
panel.run_number_edit.setValue(1)
|
||||
|
||||
may_run = panel.file_path_error_box(scan_kind="rotation")
|
||||
|
||||
assert may_run is True
|
||||
assert panel.run_number_edit.value() == 1
|
||||
|
||||
|
||||
def test_run_is_refused_and_the_run_number_bumped_on_a_clash(panel, monkeypatch):
|
||||
"""The clash branch at file_path_panel.py:249.
|
||||
|
||||
Note it *refuses* the run rather than silently picking the next free
|
||||
number: the run number goes up by exactly one, the user is told, and the
|
||||
next click re-checks. QMessageBox is stubbed out so the test does not block
|
||||
on a modal dialog.
|
||||
"""
|
||||
from aare.gui.panels import file_path_panel as module
|
||||
|
||||
monkeypatch.setattr(panel, "_exists_for_run", lambda _base: True)
|
||||
monkeypatch.setattr(module.QMessageBox, "warning", lambda *a, **k: None)
|
||||
panel._sample_name = "xtal7"
|
||||
panel.directory_edit.setText("")
|
||||
panel.file_prefix_edit.setText("{sample}")
|
||||
panel.run_number_edit.setValue(1)
|
||||
|
||||
may_run = panel.file_path_error_box(scan_kind="rotation")
|
||||
|
||||
assert may_run is False
|
||||
assert panel.run_number_edit.value() == 2
|
||||
|
||||
|
||||
def test_update_filename_stores_the_base_without_a_scan_kind(panel):
|
||||
"""The stored name carries no data/ or screening/ component.
|
||||
|
||||
That is deliberate (see the comment at file_path_panel.py:164) -- the scan
|
||||
kind is attached later, per request, at stage 3. Note _scan_kind still
|
||||
affects the *preview label* via _effective_dataset_base, which is the root
|
||||
of the screening mismatch in docs 6.1.
|
||||
"""
|
||||
panel._sample_name = "xtal7"
|
||||
panel.directory_edit.setText("")
|
||||
panel.file_prefix_edit.setText("{sample}")
|
||||
|
||||
panel.update_filename()
|
||||
|
||||
assert not panel._filename.startswith(("data/", "screening/"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3 -- the scan-kind prefix (rotation_data_collection.py:33)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_data_to_path_prefixes_rotation_datasets():
|
||||
"""Applied at rotation_data_collection.py:341 and smart_rotation_panel.py:523."""
|
||||
from aare.gui.panels.rotation_data_collection import add_data_to_path
|
||||
|
||||
assert str(add_data_to_path("20260922/PUCK1/03/xtal7_001")) == (
|
||||
"data/20260922/PUCK1/03/xtal7_001"
|
||||
)
|
||||
|
||||
|
||||
def test_add_screening_to_path_prefixes_screening_datasets():
|
||||
"""Applied at rotation_data_collection.py:303 and :315.
|
||||
|
||||
Compare with stage 5: JFJoch *also* routes screening into a screening
|
||||
folder, which is where the duplication in docs 6.1 comes from.
|
||||
"""
|
||||
from aare.gui.panels.rotation_data_collection import add_screening_to_path
|
||||
|
||||
assert str(add_screening_to_path("20260922/PUCK1/03/xtal7_001")) == (
|
||||
"screening/20260922/PUCK1/03/xtal7_001"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 4 -- the automation prefix (daq.py:2469 and :2647)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# measure() is a ~200 line hardware sequence, so there is no seam to call here.
|
||||
# The two tests below mirror its two lines verbatim so you can see the shape and
|
||||
# feed it into stage 5. To watch the real thing, breakpoint daq.py:2469.
|
||||
|
||||
|
||||
def test_automation_prefix_is_a_date_first_directory_hierarchy(sample_info):
|
||||
"""Mirrors daq.py:2469.
|
||||
|
||||
Unlike the GUI's flat ``<base>_<run>``, automation builds a slash-separated
|
||||
hierarchy led by the date. Both end up in the same ``file_prefix`` field.
|
||||
"""
|
||||
formatted_date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
sample_prefix = (
|
||||
f"{formatted_date}/{sample_info.puck_name}/{sample_info.pin:02d}/{sample_info.sample_name}"
|
||||
)
|
||||
|
||||
assert sample_prefix == f"{formatted_date}/puck1/03/sample1"
|
||||
|
||||
|
||||
def test_automation_rotation_request_prepends_data_and_appends_a_token():
|
||||
"""Mirrors daq.py:2647.
|
||||
|
||||
The hex token is generated once per measure() call (daq.py:2558) and shared
|
||||
with the raster request, so a sample's raster and rotation datasets carry
|
||||
the same run identifier.
|
||||
"""
|
||||
request = RotationScanRequest(
|
||||
file_prefix="data/" + "20260922/puck1/03/sample1" + "_a1b2c3",
|
||||
exp_time_s=0.1,
|
||||
incr_omega_deg=0.1,
|
||||
steps=10,
|
||||
dtz=200,
|
||||
)
|
||||
|
||||
assert request.file_prefix == "data/20260922/puck1/03/sample1_a1b2c3"
|
||||
|
||||
|
||||
def test_no_parameter_source_ever_supplies_a_filename():
|
||||
"""Why the ``if params.filename is not None`` branches never fire.
|
||||
|
||||
docs 6.3: get_collection_params picks one of three SimpleScanParameters and
|
||||
all three leave ``filename`` at its default. get_beamline_default_raster_params
|
||||
touches no ``self``, so it is callable unbound -- step into it at daq.py:2307.
|
||||
"""
|
||||
from aarecommon.models.models import SimpleScanParameters
|
||||
|
||||
from aare.beamline_dispatch.beamline_dispatch import get_beamline_dispatch
|
||||
from aare.daq.daq import AareDAQ
|
||||
|
||||
raster_defaults = AareDAQ.get_beamline_default_raster_params(None)
|
||||
|
||||
assert raster_defaults.filename is None
|
||||
assert SimpleScanParameters().filename is None
|
||||
assert "filename" not in get_beamline_dispatch().default_rotation_parameters
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5 -- the only server-side transform (jfjoch.py:133)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# _format_dataset_settings uses no `self`, so it can be called unbound with None
|
||||
# and needs no detector, no network and no init. This is the most useful
|
||||
# breakpoint in the whole path.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status(daq_status_factory, sample_info):
|
||||
"""A real DAQStatusModel carrying a sample, so pgroup resolves to its user.
|
||||
|
||||
With ``sample=None`` JFJoch falls back to the hard-coded pgroup "p16371"
|
||||
(jfjoch.py:143) -- worth stepping through once.
|
||||
"""
|
||||
return daq_status_factory(sample=sample_info)
|
||||
|
||||
|
||||
def rotation_request(*, file_prefix: str, screening: bool) -> RotationScanRequest:
|
||||
return RotationScanRequest(
|
||||
file_prefix=file_prefix,
|
||||
exp_time_s=0.1,
|
||||
incr_omega_deg=0.1,
|
||||
steps=10,
|
||||
dtz=200,
|
||||
screening=screening,
|
||||
wedge_omega_deg=5.0 if screening else None,
|
||||
)
|
||||
|
||||
|
||||
def test_jfjoch_prepends_pgroup_raw_for_a_rotation(status):
|
||||
"""Step into _format_dataset_settings: jfjoch.py:133.
|
||||
|
||||
Two things to watch: ``data_folder`` is chosen at jfjoch.py:152-169 from the
|
||||
request type and the screening flag, then joined on at jfjoch.py:189. The
|
||||
``data/`` component came from the GUI at stage 3; JFJoch adds none for a
|
||||
plain rotation.
|
||||
"""
|
||||
from aare.devices.jfjoch import JFJochWrapper
|
||||
|
||||
settings = JFJochWrapper._format_dataset_settings(
|
||||
None, rotation_request(file_prefix="data/xtal7_001", screening=False), status
|
||||
)
|
||||
|
||||
assert settings.file_prefix == "group1/raw/data/xtal7_001"
|
||||
|
||||
|
||||
def test_jfjoch_routes_raster_requests_to_their_own_folder(status):
|
||||
"""The raster branch at jfjoch.py:152, for contrast with rotation.
|
||||
|
||||
Here JFJoch supplies ``raster`` and the GUI supplies nothing, which is why
|
||||
raster does not suffer the duplication that screening does.
|
||||
"""
|
||||
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
|
||||
from aarecommon.models.raster_grid import RasterGridRequest
|
||||
|
||||
from aare.devices.jfjoch import JFJochWrapper
|
||||
|
||||
grid = RasterGridRequest(
|
||||
file_prefix="xtal7_001_raster2d",
|
||||
exp_time_s=0.1,
|
||||
smargon_top_left=SmargonCoordinate(),
|
||||
n_x=3,
|
||||
n_y=3,
|
||||
dtz=200,
|
||||
grid_size_mm=Coordinate(x=0.05, y=0.05),
|
||||
omega_deg=0.0,
|
||||
transmission=1.0,
|
||||
)
|
||||
|
||||
settings = JFJochWrapper._format_dataset_settings(None, grid, status)
|
||||
|
||||
assert settings.file_prefix == "group1/raw/raster/xtal7_001_raster2d"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason=(
|
||||
"docs 6.1: the GUI prepends 'screening/' (rotation_data_collection.py:303) "
|
||||
"and JFJoch independently selects data_folder '{pgroup}/raw/screening' "
|
||||
"(jfjoch.py:162), so the scan kind is encoded twice. The GUI's own preview "
|
||||
"and run-number collision check build the single-level form, so display, "
|
||||
"collision check and written path disagree."
|
||||
),
|
||||
)
|
||||
def test_jfjoch_does_not_duplicate_the_screening_folder(status):
|
||||
"""Step in and compare ``data_folder`` against the incoming ``r.file_prefix``.
|
||||
|
||||
Both say "screening". Fixing this means deciding which side owns the scan
|
||||
kind -- the GUI transform at stage 3, or the JFJoch routing here.
|
||||
"""
|
||||
from aare.devices.jfjoch import JFJochWrapper
|
||||
|
||||
settings = JFJochWrapper._format_dataset_settings(
|
||||
None, rotation_request(file_prefix="screening/xtal7_001", screening=True), status
|
||||
)
|
||||
|
||||
assert settings.file_prefix == "group1/raw/screening/xtal7_001"
|
||||
|
||||
|
||||
def test_end_to_end_gui_rotation_name_reaches_the_detector(panel, status):
|
||||
"""Stages 1-3 and 5 joined up, with no mirrored values.
|
||||
|
||||
The only hand-written string is the template a user would type. Step through
|
||||
this one to watch a name cross every boundary in the path.
|
||||
"""
|
||||
from aare.devices.jfjoch import JFJochWrapper
|
||||
from aare.gui.panels.rotation_data_collection import add_data_to_path
|
||||
|
||||
panel._formatted_date = "20260922"
|
||||
panel._sample_name = "xtal7"
|
||||
panel._puck_name = "PUCK1"
|
||||
panel._puck_pos = 3
|
||||
panel.directory_edit.setText("{date}/{puck}/{position}")
|
||||
panel.file_prefix_edit.setText("{sample}")
|
||||
|
||||
panel.update_filename() # stages 1-2
|
||||
request = rotation_request( # stage 3
|
||||
file_prefix=str(add_data_to_path(panel._filename)), screening=False
|
||||
)
|
||||
settings = JFJochWrapper._format_dataset_settings(None, request, status) # stage 5
|
||||
|
||||
assert panel._filename == "20260922/PUCK1/03/xtal7_001"
|
||||
assert request.file_prefix == "data/20260922/PUCK1/03/xtal7_001"
|
||||
assert settings.file_prefix == "group1/raw/data/20260922/PUCK1/03/xtal7_001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 6 -- what gets recorded in aaredb (aaredb.py:195)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_aaredb_records_the_prejfjoch_prefix(status):
|
||||
"""docs 6.5: ``Datasets.filepath = r.file_prefix`` stores the request value.
|
||||
|
||||
That is the value *before* JFJoch prepends ``data_folder``, so the database
|
||||
path is not the path the file was written to. This test contrasts the two
|
||||
side by side; there is no seam to call aaredb.py:195 directly without a
|
||||
database, so the assertion is on the gap itself.
|
||||
"""
|
||||
from aare.devices.jfjoch import JFJochWrapper
|
||||
|
||||
request = rotation_request(file_prefix="data/xtal7_001", screening=False)
|
||||
settings = JFJochWrapper._format_dataset_settings(None, request, status)
|
||||
|
||||
recorded_in_db = request.file_prefix # aaredb.py:195
|
||||
written_to_disk = settings.file_prefix
|
||||
|
||||
assert recorded_in_db == "data/xtal7_001"
|
||||
assert written_to_disk.endswith(recorded_in_db)
|
||||
assert written_to_disk != recorded_in_db # missing "group1/raw" and the storage root
|
||||
Reference in New Issue
Block a user